Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 6 | //! account's. A sealed folder has a key of its own, wrapped to every machine |
| 7 | //! key of every person who can read it; only someone holding the folder key |
| 8 | //! can wrap it for someone new, by running `on access` on it (which prints |
| 9 | //! whose machines it wraps for). A file's content is sealed with |
| 10 | //! XChaCha20-Poly1305 and a nonce made from the content itself, so the same |
| 11 | //! content always seals the same way and an unchanged file keeps its ID. |
| 12 | //! The host can't merge sealed files: two changes to one come back as a |
| 13 | //! conflict, and `on sync` merges it on this machine. |
| 14 |
| 15 | use std::collections::HashMap; |
| 16 | use std::sync::{Mutex, OnceLock}; |
| 17 |
| 18 | use anyhow::{Result, anyhow}; |
| 19 | use base64::Engine; |
| 20 | use chacha20poly1305::aead::{Aead, KeyInit}; |
| 21 | use chacha20poly1305::{XChaCha20Poly1305, XNonce}; |
| 22 | use hmac::{Hmac, Mac}; |
| 23 | use rusqlite::params; |
| 24 | use sha2::{Digest, Sha256}; |
| 25 | use x25519_dalek::{PublicKey, StaticSecret}; |
| 26 |
| 27 | use crate::db::Db; |
| 28 | use crate::ids; |
| 29 | use crate::paths; |
| 30 |
| 31 | /// What a sealed file's content starts with: MAGIC, the key's ID (8 bytes), |
| 32 | /// the nonce (24), then the ciphertext. |
| 33 | pub const MAGIC: &[u8] = b"VCSSEAL1"; |
| 34 |
| 35 | fn b64() -> base64::engine::GeneralPurpose { |
| 36 | base64::engine::general_purpose::STANDARD |
| 37 | } |
| 38 |
| 39 | /// A folder key's ID: what a sealed file names it by. |
| 40 | pub fn kid(key: &[u8; 32]) -> [u8; 8] { |
| 41 | Sha256::new().chain_update(b"vcs seal kid\n").chain_update(key).finalize()[..8].try_into().unwrap_or_default() |
| 42 | } |
| 43 |
| 44 | fn ring() -> &'static Mutex<HashMap<[u8; 8], [u8; 32]>> { |
| 45 | static RING: OnceLock<Mutex<HashMap<[u8; 8], [u8; 32]>>> = OnceLock::new(); |
| 46 | RING.get_or_init(Default::default) |
| 47 | } |
| 48 |
| 49 | /// Makes a folder key readable in this process (`open` uses it). |
| 50 | pub fn remember(key: &[u8; 32]) { |
| 51 | ring().lock().unwrap().insert(kid(key), *key); |
| 52 | } |
| 53 |
| 54 | /// Every folder key this machine has, for every project. |
| 55 | pub fn load_ring(db: &Db) -> Result<()> { |
| 56 | let mut stmt = db.conn.prepare("SELECT key FROM seal_keys")?; |
| 57 | for row in stmt.query_map([], |r| r.get::<_, String>(0))? { |
| 58 | if let Some(k) = hex32(&row?) { |
| 59 | remember(&k); |
| 60 | } |
| 61 | } |
| 62 | Ok(()) |
| 63 | } |
| 64 |
| 65 | fn hex32(text: &str) -> Option<[u8; 32]> { |
| 66 | let id = onset_core::Id::parse(text)?; |
| 67 | Some(id.0) |
| 68 | } |
| 69 |
| 70 | /// Content as a sealed folder keeps it, sealed with `key`. |
| 71 | pub fn seal(key: &[u8; 32], plain: &[u8]) -> Vec<u8> { |
| 72 | let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("any key length"); |
| 73 | mac.update(b"vcs seal nonce\n"); |
| 74 | mac.update(plain); |
| 75 | let nonce: [u8; 24] = mac.finalize().into_bytes()[..24].try_into().unwrap_or_default(); |
| 76 | let cipher = XChaCha20Poly1305::new(key.into()); |
| 77 | let sealed = cipher.encrypt(XNonce::from_slice(&nonce), plain).expect("sealing doesn't fail"); |
| 78 | let mut out = Vec::with_capacity(MAGIC.len() + 8 + 24 + sealed.len()); |
| 79 | out.extend_from_slice(MAGIC); |
| 80 | out.extend_from_slice(&kid(key)); |
| 81 | out.extend_from_slice(&nonce); |
| 82 | out.extend_from_slice(&sealed); |
| 83 | out |
| 84 | } |
| 85 |
| 86 | pub fn is_sealed(data: &[u8]) -> bool { |
| 87 | data.starts_with(MAGIC) && data.len() >= MAGIC.len() + 8 + 24 + 16 |
| 88 | } |
| 89 |
| 90 | /// Content as it reads: opened when it's sealed with a key this machine has. |
| 91 | pub fn open(data: Vec<u8>) -> Vec<u8> { |
| 92 | try_open(&data).unwrap_or(data) |
| 93 | } |
| 94 |
| 95 | /// A sealed file's plain content (None: not sealed, or no key here). |
| 96 | pub fn try_open(data: &[u8]) -> Option<Vec<u8>> { |
| 97 | if !is_sealed(data) { |
| 98 | return None; |
| 99 | } |
| 100 | let at = MAGIC.len(); |
| 101 | let kid: [u8; 8] = data[at..at + 8].try_into().ok()?; |
| 102 | let key = *ring().lock().unwrap().get(&kid)?; |
| 103 | XChaCha20Poly1305::new((&key).into()).decrypt(XNonce::from_slice(&data[at + 8..at + 32]), &data[at + 32..]).ok() |
| 104 | } |
| 105 |
| 106 | // This machine's key. |
| 107 |
| 108 | fn machine_path() -> std::path::PathBuf { |
| 109 | paths::home().join("seal-key") |
| 110 | } |
| 111 |
| 112 | /// This machine's X25519 key, made the first time it's needed. |
| 113 | pub fn machine() -> Result<StaticSecret> { |
| 114 | if let Ok(bytes) = std::fs::read(machine_path()) |
| 115 | && let Ok(b) = <[u8; 32]>::try_from(bytes.as_slice()) |
| 116 | { |
| 117 | return Ok(StaticSecret::from(b)); |
| 118 | } |
| 119 | let secret = StaticSecret::from(rand::random::<[u8; 32]>()); |
| 120 | std::fs::create_dir_all(paths::home())?; |
| 121 | let tmp = machine_path().with_extension("tmp"); |
| 122 | std::fs::write(&tmp, secret.to_bytes())?; |
| 123 | #[cfg(unix)] |
| 124 | { |
| 125 | use std::os::unix::fs::PermissionsExt; |
| 126 | std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?; |
| 127 | } |
| 128 | std::fs::rename(&tmp, machine_path())?; |
| 129 | Ok(secret) |
| 130 | } |
| 131 |
| 132 | /// A machine key as hosts keep it: "x25519:<base64>". |
| 133 | pub fn public_text(key: &PublicKey) -> String { |
| 134 | format!("x25519:{}", b64().encode(key.as_bytes())) |
| 135 | } |
| 136 |
| 137 | pub fn parse_public(text: &str) -> Option<PublicKey> { |
| 138 | let bytes: [u8; 32] = b64().decode(text.strip_prefix("x25519:")?).ok()?.try_into().ok()?; |
| 139 | Some(PublicKey::from(bytes)) |
| 140 | } |
| 141 |
| 142 | /// A short form of a machine key, for people to tell machines apart. |
| 143 | pub fn fingerprint(text: &str) -> String { |
| 144 | let hash = Sha256::digest(text.as_bytes()); |
| 145 | hash[..6].iter().map(|b| format!("{b:02x}")).collect() |
| 146 | } |
| 147 |
| 148 | fn wrap_key(shared: &[u8], from: &PublicKey, to: &PublicKey) -> [u8; 32] { |
| 149 | let mut salt = Vec::with_capacity(64); |
| 150 | salt.extend_from_slice(from.as_bytes()); |
| 151 | salt.extend_from_slice(to.as_bytes()); |
| 152 | let mut out = [0u8; 32]; |
| 153 | hkdf::Hkdf::<Sha256>::new(Some(&salt), shared).expand(b"vcs seal wrap", &mut out).expect("32 bytes is a valid length"); |
| 154 | out |
| 155 | } |
| 156 |
| 157 | /// A folder key wrapped for one machine key (a sealed box: a key made for |
| 158 | /// this wrap, then XChaCha20-Poly1305). |
| 159 | pub fn wrap(to: &PublicKey, key: &[u8; 32]) -> String { |
| 160 | let ephemeral = StaticSecret::from(rand::random::<[u8; 32]>()); |
| 161 | let from = PublicKey::from(&ephemeral); |
| 162 | let k = wrap_key(ephemeral.diffie_hellman(to).as_bytes(), &from, to); |
| 163 | let nonce: [u8; 24] = rand::random(); |
| 164 | let sealed = XChaCha20Poly1305::new((&k).into()).encrypt(XNonce::from_slice(&nonce), key.as_slice()).expect("sealing doesn't fail"); |
| 165 | let mut out = Vec::with_capacity(32 + 24 + sealed.len()); |
| 166 | out.extend_from_slice(from.as_bytes()); |
| 167 | out.extend_from_slice(&nonce); |
| 168 | out.extend_from_slice(&sealed); |
| 169 | b64().encode(out) |
| 170 | } |
| 171 |
| 172 | pub fn unwrap(secret: &StaticSecret, wrapped: &str) -> Option<[u8; 32]> { |
| 173 | let bytes = b64().decode(wrapped).ok()?; |
| 174 | if bytes.len() < 32 + 24 + 16 { |
| 175 | return None; |
| 176 | } |
| 177 | let from = PublicKey::from(<[u8; 32]>::try_from(&bytes[..32]).ok()?); |
| 178 | let to = PublicKey::from(secret); |
| 179 | let k = wrap_key(secret.diffie_hellman(&from).as_bytes(), &from, &to); |
| 180 | let plain = XChaCha20Poly1305::new((&k).into()).decrypt(XNonce::from_slice(&bytes[32..56]), &bytes[56..]).ok()?; |
| 181 | plain.try_into().ok() |
| 182 | } |
| 183 |
| 184 | // A project's sealed folders, as this machine knows them. |
| 185 |
| 186 | pub struct Seals { |
| 187 | /// Sealed folders, longest first (the deepest one holding a path decides). |
| 188 | pub folders: Vec<String>, |
| 189 | /// Each folder's keys here, newest last. |
| 190 | keys: HashMap<String, Vec<[u8; 32]>>, |
| 191 | } |
| 192 |
| 193 | impl Seals { |
| 194 | pub fn load(db: &Db, project: &str) -> Result<Seals> { |
| 195 | let mut folders: Vec<String> = db.conn.prepare("SELECT path FROM seal_folders WHERE project = ?1")?.query_map([project], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?; |
| 196 | folders.sort_by_key(|f: &String| std::cmp::Reverse(f.len())); |
| 197 | let mut keys: HashMap<String, Vec<[u8; 32]>> = HashMap::new(); |
| 198 | let mut stmt = db.conn.prepare("SELECT path, key FROM seal_keys WHERE project = ?1 ORDER BY at, rowid")?; |
| 199 | for row in stmt.query_map([project], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? { |
| 200 | let (path, key) = row?; |
| 201 | if let Some(k) = hex32(&key) { |
| 202 | keys.entry(path).or_default().push(k); |
| 203 | } |
| 204 | } |
| 205 | Ok(Seals { folders, keys }) |
| 206 | } |
| 207 |
| 208 | pub fn is_empty(&self) -> bool { |
| 209 | self.folders.is_empty() |
| 210 | } |
| 211 |
| 212 | /// The sealed folder holding `path`, if any. |
| 213 | pub fn folder_of(&self, path: &str) -> Option<&str> { |
| 214 | self.folders.iter().find(|f| onset_core::tree::under(path, f)).map(String::as_str) |
| 215 | } |
| 216 |
| 217 | /// The key new content in `folder` is sealed with (its newest here). |
| 218 | pub fn writer(&self, folder: &str) -> Option<&[u8; 32]> { |
| 219 | self.keys.get(folder).and_then(|k| k.last()) |
| 220 | } |
| 221 |
| 222 | /// Every key `folder` has had here, oldest first: its earlier files are sealed with the older ones. |
| 223 | pub fn all(&self, folder: &str) -> &[[u8; 32]] { |
| 224 | self.keys.get(folder).map(Vec::as_slice).unwrap_or(&[]) |
| 225 | } |
| 226 |
| 227 | /// Sealed folders this machine has no key for: their files are kept as they are, never written out. |
| 228 | pub fn unreadable(&self) -> Vec<String> { |
| 229 | self.folders.iter().filter(|f| self.writer(f).is_none()).cloned().collect() |
| 230 | } |
| 231 |
| 232 | /// A file's content as it's kept: sealed when it's in a sealed folder. |
| 233 | pub fn keep(&self, path: &str, plain: Vec<u8>) -> Result<Vec<u8>> { |
| 234 | match self.folder_of(path) { |
| 235 | None => Ok(plain), |
| 236 | Some(folder) => { |
| 237 | let key = self.writer(folder).ok_or_else(|| anyhow!("{path} is in sealed folder {folder}, and this machine has no key for it"))?; |
| 238 | Ok(seal(key, &plain)) |
| 239 | } |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 |
| 244 | /// A merge's conflicts in sealed files, merged here (the host can't read |
| 245 | /// them, so it can't merge them): each side opened, merged as text, and |
| 246 | /// sealed again; what's still a conflict keeps its markers, sealed. |
| 247 | 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<()> { |
| 248 | use onset_core::tree::{self, EntryKind}; |
| 249 | // Landing, the host's merge leaves them as conflicts; syncing, as notes that kept your side. |
| 250 | let paths: Vec<String> = 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(); |
| 251 | if paths.is_empty() { |
| 252 | return Ok(()); |
| 253 | } |
| 254 | for o in m.objects.objects.values() { |
| 255 | store.put(o)?; |
| 256 | } |
| 257 | let read = |t: Option<&onset_core::Id>, path: &str| -> Result<Option<(EntryKind, Vec<u8>)>> { |
| 258 | let Some(t) = t else { return Ok(None) }; |
| 259 | Ok(match tree::lookup(store, t, path)? { |
| 260 | Some(e) if e.kind == EntryKind::File || e.kind == EntryKind::Exec => Some((e.kind, store.read_file(&e.id)?)), |
| 261 | _ => None, |
| 262 | }) |
| 263 | }; |
| 264 | let (mut puts, mut settled, mut marked) = (Vec::new(), Vec::new(), Vec::new()); |
| 265 | for path in &paths { |
| 266 | let (b, o, t) = (read(base, path)?, read(Some(ours), path)?, read(Some(theirs), path)?); |
| 267 | // A file on both sides, open here (a deletion against an edit stays as it was). |
| 268 | let (Some((kind, ov)), Some((_, tv))) = (o, t) else { continue }; |
| 269 | let bv = b.map(|x| x.1).unwrap_or_default(); |
| 270 | if is_sealed(&ov) || is_sealed(&tv) || is_sealed(&bv) { |
| 271 | continue; |
| 272 | } |
| 273 | let Some(key) = seals.folder_of(path).and_then(|f| seals.writer(f)) else { continue }; |
| 274 | let merged = onset_core::merge::text_for(path, &bv, &ov, &tv, labels.0, labels.1); |
| 275 | puts.push(tree::Put { path: path.clone(), kind, content: seal(key, &merged.content) }); |
| 276 | if merged.conflicts > 0 { marked.push(path.clone()) } else { settled.push(path.clone()) } |
| 277 | } |
| 278 | if puts.is_empty() { |
| 279 | return Ok(()); |
| 280 | } |
| 281 | let Ok((tree, objects)) = tree::edit(store, &m.tree, &puts, &[])? else { return Ok(()) }; |
| 282 | for o in &objects { |
| 283 | store.put(o)?; |
| 284 | } |
| 285 | m.tree = tree; |
| 286 | m.conflicts.retain(|c| !settled.contains(&c.path) && !marked.contains(&c.path)); |
| 287 | m.notes.retain(|n| !settled.contains(&n.path) && !marked.contains(&n.path)); |
| 288 | m.conflicts.extend(marked.into_iter().map(|path| onset_core::merge::Conflict { path, kind: "text".into() })); |
| 289 | Ok(()) |
| 290 | } |
| 291 |
| 292 | pub fn put_key(db: &Db, project: &str, path: &str, key: &[u8; 32]) -> Result<()> { |
| 293 | db.conn.execute( |
| 294 | "INSERT OR IGNORE INTO seal_keys (project, path, kid, key, at) VALUES (?1, ?2, ?3, ?4, ?5)", |
| 295 | params![project, path, hex(&kid(key)), onset_core::Id(*key).hex(), ids::now()], |
| 296 | )?; |
| 297 | remember(key); |
| 298 | Ok(()) |
| 299 | } |
| 300 |
| 301 | fn hex(bytes: &[u8]) -> String { |
| 302 | bytes.iter().map(|b| format!("{b:02x}")).collect() |
| 303 | } |
| 304 |
| 305 | pub fn set_folders(db: &Db, project: &str, folders: &[String]) -> Result<()> { |
| 306 | db.conn.execute("DELETE FROM seal_folders WHERE project = ?1", [project])?; |
| 307 | for f in folders { |
| 308 | db.conn.execute("INSERT OR IGNORE INTO seal_folders (project, path) VALUES (?1, ?2)", params![project, f])?; |
| 309 | } |
| 310 | Ok(()) |
| 311 | } |
| 312 |
| 313 | pub fn state(db: &Db, project: &str) -> Result<(u64, u64)> { |
| 314 | use rusqlite::OptionalExtension; |
| 315 | 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()?; |
| 316 | Ok(row.map_or((0, 0), |(s, h)| (s as u64, h as u64))) |
| 317 | } |
| 318 |
| 319 | pub fn set_state(db: &Db, project: &str, seen: Option<u64>, host: Option<u64>) -> Result<()> { |
| 320 | let (s, h) = state(db, project)?; |
| 321 | 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])?; |
| 322 | Ok(()) |
| 323 | } |
| 324 |
| 325 | // With the host. |
| 326 |
| 327 | #[derive(serde::Deserialize)] |
| 328 | struct HostWrap { |
| 329 | recipient: String, |
| 330 | wrapped: String, |
| 331 | } |
| 332 |
| 333 | #[derive(serde::Deserialize)] |
| 334 | struct HostWaiting { |
| 335 | handle: String, |
| 336 | recipient: String, |
| 337 | } |
| 338 |
| 339 | #[derive(serde::Deserialize)] |
| 340 | struct HostFolder { |
| 341 | path: String, |
| 342 | /// The key new files take, and who has it. |
| 343 | #[serde(default)] |
| 344 | kid: Option<String>, |
| 345 | #[serde(default)] |
| 346 | readers: Vec<String>, |
| 347 | #[serde(default)] |
| 348 | wraps: Vec<HostWrap>, |
| 349 | /// Machines it's for that don't have the key yet. |
| 350 | #[serde(default)] |
| 351 | waiting: Vec<HostWaiting>, |
| 352 | /// Machines that have it. |
| 353 | #[serde(default)] |
| 354 | machines: Vec<HostWaiting>, |
| 355 | } |
| 356 |
| 357 | /// One reader's machines for a sealed folder, by fingerprint. |
| 358 | pub struct Machines { |
| 359 | pub handle: String, |
| 360 | pub have: Vec<String>, |
| 361 | pub waiting: Vec<String>, |
| 362 | } |
| 363 |
| 364 | /// The machines that hold a sealed folder's key and those waiting for it, for `on access <path>`. |
| 365 | pub fn machines_of(project: &crate::db::Project, path: &str) -> Result<Vec<Machines>> { |
| 366 | let list: HostSeals = crate::remote::Remote::for_project(project).get_json("/sealed")?; |
| 367 | let Some(f) = list.sealed.into_iter().find(|f| f.path == path) else { return Ok(Vec::new()) }; |
| 368 | let mut out: Vec<Machines> = Vec::new(); |
| 369 | for (m, waiting) in f.machines.iter().map(|m| (m, false)).chain(f.waiting.iter().map(|m| (m, true))) { |
| 370 | let i = match out.iter().position(|x| x.handle == m.handle) { |
| 371 | Some(i) => i, |
| 372 | None => { |
| 373 | out.push(Machines { handle: m.handle.clone(), have: Vec::new(), waiting: Vec::new() }); |
| 374 | out.len() - 1 |
| 375 | } |
| 376 | }; |
| 377 | if waiting { out[i].waiting.push(fingerprint(&m.recipient)) } else { out[i].have.push(fingerprint(&m.recipient)) } |
| 378 | } |
| 379 | Ok(out) |
| 380 | } |
| 381 |
| 382 | #[derive(serde::Deserialize)] |
| 383 | struct HostSeals { |
| 384 | #[serde(default)] |
| 385 | #[serde(rename = "gen")] |
| 386 | generation: u64, |
| 387 | #[serde(default)] |
| 388 | sealed: Vec<HostFolder>, |
| 389 | } |
| 390 |
| 391 | /// The project's sealed folders as the host says, and the keys wrapped for |
| 392 | /// this machine, opened. A folder sealed or unsealed since is read again in |
| 393 | /// this machine's workspaces, to be kept the new way. |
| 394 | pub fn sync(app: &crate::app::App, project: &crate::db::Project) -> Result<()> { |
| 395 | let remote = crate::remote::Remote::for_project(project); |
| 396 | let list: HostSeals = match remote.get_json("/sealed") { |
| 397 | Ok(l) => l, |
| 398 | Err(e) => { |
| 399 | let fail = crate::out::Fail::from(e); |
| 400 | // A host without sealed folders. |
| 401 | if fail.code != "not_found" { |
| 402 | return Err(fail.into_anyhow()); |
| 403 | } |
| 404 | HostSeals { generation: 0, sealed: Vec::new() } |
| 405 | } |
| 406 | }; |
| 407 | let me = machine()?; |
| 408 | let mine = public_text(&PublicKey::from(&me)); |
| 409 | let earlier = Seals::load(&app.db, &project.id)?; |
| 410 | let (before, closed) = (earlier.folders.clone(), earlier.unreadable()); |
| 411 | let now: Vec<String> = list.sealed.iter().map(|f| f.path.clone()).collect(); |
| 412 | set_folders(&app.db, &project.id, &now)?; |
| 413 | let mut lacking = false; |
| 414 | for f in &list.sealed { |
| 415 | for w in f.wraps.iter().filter(|w| w.recipient == mine) { |
| 416 | if let Some(k) = unwrap(&me, &w.wrapped) { |
| 417 | put_key(&app.db, &project.id, &f.path, &k)?; |
| 418 | } |
| 419 | } |
| 420 | let seals = Seals::load(&app.db, &project.id)?; |
| 421 | match seals.writer(&f.path) { |
| 422 | // Waiting already says this machine is. |
| 423 | None => lacking |= !f.waiting.iter().any(|w| w.recipient == mine), |
| 424 | // This machine has the folder's key: it brings in the machines waiting for it. |
| 425 | Some(k) if !f.waiting.is_empty() && f.kid.as_deref() == Some(hex(&kid(k)).as_str()) => { |
| 426 | let _ = bring_in(&remote, &seals, f); |
| 427 | } |
| 428 | Some(_) => {} |
| 429 | } |
| 430 | } |
| 431 | // This machine's key on the account, and a word to the folder's readers that it's waiting. |
| 432 | if lacking { |
| 433 | let _ = remote.post_host::<serde_json::Value>("/keys", &serde_json::json!({ "key": mine })); |
| 434 | let _ = remote.send_json::<serde_json::Value>("POST", "/sealed/waiting", &serde_json::json!({})); |
| 435 | } |
| 436 | let changed: Vec<String> = now.iter().filter(|p| !before.contains(p)).chain(before.iter().filter(|p| !now.contains(p))).cloned().collect(); |
| 437 | if !changed.is_empty() { |
| 438 | reread(app, &project.id, &changed)?; |
| 439 | } |
| 440 | let still = Seals::load(&app.db, &project.id)?.unreadable(); |
| 441 | let opened: Vec<String> = closed.into_iter().filter(|f| now.contains(f) && !still.contains(f)).collect(); |
| 442 | if !opened.is_empty() { |
| 443 | write_opened(app, &project.id, &opened, &still)?; |
| 444 | } |
| 445 | set_state(&app.db, &project.id, Some(list.generation), Some(list.generation))?; |
| 446 | Ok(()) |
| 447 | } |
| 448 |
| 449 | /// Wraps each of a folder's keys for the machines waiting for it. |
| 450 | fn bring_in(remote: &crate::remote::Remote, seals: &Seals, f: &HostFolder) -> Result<()> { |
| 451 | let mut wraps = Vec::new(); |
| 452 | for w in &f.waiting { |
| 453 | let Some(p) = parse_public(&w.recipient) else { continue }; |
| 454 | for k in seals.all(&f.path) { |
| 455 | wraps.push(serde_json::json!({ "handle": w.handle, "recipient": w.recipient, "kid": hex(&kid(k)), "wrapped": wrap(&p, k) })); |
| 456 | } |
| 457 | } |
| 458 | if !wraps.is_empty() { |
| 459 | let _: serde_json::Value = remote.send_json("POST", "/sealed/wraps", &serde_json::json!({ "path": f.path, "kid": f.kid, "wraps": wraps }))?; |
| 460 | } |
| 461 | Ok(()) |
| 462 | } |
| 463 |
| 464 | /// Writes out the files of folders this machine can open now, in each of the |
| 465 | /// project's workspaces as its change has them (files already there stay). |
| 466 | fn write_opened(app: &crate::app::App, project: &str, folders: &[String], skip: &[String]) -> Result<()> { |
| 467 | for w in app.db.project_workspaces(project)? { |
| 468 | if crate::lazy::is_lazy(&w.id) || !w.path.is_dir() { |
| 469 | continue; |
| 470 | } |
| 471 | let Ok(change) = app.change(&w.change) else { continue }; |
| 472 | let files: onset_core::tree::Files = app |
| 473 | .files(&change.tree())? |
| 474 | .into_iter() |
| 475 | .filter(|(p, _)| folders.iter().any(|f| onset_core::tree::under(p, f)) && w.path.join(p).symlink_metadata().is_err()) |
| 476 | .collect(); |
| 477 | if files.is_empty() { |
| 478 | continue; |
| 479 | } |
| 480 | let written = crate::ws::materialize(&w.path, &app.store, &crate::ws::Index::default(), &files, &w.only, skip, None)?; |
| 481 | let mut index = crate::ws::Index::load(&w.path); |
| 482 | index.entries.extend(written.entries); |
| 483 | index.tree = None; |
| 484 | index.save(&w.path)?; |
| 485 | } |
| 486 | Ok(()) |
| 487 | } |
| 488 |
| 489 | /// Takes a folder's files out of this machine's workspaces' indexes, so the next save reads (and keeps) them again. |
| 490 | pub fn reread(app: &crate::app::App, project: &str, folders: &[String]) -> Result<()> { |
| 491 | for w in app.db.project_workspaces(project)? { |
| 492 | let mut index = crate::ws::Index::load(&w.path); |
| 493 | let before = index.entries.len(); |
| 494 | index.entries.retain(|p, _| !folders.iter().any(|f| onset_core::tree::under(p, f))); |
| 495 | if index.entries.len() != before { |
| 496 | index.tree = None; |
| 497 | index.save(&w.path)?; |
| 498 | } |
| 499 | } |
| 500 | Ok(()) |
| 501 | } |
| 502 |
| 503 | /// `on access <path> @sam [@group...] --sealed`: the folder's files are |
| 504 | /// encrypted on the machines of the people named (and yours) from here on. |
| 505 | /// Run again to bring in someone new, or a new machine of someone's; taking |
| 506 | /// someone off makes a new key for what's written after. |
| 507 | pub fn seal_folder(app: &crate::app::App, s: &mut crate::app::Session, path: &str, mut who: Vec<String>) -> crate::out::Out { |
| 508 | use crate::out::{Fail, Report}; |
| 509 | use crate::wire::Rule; |
| 510 | if !s.project.hosted() { |
| 511 | 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")); |
| 512 | } |
| 513 | if who.is_empty() || who.iter().any(|w| matches!(w.as_str(), "team" | "everyone" | "me")) { |
| 514 | return Err(Fail::new("bad_usage", "a sealed folder is for people and groups you name (@sam, @billing)").state("nothing changed")); |
| 515 | } |
| 516 | let remote = crate::remote::Remote::for_project(&s.project); |
| 517 | let before: HostSeals = remote.get_json("/sealed").map_err(|e| { |
| 518 | let f = Fail::from(e); |
| 519 | if f.code == "not_found" { Fail::new("bad_usage", "this host doesn't keep sealed folders").state("nothing changed") } else { f } |
| 520 | })?; |
| 521 | let me = machine()?; |
| 522 | let mine = public_text(&PublicKey::from(&me)); |
| 523 | #[derive(serde::Deserialize)] |
| 524 | struct Added { |
| 525 | handle: String, |
| 526 | } |
| 527 | let added: Added = remote.post_host("/keys", &serde_json::json!({ "key": mine }))?; |
| 528 | #[derive(serde::Deserialize)] |
| 529 | struct Rules { |
| 530 | rules: Vec<Rule>, |
| 531 | } |
| 532 | let old = remote.get_json::<Rules>("/rules")?.rules.into_iter().find(|r| r.path == path); |
| 533 | sync(app, &s.project)?; |
| 534 | // Who reads it: each person named, each named group's members, and you. |
| 535 | let groups: std::collections::BTreeMap<String, Vec<String>> = { |
| 536 | #[derive(serde::Deserialize)] |
| 537 | struct G { |
| 538 | groups: std::collections::BTreeMap<String, Vec<String>>, |
| 539 | } |
| 540 | remote.get_json::<G>("/groups").map(|g| g.groups).unwrap_or_default() |
| 541 | }; |
| 542 | let mut handles = std::collections::BTreeSet::new(); |
| 543 | for w in &who { |
| 544 | let name = w.trim_start_matches('@'); |
| 545 | match groups.get(name) { |
| 546 | Some(members) => handles.extend(members.iter().map(|m| m.trim_start_matches('@').to_string())), |
| 547 | None => { |
| 548 | handles.insert(name.to_string()); |
| 549 | } |
| 550 | } |
| 551 | } |
| 552 | if handles.insert(added.handle.clone()) { |
| 553 | who.push(format!("@{}", added.handle)); |
| 554 | } |
| 555 | // 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. |
| 556 | let removed = before.sealed.iter().find(|f| f.path == path).is_some_and(|f| f.readers.iter().any(|h| !handles.contains(h))); |
| 557 | let seals = Seals::load(&app.db, &s.project.id)?; |
| 558 | let key = match seals.writer(path) { |
| 559 | Some(k) if !removed => *k, |
| 560 | _ => rand::random::<[u8; 32]>(), |
| 561 | }; |
| 562 | // Every key the folder has had goes to each machine, so its earlier files open there too. |
| 563 | let mut folder_keys: Vec<[u8; 32]> = seals.all(path).to_vec(); |
| 564 | if !folder_keys.contains(&key) { |
| 565 | folder_keys.push(key); |
| 566 | } |
| 567 | let mut wraps = Vec::new(); |
| 568 | let mut machines = Vec::new(); |
| 569 | for h in &handles { |
| 570 | #[derive(serde::Deserialize)] |
| 571 | struct Keys { |
| 572 | keys: Vec<String>, |
| 573 | } |
| 574 | let keys: Keys = remote.get_host(&format!("/keys/{h}"))?; |
| 575 | let mut prints = Vec::new(); |
| 576 | for k in keys.keys.iter().filter(|k| k.starts_with("x25519:")) { |
| 577 | if let Some(p) = parse_public(k) { |
| 578 | for each in &folder_keys { |
| 579 | wraps.push(serde_json::json!({ "handle": h, "recipient": k, "kid": hex(&kid(each)), "wrapped": wrap(&p, each) })); |
| 580 | } |
| 581 | prints.push(fingerprint(k)); |
| 582 | } |
| 583 | } |
| 584 | machines.push((h.clone(), prints)); |
| 585 | } |
| 586 | let _: serde_json::Value = remote.send_json("PUT", "/rules", &Rule { path: path.to_string(), who: who.clone(), sealed: true })?; |
| 587 | let _: serde_json::Value = remote.send_json("POST", "/sealed/wraps", &serde_json::json!({ "path": path, "kid": hex(&kid(&key)), "wraps": wraps }))?; |
| 588 | put_key(&app.db, &s.project.id, path, &key)?; |
| 589 | sync(app, &s.project)?; |
| 590 | // What's in it here is kept sealed from the next save on: now. |
| 591 | reread(app, &s.project.id, &[path.to_string()])?; |
| 592 | app.save(s)?; |
| 593 | 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() }))?; |
| 594 | let mut r = Report::new(); |
| 595 | r.line("sealed", format!("{path}: encrypted on its readers' machines; the host can't read it")); |
| 596 | for (h, prints) in &machines { |
| 597 | if prints.is_empty() { |
| 598 | r.line("waiting", format!("@{h} has no machine key yet: their next Onset command in this project asks for it, and a reader's next command after that brings them in")); |
| 599 | } else { |
| 600 | r.line("machines", format!("@{h}: {} (on key shows a machine's)", prints.join(", "))); |
| 601 | } |
| 602 | } |
| 603 | if removed { |
| 604 | r.line("key", "a new key for what's written from now on; whoever was taken off keeps what they already read"); |
| 605 | } |
| 606 | 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::<Vec<_>>()); |
| 607 | r.next(format!("on land -m \"Seal {path}\" (main's earlier versions keep it readable to the host; on purge {path} takes those out)")); |
| 608 | Ok(r) |
| 609 | } |
| 610 |
| 611 | #[cfg(test)] |
| 612 | mod tests { |
| 613 | use super::*; |
| 614 |
| 615 | #[test] |
| 616 | fn the_same_content_seals_the_same_way_and_opens() { |
| 617 | let key = [7u8; 32]; |
| 618 | remember(&key); |
| 619 | let a = seal(&key, b"rates: 12%\n"); |
| 620 | assert_eq!(a, seal(&key, b"rates: 12%\n")); |
| 621 | assert_ne!(a, seal(&key, b"rates: 13%\n")); |
| 622 | assert!(is_sealed(&a) && !a.windows(5).any(|w| w == b"rates")); |
| 623 | assert_eq!(open(a), b"rates: 12%\n"); |
| 624 | } |
| 625 |
| 626 | #[test] |
| 627 | fn a_key_unknown_here_stays_sealed() { |
| 628 | let sealed = seal(&[9u8; 32], b"secret"); |
| 629 | assert_eq!(try_open(&sealed), None); |
| 630 | assert_eq!(open(sealed.clone()), sealed); |
| 631 | } |
| 632 |
| 633 | #[test] |
| 634 | fn wraps_open_only_with_their_machine_key() { |
| 635 | let alice = StaticSecret::from([1u8; 32]); |
| 636 | let bob = StaticSecret::from([2u8; 32]); |
| 637 | let key = [5u8; 32]; |
| 638 | let w = wrap(&PublicKey::from(&alice), &key); |
| 639 | assert_eq!(unwrap(&alice, &w), Some(key)); |
| 640 | assert_eq!(unwrap(&bob, &w), None); |
| 641 | assert_eq!(parse_public(&public_text(&PublicKey::from(&alice))).map(|p| *p.as_bytes()), Some(*PublicKey::from(&alice).as_bytes())); |
| 642 | } |
| 643 | } |
| 644 |