//! Sealed folders (`on access @sam --sealed`): their files are //! encrypted on the machines of the people who can read them, so the host //! keeps them without being able to read them. //! //! Each machine has an X25519 key, registered with the host as its //! account's. A sealed folder has a key of its own, wrapped to every machine //! key of every person who can read it; only someone holding the folder key //! can wrap it for someone new, by running `on access` on it (which prints //! whose machines it wraps for). A file's content is sealed with //! XChaCha20-Poly1305 and a nonce made from the content itself, so the same //! content always seals the same way and an unchanged file keeps its ID. //! The host can't merge sealed files: two changes to one come back as a //! conflict, and `on sync` merges it on this machine. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use anyhow::{Result, anyhow}; use base64::Engine; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{XChaCha20Poly1305, XNonce}; use hmac::{Hmac, Mac}; use rusqlite::params; use sha2::{Digest, Sha256}; use x25519_dalek::{PublicKey, StaticSecret}; use crate::db::Db; use crate::ids; use crate::paths; /// What a sealed file's content starts with: MAGIC, the key's ID (8 bytes), /// the nonce (24), then the ciphertext. pub const MAGIC: &[u8] = b"VCSSEAL1"; fn b64() -> base64::engine::GeneralPurpose { base64::engine::general_purpose::STANDARD } /// A folder key's ID: what a sealed file names it by. pub fn kid(key: &[u8; 32]) -> [u8; 8] { Sha256::new().chain_update(b"vcs seal kid\n").chain_update(key).finalize()[..8].try_into().unwrap_or_default() } fn ring() -> &'static Mutex> { static RING: OnceLock>> = OnceLock::new(); RING.get_or_init(Default::default) } /// Makes a folder key readable in this process (`open` uses it). pub fn remember(key: &[u8; 32]) { ring().lock().unwrap().insert(kid(key), *key); } /// Every folder key this machine has, for every project. pub fn load_ring(db: &Db) -> Result<()> { let mut stmt = db.conn.prepare("SELECT key FROM seal_keys")?; for row in stmt.query_map([], |r| r.get::<_, String>(0))? { if let Some(k) = hex32(&row?) { remember(&k); } } Ok(()) } fn hex32(text: &str) -> Option<[u8; 32]> { let id = onset_core::Id::parse(text)?; Some(id.0) } /// Content as a sealed folder keeps it, sealed with `key`. pub fn seal(key: &[u8; 32], plain: &[u8]) -> Vec { let mut mac = as Mac>::new_from_slice(key).expect("any key length"); mac.update(b"vcs seal nonce\n"); mac.update(plain); let nonce: [u8; 24] = mac.finalize().into_bytes()[..24].try_into().unwrap_or_default(); let cipher = XChaCha20Poly1305::new(key.into()); let sealed = cipher.encrypt(XNonce::from_slice(&nonce), plain).expect("sealing doesn't fail"); let mut out = Vec::with_capacity(MAGIC.len() + 8 + 24 + sealed.len()); out.extend_from_slice(MAGIC); out.extend_from_slice(&kid(key)); out.extend_from_slice(&nonce); out.extend_from_slice(&sealed); out } pub fn is_sealed(data: &[u8]) -> bool { data.starts_with(MAGIC) && data.len() >= MAGIC.len() + 8 + 24 + 16 } /// Content as it reads: opened when it's sealed with a key this machine has. pub fn open(data: Vec) -> Vec { try_open(&data).unwrap_or(data) } /// A sealed file's plain content (None: not sealed, or no key here). pub fn try_open(data: &[u8]) -> Option> { if !is_sealed(data) { return None; } let at = MAGIC.len(); let kid: [u8; 8] = data[at..at + 8].try_into().ok()?; let key = *ring().lock().unwrap().get(&kid)?; XChaCha20Poly1305::new((&key).into()).decrypt(XNonce::from_slice(&data[at + 8..at + 32]), &data[at + 32..]).ok() } // This machine's key. fn machine_path() -> std::path::PathBuf { paths::home().join("seal-key") } /// This machine's X25519 key, made the first time it's needed. pub fn machine() -> Result { if let Ok(bytes) = std::fs::read(machine_path()) && let Ok(b) = <[u8; 32]>::try_from(bytes.as_slice()) { return Ok(StaticSecret::from(b)); } let secret = StaticSecret::from(rand::random::<[u8; 32]>()); std::fs::create_dir_all(paths::home())?; let tmp = machine_path().with_extension("tmp"); std::fs::write(&tmp, secret.to_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; } std::fs::rename(&tmp, machine_path())?; Ok(secret) } /// A machine key as hosts keep it: "x25519:". pub fn public_text(key: &PublicKey) -> String { format!("x25519:{}", b64().encode(key.as_bytes())) } pub fn parse_public(text: &str) -> Option { let bytes: [u8; 32] = b64().decode(text.strip_prefix("x25519:")?).ok()?.try_into().ok()?; Some(PublicKey::from(bytes)) } /// A short form of a machine key, for people to tell machines apart. pub fn fingerprint(text: &str) -> String { let hash = Sha256::digest(text.as_bytes()); hash[..6].iter().map(|b| format!("{b:02x}")).collect() } fn wrap_key(shared: &[u8], from: &PublicKey, to: &PublicKey) -> [u8; 32] { let mut salt = Vec::with_capacity(64); salt.extend_from_slice(from.as_bytes()); salt.extend_from_slice(to.as_bytes()); let mut out = [0u8; 32]; hkdf::Hkdf::::new(Some(&salt), shared).expand(b"vcs seal wrap", &mut out).expect("32 bytes is a valid length"); out } /// A folder key wrapped for one machine key (a sealed box: a key made for /// this wrap, then XChaCha20-Poly1305). pub fn wrap(to: &PublicKey, key: &[u8; 32]) -> String { let ephemeral = StaticSecret::from(rand::random::<[u8; 32]>()); let from = PublicKey::from(&ephemeral); let k = wrap_key(ephemeral.diffie_hellman(to).as_bytes(), &from, to); let nonce: [u8; 24] = rand::random(); let sealed = XChaCha20Poly1305::new((&k).into()).encrypt(XNonce::from_slice(&nonce), key.as_slice()).expect("sealing doesn't fail"); let mut out = Vec::with_capacity(32 + 24 + sealed.len()); out.extend_from_slice(from.as_bytes()); out.extend_from_slice(&nonce); out.extend_from_slice(&sealed); b64().encode(out) } pub fn unwrap(secret: &StaticSecret, wrapped: &str) -> Option<[u8; 32]> { let bytes = b64().decode(wrapped).ok()?; if bytes.len() < 32 + 24 + 16 { return None; } let from = PublicKey::from(<[u8; 32]>::try_from(&bytes[..32]).ok()?); let to = PublicKey::from(secret); let k = wrap_key(secret.diffie_hellman(&from).as_bytes(), &from, &to); let plain = XChaCha20Poly1305::new((&k).into()).decrypt(XNonce::from_slice(&bytes[32..56]), &bytes[56..]).ok()?; plain.try_into().ok() } // A project's sealed folders, as this machine knows them. pub struct Seals { /// Sealed folders, longest first (the deepest one holding a path decides). pub folders: Vec, /// Each folder's keys here, newest last. keys: HashMap>, } impl Seals { pub fn load(db: &Db, project: &str) -> Result { let mut folders: Vec = db.conn.prepare("SELECT path FROM seal_folders WHERE project = ?1")?.query_map([project], |r| r.get(0))?.collect::>()?; folders.sort_by_key(|f: &String| std::cmp::Reverse(f.len())); let mut keys: HashMap> = HashMap::new(); let mut stmt = db.conn.prepare("SELECT path, key FROM seal_keys WHERE project = ?1 ORDER BY at, rowid")?; for row in stmt.query_map([project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? { let (path, key) = row?; if let Some(k) = hex32(&key) { keys.entry(path).or_default().push(k); } } Ok(Seals { folders, keys }) } pub fn is_empty(&self) -> bool { self.folders.is_empty() } /// The sealed folder holding `path`, if any. pub fn folder_of(&self, path: &str) -> Option<&str> { self.folders.iter().find(|f| onset_core::tree::under(path, f)).map(String::as_str) } /// The key new content in `folder` is sealed with (its newest here). pub fn writer(&self, folder: &str) -> Option<&[u8; 32]> { self.keys.get(folder).and_then(|k| k.last()) } /// Every key `folder` has had here, oldest first: its earlier files are sealed with the older ones. pub fn all(&self, folder: &str) -> &[[u8; 32]] { self.keys.get(folder).map(Vec::as_slice).unwrap_or(&[]) } /// Sealed folders this machine has no key for: their files are kept as they are, never written out. pub fn unreadable(&self) -> Vec { self.folders.iter().filter(|f| self.writer(f).is_none()).cloned().collect() } /// A file's content as it's kept: sealed when it's in a sealed folder. pub fn keep(&self, path: &str, plain: Vec) -> Result> { match self.folder_of(path) { None => Ok(plain), Some(folder) => { let key = self.writer(folder).ok_or_else(|| anyhow!("{path} is in sealed folder {folder}, and this machine has no key for it"))?; Ok(seal(key, &plain)) } } } } /// A merge's conflicts in sealed files, merged here (the host can't read /// them, so it can't merge them): each side opened, merged as text, and /// sealed again; what's still a conflict keeps its markers, sealed. pub fn merge_sealed(store: &crate::objects::Store, seals: &Seals, base: Option<&onset_core::Id>, ours: &onset_core::Id, theirs: &onset_core::Id, m: &mut onset_core::merge::TreeMerge, labels: (&str, &str)) -> Result<()> { use onset_core::tree::{self, EntryKind}; // Landing, the host's merge leaves them as conflicts; syncing, as notes that kept your side. let paths: Vec = m.conflicts.iter().map(|c| c.path.clone()).chain(m.notes.iter().map(|n| n.path.clone())).filter(|p| seals.folder_of(p).is_some()).collect(); if paths.is_empty() { return Ok(()); } for o in m.objects.objects.values() { store.put(o)?; } let read = |t: Option<&onset_core::Id>, path: &str| -> Result)>> { let Some(t) = t else { return Ok(None) }; Ok(match tree::lookup(store, t, path)? { Some(e) if e.kind == EntryKind::File || e.kind == EntryKind::Exec => Some((e.kind, store.read_file(&e.id)?)), _ => None, }) }; let (mut puts, mut settled, mut marked) = (Vec::new(), Vec::new(), Vec::new()); for path in &paths { let (b, o, t) = (read(base, path)?, read(Some(ours), path)?, read(Some(theirs), path)?); // A file on both sides, open here (a deletion against an edit stays as it was). let (Some((kind, ov)), Some((_, tv))) = (o, t) else { continue }; let bv = b.map(|x| x.1).unwrap_or_default(); if is_sealed(&ov) || is_sealed(&tv) || is_sealed(&bv) { continue; } let Some(key) = seals.folder_of(path).and_then(|f| seals.writer(f)) else { continue }; let merged = onset_core::merge::text_for(path, &bv, &ov, &tv, labels.0, labels.1); puts.push(tree::Put { path: path.clone(), kind, content: seal(key, &merged.content) }); if merged.conflicts > 0 { marked.push(path.clone()) } else { settled.push(path.clone()) } } if puts.is_empty() { return Ok(()); } let Ok((tree, objects)) = tree::edit(store, &m.tree, &puts, &[])? else { return Ok(()) }; for o in &objects { store.put(o)?; } m.tree = tree; m.conflicts.retain(|c| !settled.contains(&c.path) && !marked.contains(&c.path)); m.notes.retain(|n| !settled.contains(&n.path) && !marked.contains(&n.path)); m.conflicts.extend(marked.into_iter().map(|path| onset_core::merge::Conflict { path, kind: "text".into() })); Ok(()) } pub fn put_key(db: &Db, project: &str, path: &str, key: &[u8; 32]) -> Result<()> { db.conn.execute( "INSERT OR IGNORE INTO seal_keys (project, path, kid, key, at) VALUES (?1, ?2, ?3, ?4, ?5)", params![project, path, hex(&kid(key)), onset_core::Id(*key).hex(), ids::now()], )?; remember(key); Ok(()) } fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } pub fn set_folders(db: &Db, project: &str, folders: &[String]) -> Result<()> { db.conn.execute("DELETE FROM seal_folders WHERE project = ?1", [project])?; for f in folders { db.conn.execute("INSERT OR IGNORE INTO seal_folders (project, path) VALUES (?1, ?2)", params![project, f])?; } Ok(()) } pub fn state(db: &Db, project: &str) -> Result<(u64, u64)> { use rusqlite::OptionalExtension; let row: Option<(i64, i64)> = db.conn.query_row("SELECT seen, host FROM seal_state WHERE project = ?1", [project], |r| Ok((r.get(0)?, r.get(1)?))).optional()?; Ok(row.map_or((0, 0), |(s, h)| (s as u64, h as u64))) } pub fn set_state(db: &Db, project: &str, seen: Option, host: Option) -> Result<()> { let (s, h) = state(db, project)?; db.conn.execute("INSERT OR REPLACE INTO seal_state (project, seen, host) VALUES (?1, ?2, ?3)", params![project, seen.unwrap_or(s) as i64, host.unwrap_or(h) as i64])?; Ok(()) } // With the host. #[derive(serde::Deserialize)] struct HostWrap { recipient: String, wrapped: String, } #[derive(serde::Deserialize)] struct HostWaiting { handle: String, recipient: String, } #[derive(serde::Deserialize)] struct HostFolder { path: String, /// The key new files take, and who has it. #[serde(default)] kid: Option, #[serde(default)] readers: Vec, #[serde(default)] wraps: Vec, /// Machines it's for that don't have the key yet. #[serde(default)] waiting: Vec, /// Machines that have it. #[serde(default)] machines: Vec, } /// One reader's machines for a sealed folder, by fingerprint. pub struct Machines { pub handle: String, pub have: Vec, pub waiting: Vec, } /// The machines that hold a sealed folder's key and those waiting for it, for `on access `. pub fn machines_of(project: &crate::db::Project, path: &str) -> Result> { let list: HostSeals = crate::remote::Remote::for_project(project).get_json("/sealed")?; let Some(f) = list.sealed.into_iter().find(|f| f.path == path) else { return Ok(Vec::new()) }; let mut out: Vec = Vec::new(); for (m, waiting) in f.machines.iter().map(|m| (m, false)).chain(f.waiting.iter().map(|m| (m, true))) { let i = match out.iter().position(|x| x.handle == m.handle) { Some(i) => i, None => { out.push(Machines { handle: m.handle.clone(), have: Vec::new(), waiting: Vec::new() }); out.len() - 1 } }; if waiting { out[i].waiting.push(fingerprint(&m.recipient)) } else { out[i].have.push(fingerprint(&m.recipient)) } } Ok(out) } #[derive(serde::Deserialize)] struct HostSeals { #[serde(default)] #[serde(rename = "gen")] generation: u64, #[serde(default)] sealed: Vec, } /// The project's sealed folders as the host says, and the keys wrapped for /// this machine, opened. A folder sealed or unsealed since is read again in /// this machine's workspaces, to be kept the new way. pub fn sync(app: &crate::app::App, project: &crate::db::Project) -> Result<()> { let remote = crate::remote::Remote::for_project(project); let list: HostSeals = match remote.get_json("/sealed") { Ok(l) => l, Err(e) => { let fail = crate::out::Fail::from(e); // A host without sealed folders. if fail.code != "not_found" { return Err(fail.into_anyhow()); } HostSeals { generation: 0, sealed: Vec::new() } } }; let me = machine()?; let mine = public_text(&PublicKey::from(&me)); let earlier = Seals::load(&app.db, &project.id)?; let (before, closed) = (earlier.folders.clone(), earlier.unreadable()); let now: Vec = list.sealed.iter().map(|f| f.path.clone()).collect(); set_folders(&app.db, &project.id, &now)?; let mut lacking = false; for f in &list.sealed { for w in f.wraps.iter().filter(|w| w.recipient == mine) { if let Some(k) = unwrap(&me, &w.wrapped) { put_key(&app.db, &project.id, &f.path, &k)?; } } let seals = Seals::load(&app.db, &project.id)?; match seals.writer(&f.path) { // Waiting already says this machine is. None => lacking |= !f.waiting.iter().any(|w| w.recipient == mine), // This machine has the folder's key: it brings in the machines waiting for it. Some(k) if !f.waiting.is_empty() && f.kid.as_deref() == Some(hex(&kid(k)).as_str()) => { let _ = bring_in(&remote, &seals, f); } Some(_) => {} } } // This machine's key on the account, and a word to the folder's readers that it's waiting. if lacking { let _ = remote.post_host::("/keys", &serde_json::json!({ "key": mine })); let _ = remote.send_json::("POST", "/sealed/waiting", &serde_json::json!({})); } let changed: Vec = now.iter().filter(|p| !before.contains(p)).chain(before.iter().filter(|p| !now.contains(p))).cloned().collect(); if !changed.is_empty() { reread(app, &project.id, &changed)?; } let still = Seals::load(&app.db, &project.id)?.unreadable(); let opened: Vec = closed.into_iter().filter(|f| now.contains(f) && !still.contains(f)).collect(); if !opened.is_empty() { write_opened(app, &project.id, &opened, &still)?; } set_state(&app.db, &project.id, Some(list.generation), Some(list.generation))?; Ok(()) } /// Wraps each of a folder's keys for the machines waiting for it. fn bring_in(remote: &crate::remote::Remote, seals: &Seals, f: &HostFolder) -> Result<()> { let mut wraps = Vec::new(); for w in &f.waiting { let Some(p) = parse_public(&w.recipient) else { continue }; for k in seals.all(&f.path) { wraps.push(serde_json::json!({ "handle": w.handle, "recipient": w.recipient, "kid": hex(&kid(k)), "wrapped": wrap(&p, k) })); } } if !wraps.is_empty() { let _: serde_json::Value = remote.send_json("POST", "/sealed/wraps", &serde_json::json!({ "path": f.path, "kid": f.kid, "wraps": wraps }))?; } Ok(()) } /// Writes out the files of folders this machine can open now, in each of the /// project's workspaces as its change has them (files already there stay). fn write_opened(app: &crate::app::App, project: &str, folders: &[String], skip: &[String]) -> Result<()> { for w in app.db.project_workspaces(project)? { if crate::lazy::is_lazy(&w.id) || !w.path.is_dir() { continue; } let Ok(change) = app.change(&w.change) else { continue }; let files: onset_core::tree::Files = app .files(&change.tree())? .into_iter() .filter(|(p, _)| folders.iter().any(|f| onset_core::tree::under(p, f)) && w.path.join(p).symlink_metadata().is_err()) .collect(); if files.is_empty() { continue; } let written = crate::ws::materialize(&w.path, &app.store, &crate::ws::Index::default(), &files, &w.only, skip, None)?; let mut index = crate::ws::Index::load(&w.path); index.entries.extend(written.entries); index.tree = None; index.save(&w.path)?; } Ok(()) } /// Takes a folder's files out of this machine's workspaces' indexes, so the next save reads (and keeps) them again. pub fn reread(app: &crate::app::App, project: &str, folders: &[String]) -> Result<()> { for w in app.db.project_workspaces(project)? { let mut index = crate::ws::Index::load(&w.path); let before = index.entries.len(); index.entries.retain(|p, _| !folders.iter().any(|f| onset_core::tree::under(p, f))); if index.entries.len() != before { index.tree = None; index.save(&w.path)?; } } Ok(()) } /// `on access @sam [@group...] --sealed`: the folder's files are /// encrypted on the machines of the people named (and yours) from here on. /// Run again to bring in someone new, or a new machine of someone's; taking /// someone off makes a new key for what's written after. pub fn seal_folder(app: &crate::app::App, s: &mut crate::app::Session, path: &str, mut who: Vec) -> crate::out::Out { use crate::out::{Fail, Report}; use crate::wire::Rule; if !s.project.hosted() { return Err(Fail::new("bad_usage", "sealing a folder is for a hosted project: on this machine only, it's yours already").state("nothing changed")); } if who.is_empty() || who.iter().any(|w| matches!(w.as_str(), "team" | "everyone" | "me")) { return Err(Fail::new("bad_usage", "a sealed folder is for people and groups you name (@sam, @billing)").state("nothing changed")); } let remote = crate::remote::Remote::for_project(&s.project); let before: HostSeals = remote.get_json("/sealed").map_err(|e| { let f = Fail::from(e); if f.code == "not_found" { Fail::new("bad_usage", "this host doesn't keep sealed folders").state("nothing changed") } else { f } })?; let me = machine()?; let mine = public_text(&PublicKey::from(&me)); #[derive(serde::Deserialize)] struct Added { handle: String, } let added: Added = remote.post_host("/keys", &serde_json::json!({ "key": mine }))?; #[derive(serde::Deserialize)] struct Rules { rules: Vec, } let old = remote.get_json::("/rules")?.rules.into_iter().find(|r| r.path == path); sync(app, &s.project)?; // Who reads it: each person named, each named group's members, and you. let groups: std::collections::BTreeMap> = { #[derive(serde::Deserialize)] struct G { groups: std::collections::BTreeMap>, } remote.get_json::("/groups").map(|g| g.groups).unwrap_or_default() }; let mut handles = std::collections::BTreeSet::new(); for w in &who { let name = w.trim_start_matches('@'); match groups.get(name) { Some(members) => handles.extend(members.iter().map(|m| m.trim_start_matches('@').to_string())), None => { handles.insert(name.to_string()); } } } if handles.insert(added.handle.clone()) { who.push(format!("@{}", added.handle)); } // The folder's key: the one here, or a new one when it's newly sealed or someone who has it no longer reads it. let removed = before.sealed.iter().find(|f| f.path == path).is_some_and(|f| f.readers.iter().any(|h| !handles.contains(h))); let seals = Seals::load(&app.db, &s.project.id)?; let key = match seals.writer(path) { Some(k) if !removed => *k, _ => rand::random::<[u8; 32]>(), }; // Every key the folder has had goes to each machine, so its earlier files open there too. let mut folder_keys: Vec<[u8; 32]> = seals.all(path).to_vec(); if !folder_keys.contains(&key) { folder_keys.push(key); } let mut wraps = Vec::new(); let mut machines = Vec::new(); for h in &handles { #[derive(serde::Deserialize)] struct Keys { keys: Vec, } let keys: Keys = remote.get_host(&format!("/keys/{h}"))?; let mut prints = Vec::new(); for k in keys.keys.iter().filter(|k| k.starts_with("x25519:")) { if let Some(p) = parse_public(k) { for each in &folder_keys { wraps.push(serde_json::json!({ "handle": h, "recipient": k, "kid": hex(&kid(each)), "wrapped": wrap(&p, each) })); } prints.push(fingerprint(k)); } } machines.push((h.clone(), prints)); } let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone(), sealed: true })?; let _: serde_json::Value = remote.send_json("POST", "/sealed/wraps", &serde_json::json!({ "path": path, "kid": hex(&kid(&key)), "wraps": wraps }))?; put_key(&app.db, &s.project.id, path, &key)?; sync(app, &s.project)?; // What's in it here is kept sealed from the next save on: now. reread(app, &s.project.id, &[path.to_string()])?; app.save(s)?; app.db.add_op(&s.ws.id, "access", &serde_json::json!({ "target": path, "who": who.join(" "), "rule_before": old.map(|r| r.who).unwrap_or_default() }))?; let mut r = Report::new(); r.line("sealed", format!("{path}: encrypted on its readers' machines; the host can't read it")); for (h, prints) in &machines { if prints.is_empty() { r.line("waiting", format!("@{h} has no machine key yet: their next on sync (or get) in this project asks for it, and a reader's next on sync after that brings them in")); } else { r.line("machines", format!("@{h}: {} (on key shows a machine's)", prints.join(", "))); } } if removed { r.line("key", "a new key for what's written from now on; whoever was taken off keeps what they already read"); } r.set("path", path).set("who", who).set("sealed", true).set("machines", machines.iter().map(|(h, p)| serde_json::json!({ "handle": h, "machines": p })).collect::>()); r.next(format!("on land -m \"Seal {path}\" (main's earlier versions keep it readable to the host; on purge {path} takes those out)")); Ok(r) } #[cfg(test)] mod tests { use super::*; #[test] fn the_same_content_seals_the_same_way_and_opens() { let key = [7u8; 32]; remember(&key); let a = seal(&key, b"rates: 12%\n"); assert_eq!(a, seal(&key, b"rates: 12%\n")); assert_ne!(a, seal(&key, b"rates: 13%\n")); assert!(is_sealed(&a) && !a.windows(5).any(|w| w == b"rates")); assert_eq!(open(a), b"rates: 12%\n"); } #[test] fn a_key_unknown_here_stays_sealed() { let sealed = seal(&[9u8; 32], b"secret"); assert_eq!(try_open(&sealed), None); assert_eq!(open(sealed.clone()), sealed); } #[test] fn wraps_open_only_with_their_machine_key() { let alice = StaticSecret::from([1u8; 32]); let bob = StaticSecret::from([2u8; 32]); let key = [5u8; 32]; let w = wrap(&PublicKey::from(&alice), &key); assert_eq!(unwrap(&alice, &w), Some(key)); assert_eq!(unwrap(&bob, &w), None); assert_eq!(parse_public(&public_text(&PublicKey::from(&alice))).map(|p| *p.as_bytes()), Some(*PublicKey::from(&alice).as_bytes())); } }