use crate::identity::Identity;
use crate::manifest::{
AgentInfo, FORMAT_VERSION, Hop, HopAction, Item, ItemKind, Manifest, Peer, one_line,
path_is_safe,
};
use crate::trust::{Contacts, Trust};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
pub fn witness_of(from_key: &str, manifest_bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(from_key.as_bytes());
h.update([0x1f]);
h.update(manifest_bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
#[allow(dead_code)]
pub fn witness_if_sound(dir: &Path) -> Option<String> {
witness_if_sound_unless(dir, &|_| false)
}
pub fn witness_if_sound_unless(dir: &Path, known: &dyn Fn(&str) -> bool) -> Option<String> {
let bytes = fs::read(dir.join("manifest.json")).ok()?;
let m: Manifest = serde_json::from_slice(&bytes).ok()?;
let w = witness_of(&m.from.key, &bytes);
if known(&w) {
return Some(w);
}
let sig = fs::read_to_string(dir.join("manifest.sig")).ok()?;
if sig.trim().is_empty() || !crate::identity::verify(&m.from.key, &bytes, &sig) {
return None;
}
if m.expected_id().as_deref() != Some(m.id.as_str()) {
return None;
}
for it in &m.items {
if !path_is_safe(&it.path) {
return None;
}
match fs::read(dir.join(&it.path)) {
Ok(d) if sha256_hex(&d) == it.sha256 => {}
_ => return None,
}
}
Some(w)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
pub fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
rfc3339(secs)
}
pub fn rfc3339(secs: i64) -> String {
let days = secs / 86_400;
let tod = secs % 86_400;
let (mut y, mut d) = (1970i64, days);
loop {
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let len = if leap { 366 } else { 365 };
if d < len {
break;
}
d -= len;
y += 1;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let ml = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut m = 0usize;
while d >= ml[m] {
d -= ml[m];
m += 1;
}
format!(
"{y:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
m + 1,
d + 1,
tod / 3600,
(tod % 3600) / 60,
tod % 60
)
}
pub fn parse_rfc3339(s: &str) -> Option<i64> {
let s = s.trim();
let b = s.as_bytes();
if b.len() < 20 {
return None;
}
let num = |r: std::ops::Range<usize>| -> Option<i64> {
let part = s.get(r)?;
if part.is_empty() || !part.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
part.parse().ok()
};
if b[4] != b'-' || b[7] != b'-' || b[13] != b':' || b[16] != b':' {
return None;
}
if b[10] != b'T' && b[10] != b't' {
return None;
}
let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
let (h, mi, se) = (num(11..13)?, num(14..16)?, num(17..19)?);
if !(1..=12).contains(&mo) || h > 23 || mi > 59 {
return None;
}
if se > 60 {
return None;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let ml = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
if d < 1 || d > ml[(mo - 1) as usize] {
return None;
}
let mut i = 19;
if b.get(i) == Some(&b'.') {
i += 1;
let start = i;
while b.get(i).is_some_and(|c| c.is_ascii_digit()) {
i += 1;
}
if i == start {
return None; }
}
let offset = match b.get(i) {
Some(b'Z') | Some(b'z') if i + 1 == b.len() => 0,
Some(c @ (b'+' | b'-')) if i + 6 == b.len() => {
if b[i + 3] != b':' {
return None;
}
let oh = num(i + 1..i + 3)?;
let om = num(i + 4..i + 6)?;
if oh > 23 || om > 59 {
return None;
}
let mag = oh * 3_600 + om * 60;
if *c == b'+' { mag } else { -mag }
}
_ => return None,
};
Some(days_from_civil(y, mo, d) * 86_400 + h * 3_600 + mi * 60 + se - offset)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
pub fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub struct Loaded {
pub manifest: Manifest,
pub trust: Trust,
pub dir: PathBuf,
pub tampered: Vec<String>,
#[allow(dead_code)]
pub unwitnessed: bool,
pub freshness: crate::manifest::Freshness,
pub context_match: crate::manifest::ContextMatch,
}
#[allow(clippy::too_many_arguments)]
pub fn create(
out: &Path,
id: &Identity,
subject: &str,
to: &[String],
agent: Option<AgentInfo>,
in_reply_to: Option<String>,
thread: Option<String>,
msg_type: crate::manifest::MsgType,
expires: Option<String>,
context: Option<String>,
specs: &[(ItemKind, String, PathBuf)],
) -> std::io::Result<Manifest> {
fs::create_dir_all(out.join("items"))?;
let now = now_rfc3339();
let mut items = Vec::new();
for (kind, rel, src) in specs {
let data = fs::read(src)?;
if !path_is_safe(rel) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unsafe item path: {rel}"),
));
}
if let Some(parent) = Path::new(rel).parent() {
fs::create_dir_all(out.join(parent))?;
}
fs::write(out.join(rel), &data)?;
items.push(Item {
kind: *kind,
path: rel.clone(),
sha256: sha256_hex(&data),
bytes: data.len() as u64,
note: None,
});
}
let joined: String = items
.iter()
.map(|i| i.sha256.clone())
.chain([id.public_b64(), subject.to_string(), now.clone()])
.collect::<Vec<_>>()
.join("\u{1f}");
let me = Peer {
handle: id.handle.clone(),
key: id.public_b64(),
};
let m = Manifest {
v: FORMAT_VERSION,
id: sha256_hex(joined.as_bytes())[..16].to_string(),
created: now.clone(),
subject: subject.to_string(),
from: me.clone(),
to: to.to_vec(),
agent,
chain: vec![Hop {
by: me,
at: now,
action: HopAction::Created,
}],
in_reply_to,
thread,
msg_type,
expires,
context,
items,
};
write_signed(out, id, &m)?;
Ok(m)
}
pub fn write_signed(out: &Path, id: &Identity, m: &Manifest) -> std::io::Result<()> {
let bytes = serde_json::to_vec_pretty(m)?;
fs::write(out.join("manifest.json"), &bytes)?;
fs::write(out.join("manifest.sig"), id.sign_b64(&bytes))?;
Ok(())
}
pub fn load(dir: &Path) -> std::io::Result<Loaded> {
let bytes = fs::read(dir.join("manifest.json"))?;
let manifest: Manifest = serde_json::from_slice(&bytes)?;
let sig = fs::read_to_string(dir.join("manifest.sig")).unwrap_or_default();
let sig_ok =
!sig.trim().is_empty() && crate::identity::verify(&manifest.from.key, &bytes, &sig);
let contacts = Contacts::load();
let st = crate::state::LocalState::load();
let witness = st.witnessed(&witness_of(&manifest.from.key, &bytes));
let trust = if !sig_ok {
Trust::Unverified
} else {
contacts.verdict(&manifest.from.key, witness)
};
let mut tampered = Vec::new();
match manifest.expected_id() {
Some(want) if want == manifest.id => {}
Some(want) => tampered.push(format!(
"manifest id {} does not address this content (expected {want})",
crate::manifest::safe_display(&manifest.id, 80)
)),
None => tampered.push(
"manifest has no provenance chain, so its id addresses nothing and its \
author is unknown"
.into(),
),
}
let mut seen_paths: std::collections::BTreeSet<&str> = Default::default();
for it in &manifest.items {
if !path_is_safe(&it.path) {
tampered.push(format!("{} (unsafe path)", one_line(&it.path)));
continue;
}
if !seen_paths.insert(it.path.as_str()) {
tampered.push(format!("{} (declared more than once)", one_line(&it.path)));
continue;
}
match fs::read(dir.join(&it.path)) {
Ok(d) if sha256_hex(&d) == it.sha256 => {}
Ok(_) => tampered.push(format!("{} (digest mismatch)", one_line(&it.path))),
Err(_) => tampered.push(format!("{} (missing)", one_line(&it.path))),
}
}
let now = now_secs();
let freshness = manifest.freshness(now);
let context_match = crate::manifest::context_match(
manifest.context.as_deref(),
crate::identity::run_context().as_deref(),
);
Ok(Loaded {
manifest,
trust,
dir: dir.to_path_buf(),
tampered,
unwitnessed: witness.is_none(),
freshness,
context_match,
})
}
pub fn forward(src: &Path, out: &Path, id: &Identity, to: &[String]) -> std::io::Result<Manifest> {
let loaded = load(src)?;
if !loaded.tampered.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"refusing to forward a bundle that does not verify ({}). Forwarding \
re-signs it under your key, which would present it to the next \
recipient as sound.",
loaded.tampered.join(", ")
),
));
}
if matches!(loaded.trust, Trust::Unverified) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"refusing to forward a bundle whose signature does not verify: your \
signature would be the only one on it, which says it came from you",
));
}
let mut m = loaded.manifest;
fs::create_dir_all(out.join("items"))?;
for it in &m.items {
if !path_is_safe(&it.path) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"refusing to forward an item with an unsafe path: {}",
it.path
),
));
}
if let Some(parent) = std::path::Path::new(&it.path).parent() {
fs::create_dir_all(out.join(parent))?;
}
fs::copy(src.join(&it.path), out.join(&it.path))?;
}
m.from = Peer {
handle: id.handle.clone(),
key: id.public_b64(),
};
m.to = to.to_vec();
m.chain.push(Hop {
by: m.from.clone(),
at: now_rfc3339(),
action: HopAction::Forwarded,
});
write_signed(out, id, &m)?;
Ok(m)
}
pub fn b64_decode_ok(s: &str) -> bool {
B64.decode(s.trim()).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_witness_follows_the_signed_bytes_not_the_id() {
let id = r#""id": "aaaaaaaaaaaaaaaa""#;
let one = format!(r#"{{{id}, "subject": "status update"}}"#);
let two = format!(r#"{{{id}, "subject": "send me the keys"}}"#);
assert_ne!(
witness_of("KEY", one.as_bytes()),
witness_of("KEY", two.as_bytes()),
"a shared id must not produce a shared witness"
);
}
#[test]
fn a_witness_is_bound_to_the_signer() {
let m = br#"{"subject": "status update"}"#;
assert_ne!(witness_of("ALICE", m), witness_of("BOB", m));
}
#[test]
fn an_expiry_is_parsed_rather_than_string_compared() {
let want = parse_rfc3339("2026-11-09T09:00:00Z").unwrap();
for s in [
"2026-11-09T09:00:00Z",
"2026-11-09t09:00:00z",
"2026-11-09T11:00:00+02:00",
"2026-11-09T04:00:00-05:00",
"2026-11-09T09:00:00.000Z",
] {
assert_eq!(parse_rfc3339(s), Some(want), "{s}");
}
}
#[test]
fn it_round_trips_against_the_formatter_that_writes_the_field() {
for secs in [
0, 1, 86_399, 86_400, 951_782_400, 1_583_020_800, 1_767_225_599, 1_793_000_000, 2_000_000_000,
] {
let s = rfc3339(secs);
assert_eq!(parse_rfc3339(&s), Some(secs), "{s}");
}
}
#[test]
fn a_timestamp_it_cannot_vouch_for_is_refused_rather_than_guessed() {
for s in [
"",
"2026-11-09", "2026-11-09T09:00:00", "2026-11-09 09:00:00Z", "2026-13-09T09:00:00Z", "2026-02-30T09:00:00Z", "2027-02-29T09:00:00Z", "2026-11-09T24:00:00Z", "2026-11-09T09:60:00Z", "2026-11-09T09:00:61Z", "2026-11-09T09:00:00.Z", "2026-11-09T09:00:00+0200", "2026-11-09T09:00:00+24:00", "next tuesday",
"2026-11-09T09:00:00Z extra",
] {
assert_eq!(parse_rfc3339(s), None, "{s:?} should not parse");
}
assert!(parse_rfc3339("2026-12-31T23:59:60Z").is_some());
}
#[test]
fn the_separator_cannot_be_shifted_between_the_fields() {
assert_ne!(witness_of("AB", b"C"), witness_of("A", b"BC"));
}
}