use crate::identity::Identity;
use crate::manifest::{path_is_safe, AgentInfo, Hop, HopAction, Item, ItemKind, Manifest, Peer, FORMAT_VERSION};
use crate::trust::{Contacts, Trust};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
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()
}
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);
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 struct Loaded {
pub manifest: Manifest,
pub trust: Trust,
pub dir: PathBuf,
pub tampered: Vec<String>,
pub unwitnessed: bool,
}
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,
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,
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})",
manifest.id
)),
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)", it.path));
continue;
}
if !seen_paths.insert(it.path.as_str()) {
tampered.push(format!("{} (declared more than once)", it.path));
continue;
}
match fs::read(dir.join(&it.path)) {
Ok(d) if sha256_hex(&d) == it.sha256 => {}
Ok(_) => tampered.push(format!("{} (digest mismatch)", it.path)),
Err(_) => tampered.push(format!("{} (missing)", it.path)),
}
}
Ok(Loaded {
manifest,
trust,
dir: dir.to_path_buf(),
tampered,
unwitnessed: witness.is_none(),
})
}
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 the_separator_cannot_be_shifted_between_the_fields() {
assert_ne!(witness_of("AB", b"C"), witness_of("A", b"BC"));
}
}