diff --git a/.gitattributes b/.gitattributes index fdba906..4eef977 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ web/src/generated/** text eol=lf # The module is binary: no line-ending conversion. web/src/generated/wasm/*.wasm binary +# Preserve the pinned unified diff bytes, including required blank context lines. +web/engine-patches/local-zone-purge.patch -text -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eacfc01..db6c7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: cache-dependency-path: web/package-lock.json - run: npm ci - run: node scripts/verify-engine.mjs + - name: Test engine build provenance + run: node --test scripts/engine-source.test.mjs - name: Check foundation if: hashFiles('web/index.html') == '' run: npm run check:data diff --git a/docs/architecture.md b/docs/architecture.md index 24ff2d6..b059139 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,3 +72,17 @@ matching close-request acknowledgement. The WebView profile remains separate from browser profiles; builds do not migrate or erase it. This is an unsigned local build, with no installer or public release qualification. See [Windows build and boundaries](windows.md). + +## Local content domain foundation + +The library and collections domains add versioned, encrypted local records for +metadata/history, Study, Clips and a separately protected Journal vault. Local +source references and search projections validate their account/epoch ownership. +These contracts and adapters are independent of the workspace presentation. + +Whole-workspace erase uses the pinned engine's reviewed local-zone purge +adaptation to remove content keys and cached uploads before staging fresh empty +records atomically. Account identity, device trust, approval checkpoints and +unrelated zones remain. Failed writes preserve the prior snapshot. This is local +erasure, not remote deletion or memory zeroization. Preserve profiles and exports +when reverting: reverting code does not downgrade already-written versioned data. diff --git a/web/engine-patches/local-zone-purge.patch b/web/engine-patches/local-zone-purge.patch new file mode 100644 index 0000000..ff0fb81 --- /dev/null +++ b/web/engine-patches/local-zone-purge.patch @@ -0,0 +1,1222 @@ +diff --git a/crates/orbyn-client/src/lib.rs b/crates/orbyn-client/src/lib.rs +index 5cc193a..2531210 100644 +--- a/crates/orbyn-client/src/lib.rs ++++ b/crates/orbyn-client/src/lib.rs +@@ -62,6 +62,9 @@ pub const MAX_QUARANTINE_BYTES: usize = 16 * 1024 * 1024; + /// Restarts of one refetch before the host is told to stop. + pub const MAX_REFETCH_RESTARTS: u8 = 3; + ++/// Names accepted by one local-only purge request, bounded like account zones. ++pub const MAX_LOCAL_PURGE_ZONES: usize = 1000; ++ + /// What went wrong. Messages never include record content or keys. + #[derive(Debug, thiserror::Error, PartialEq, Eq)] + pub enum ClientError { +@@ -481,6 +484,35 @@ impl Engine { + Ok(engine) + } + ++ /// Irreversibly forgets the named zones on this engine only. ++ /// ++ /// Validates the entire bounded request before removing any zone. Duplicate ++ /// and absent names are harmless; returns the removed names in sorted order. ++ /// Each whole zone owner is dropped, including all keys, cached uploads, ++ /// records, trash, quarantine and refetch state. Identity, trust, protection, ++ /// the live clock and every unselected zone are unchanged. ++ /// ++ /// This does no IO and is not a remote delete or an upload acknowledgment. ++ /// The host must authorize the names, fence old work and persist the result. ++ /// It does not wipe allocator memory or previously stored copies. Only the ++ /// live instance unconditionally retains its HLC: snapshot restore derives ++ /// it from admissible remaining record stamps. A host needing that watermark ++ /// after restore must stage replacement records before persisting. ++ pub fn purge_local_zones(&mut self, zones: &[String]) -> Result> { ++ if zones.len() > MAX_LOCAL_PURGE_ZONES { ++ return Err(ClientError::InvalidInput); ++ } ++ if zones.iter().any(|name| !valid_name(name, 64)) { ++ return Err(ClientError::InvalidZone); ++ } ++ let names: BTreeSet<&String> = zones.iter().collect(); ++ Ok(names ++ .into_iter() ++ .filter(|name| self.zones.remove(name.as_str()).is_some()) ++ .cloned() ++ .collect()) ++ } ++ + pub fn snapshot(&self) -> Snapshot { + let zones = self + .zones +@@ -1450,6 +1482,245 @@ fn format_uuid(b: &[u8; 16]) -> String { + mod tests { + use super::*; + ++ const ACCOUNT: &str = "11111111-2222-4333-8444-555555555555"; ++ const DEVICE: &str = "0000000a-0000-4000-8000-00000000000a"; ++ ++ fn purge_engine() -> Result { ++ Engine::new(DeviceKeys::generate()?, ACCOUNT, DEVICE) ++ } ++ ++ fn stage_marker(engine: &mut Engine, zone: &str, at: u64) -> Result { ++ engine.create_zone_key(zone)?; ++ let stamp = engine.tick(at); ++ let mut fields = Fields::default(); ++ fields.set("value", serde_json::json!("synthetic-purge-marker"), stamp); ++ engine.stage(zone, "test", "record", &fields)?; ++ Ok(stamp) ++ } ++ ++ #[test] ++ fn local_purge_drops_entire_selected_owners_and_preserves_other_state() -> Result<()> { ++ let mut engine = purge_engine()?; ++ for zone in [ ++ "notes-local", ++ "notes-collections-v1", ++ "notes-library-v1", ++ "notes-local-approval", ++ "unrelated", ++ ] { ++ stage_marker(&mut engine, zone, 5_000)?; ++ } ++ let mut checkpoint = Fields::default(); ++ checkpoint.set( ++ "approvalId", ++ serde_json::json!("synthetic-approval"), ++ engine.tick(5_000), ++ ); ++ engine.stage( ++ "notes-local-approval", ++ "approval_checkpoint", ++ "verified-checkpoint", ++ &checkpoint, ++ )?; ++ engine.rotate_zone_key("notes-local")?; ++ let batch = engine.build_upload("notes-local")?.expect("batch"); ++ let wire::RecordChange::Save { ++ record_id, ++ record_type, ++ ciphertext, ++ wrapped_key, ++ .. ++ } = &batch.changes[0] ++ else { ++ panic!("save") ++ }; ++ let record = wire::Record { ++ record_id: record_id.clone(), ++ record_type: record_type.clone(), ++ version: 1, ++ ciphertext: Some(ciphertext.clone()), ++ wrapped_key: Some(wrapped_key.clone()), ++ author_device_id: DEVICE.into(), ++ modified_at: String::new(), ++ deleted: false, ++ delete_proof: None, ++ }; ++ let local = engine.zones["notes-local"].records["record"].clone(); ++ let zone = engine.zones.get_mut("notes-local").expect("zone"); ++ zone.quarantine.insert( ++ "quarantined".into(), ++ Quarantined { ++ record, ++ reason: "open_failed".into(), ++ }, ++ ); ++ zone.trash.insert( ++ "trashed".into(), ++ Trashed { ++ record_type: "test".into(), ++ fields: local.fields, ++ deleted_by: DEVICE.into(), ++ deleted_at_millis: 5_000, ++ }, ++ ); ++ zone.token = Some("token".into()); ++ zone.refetch = Some(BTreeSet::from(["record".into()])); ++ zone.refetch_restarts = 2; ++ // Seed non-default persisted trust/protection, then restore it normally. ++ let mut before = engine.snapshot(); ++ let peer = DeviceKeys::generate()?; ++ engine.pin_device("0000000b-0000-4000-8000-00000000000b", peer.public())?; ++ before.pinned = engine.snapshot().pinned; ++ before.revoked.insert( ++ "0000000c-0000-4000-8000-00000000000c".into(), ++ before.pinned[DEVICE].clone(), ++ ); ++ before.removal_sequence = 7; ++ before.protection = Some(ProtectionHead { ++ seq: 3, ++ level: wire::ProtectionLevel::Advanced, ++ hash: b64(&[3; 32]), ++ }); ++ before.protection_floor = Some(ProtectionFloor { ++ seq: 4, ++ hash: b64(&[4; 32]), ++ }); ++ before.protection_anchored = true; ++ before.protection_complete = false; ++ let keys = DeviceKeys::from_bytes(engine.keys.to_bytes().as_slice())?; ++ let mut engine = Engine::restore(keys, before.clone(), 5_000)?; ++ let stamp = engine.tick(5_000); ++ let names = ["notes-library-v1", "notes-local", "notes-collections-v1"].map(str::to_owned); ++ assert_eq!( ++ engine.purge_local_zones(&names)?, ++ vec!["notes-collections-v1", "notes-library-v1", "notes-local"] ++ ); ++ let mut expected = before; ++ for name in &names { ++ expected.zones.remove(name); ++ } ++ assert!( ++ engine.snapshot() == expected, ++ "only selected zone owners change" ++ ); ++ assert!( ++ engine ++ .record("notes-local-approval", "verified-checkpoint") ++ .is_some() ++ ); ++ assert!(engine.tick(4_000) > stamp, "live HLC retained"); ++ let restored = Engine::restore( ++ DeviceKeys::from_bytes(engine.keys.to_bytes().as_slice())?, ++ engine.snapshot(), ++ 5_000, ++ )?; ++ assert!( ++ restored.snapshot() == expected, ++ "preserved state survives restore" ++ ); ++ Ok(()) ++ } ++ ++ #[test] ++ fn local_purge_validates_the_whole_request_before_mutation() -> Result<()> { ++ let mut engine = purge_engine()?; ++ stage_marker(&mut engine, "a", 5_000)?; ++ let before = engine.snapshot(); ++ for invalid in ["".to_owned(), "\n".into(), "x".repeat(65)] { ++ assert_eq!( ++ engine.purge_local_zones(&["a".into(), invalid]), ++ Err(ClientError::InvalidZone) ++ ); ++ assert!(engine.snapshot() == before); ++ } ++ assert_eq!( ++ engine.purge_local_zones(&vec!["a".into(); 1001]), ++ Err(ClientError::InvalidInput) ++ ); ++ assert!(engine.snapshot() == before); ++ assert!(engine.purge_local_zones(&[])?.is_empty()); ++ assert_eq!( ++ engine.purge_local_zones(&vec!["a".into(); 1000])?, ++ vec!["a"] ++ ); ++ assert!( ++ engine ++ .purge_local_zones(&["a".into(), "missing".into()])? ++ .is_empty() ++ ); ++ Ok(()) ++ } ++ ++ #[test] ++ fn local_purge_bare_restore_can_lose_clock_but_tombstone_restore_retains_it() -> Result<()> { ++ let mut engine = purge_engine()?; ++ let before = stage_marker(&mut engine, "notes-local", 5_000)?; ++ engine.purge_local_zones(&["notes-local".into()])?; ++ let key_bytes = engine.keys.to_bytes(); ++ let copy_keys = || DeviceKeys::from_bytes(key_bytes.as_slice()); ++ let mut bare = Engine::restore(copy_keys()?, engine.snapshot(), 5_000)?; ++ assert!( ++ bare.tick(4_000) < before, ++ "bare snapshots contain no HLC watermark" ++ ); ++ let replacement = stage_marker(&mut engine, "notes-local", 4_000)?; ++ assert!(replacement > before, "same live engine retains its HLC"); ++ let snapshot = engine.snapshot(); ++ let mut restored = Engine::restore(copy_keys()?, snapshot.clone(), 5_000)?; ++ assert!(restored.tick(4_000) > replacement); ++ // Existing future-stamp policy deliberately excludes this watermark. ++ let future = orbyn_sync::MAX_SKEW_MILLIS + 10_000; ++ stage_marker(&mut engine, "notes-local", future)?; ++ let mut rolled_back = Engine::restore(copy_keys()?, engine.snapshot(), 0)?; ++ assert_eq!(rolled_back.skipped_future_stamps(), 1); ++ assert!(rolled_back.tick(1) < replacement); ++ Ok(()) ++ } ++ ++ #[test] ++ fn local_purge_new_keys_cannot_open_old_queued_ciphertext() -> Result<()> { ++ let mut engine = purge_engine()?; ++ stage_marker(&mut engine, "notes-local", 5_000)?; ++ let old_id = engine.current_key_id("notes-local").expect("key"); ++ let upload = engine.build_upload("notes-local")?.expect("batch"); ++ engine.purge_local_zones(&["notes-local".into()])?; ++ let new_id = engine.create_zone_key("notes-local")?; ++ assert_ne!(old_id, new_id); ++ assert!(engine.build_upload("notes-local")?.is_none()); ++ let wire::RecordChange::Save { ++ ciphertext, ++ wrapped_key, ++ .. ++ } = &upload.changes[0] ++ else { ++ panic!("save") ++ }; ++ let opened = crypt::open_record( ++ Place { ++ account: &engine.trust.account, ++ zone: "notes-local", ++ record_id: "record", ++ }, ++ "test", ++ 1, ++ &unb64(ciphertext)?, ++ &unb64(wrapped_key)?, ++ &engine.zones["notes-local"].keys, ++ ); ++ assert!(matches!(opened, Err(ClientError::MissingZoneKey { .. }))); ++ let answer = wire::UploadResult { ++ token: "late".into(), ++ modified_at: String::new(), ++ records: vec![], ++ replayed: false, ++ }; ++ assert_eq!( ++ engine.upload_succeeded("notes-local", &answer), ++ Err(ClientError::NoUpload) ++ ); ++ Ok(()) ++ } ++ + #[test] + fn operation_ids_are_version_4_uuids() -> Result<()> { + let id = uuid_v4()?; +diff --git a/crates/orbyn-wasm/README.md b/crates/orbyn-wasm/README.md +index 284bdf1..be5e7ce 100644 +--- a/crates/orbyn-wasm/README.md ++++ b/crates/orbyn-wasm/README.md +@@ -1,101 +1,102 @@ +-# orbyn-wasm +- +-JavaScript bindings, made with wasm-bindgen (MIT OR Apache-2.0), for the device crates: sign-in (`orbyn-auth-client`), device keys (`orbyn-crypto`) and the sync engine (`orbyn-client`). Data goes in and out as JSON in the `orbyn-contracts` wire shapes (their TypeScript is in `crates/orbyn-contracts/bindings`), and bytes as `Uint8Array`. A failure throws an `Error` whose message starts with a stable code, such as `missing_zone_key: ...`. +- +-The crate also builds natively, so its tests run with `cargo test`. The JavaScript package itself is not built or tested in CI yet (that needs the `wasm-bindgen` CLI and a JavaScript runtime); CI only checks that the crate compiles for `wasm32-unknown-unknown`. +- +-## Secrets stay inside WebAssembly +- +-No function returns device keys, zone keys or the OPAQUE export key. What the host stores, the device keys and the engine snapshot, comes out sealed (XChaCha20-Poly1305) under a key derived inside WebAssembly with HKDF-SHA256 from a *wrapping secret* the host passes in. +- +-The host gets the wrapping secret from a WebCrypto key that cannot be exported: +- +-```js +-// Once per device: a non-extractable HMAC key, kept in IndexedDB (structured +-// clone keeps it non-extractable). +-const wrapKey = await crypto.subtle.generateKey( +- { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); +- +-// Each launch: the wrapping secret is the key's MAC over a fixed label. +-const label = new TextEncoder().encode("orbyn/local-wrap/v1"); +-const wrapSecret = new Uint8Array(await crypto.subtle.sign("HMAC", wrapKey, label)); +- +-const sealedKeys = keys.export(wrapSecret); // store this +-const keys2 = DeviceKeys.import(sealedKeys, wrapSecret); +-const sealedState = engine.snapshot(wrapSecret); // store this +-const engine2 = SyncEngine.restore(keys2, sealedState, wrapSecret, Date.now()); +-wrapSecret.fill(0); +-``` +- +-The functions that take the wrapping secret or the password (`export`, `import`, `snapshot`, `restore`, `startRegistration`, `finishRegistration`, `startLogin`, `finishLogin`) take them as owned buffers (`Vec` in Rust), which WebAssembly zeroes when the call ends, on every path including errors. A borrowed `&[u8]` would be copied into WebAssembly memory by the generated glue and freed without being zeroed. The `fill(0)` above clears only the JavaScript copy. Not covered: buffers inside `opaque-ke`, `argon2` and the hash functions while they run, which this crate does not own; a check that freed blocks held no copy of the password or secret found none on the error paths it exercised, but a full OPAQUE exchange was not part of it. +- +-Why this and not AES-GCM through `crypto.subtle.encrypt` inside WebAssembly: it keeps every call synchronous and testable natively, and needs no `web-sys` or futures. The trade-off: the 32-byte wrapping secret is in JavaScript memory while it is used. Script running in the page could compute it anyway with the non-extractable key, so it reveals nothing that key does not; what it never sees is a device key or zone key. The snapshot is also bound to the device's public signing key, so one device's snapshot does not open with another's keys. +- +-## Building for the web +- +-Nothing in CI builds the JavaScript package yet; CI only runs `cargo check --target wasm32-unknown-unknown`. To build it by hand, install the `wasm-bindgen` CLI at the same version as the `wasm-bindgen` crate in `Cargo.lock` (the two must match), then: +- +-```bash +-cargo build -p orbyn-wasm --release --target wasm32-unknown-unknown +-wasm-bindgen --target web --out-dir pkg \ +- target/wasm32-unknown-unknown/release/orbyn_wasm.wasm +-``` +- +-`pkg/` then holds `orbyn_wasm.js`, its `.d.ts` and the `.wasm` file. `.cargo/config.toml` already sets the `getrandom` backend for the browser. Optionally shrink the module with `wasm-opt -Oz`. +- +-## The planner's `AuthCrypto` interface +- +-| `AuthCrypto` | This crate | Notes | +-| --- | --- | --- | +-| `startRegistration(password)` | `startRegistration(password: Uint8Array): PendingRegistration` | `.request` is `RegisterStart.registration_request` | +-| `finishRegistration(...)` | `finishRegistration(pending, password, response): Uint8Array` | Returns `RegisterFinish.registration_upload`; works once | +-| `startLogin(password)` | `startLogin(password: Uint8Array): PendingLogin` | `.request` is `LoginStart.credential_request` | +-| `finishLogin(...)` | `finishLogin(pending, password, response): Uint8Array` | Returns `LoginFinish.credential_finalization`; a wrong password throws `auth_failed` | +-| device keys: create | `DeviceKeys.create()` | | +-| device keys: export | `keys.export(wrapSecret): Uint8Array` | Sealed; see above | +-| device keys: import | `DeviceKeys.import(sealed, wrapSecret)` | | +-| public keys | `keys.signingKey()`, `keys.agreementKey()` | Base64url, as `NewDevice` wants them | +-| `sign(context, message)` | `keys.sign(context, message): Uint8Array` | Any context; prefer the helpers below | +-| sign-up signature | `keys.signRegistration(email, upload)` | `register_message` under `orbyn/device-register/v1`; normalises the email | +-| sign-in signature | `keys.signLogin(loginId)` | `login_message` under `orbyn/device-login/v1`; `loginId` is the UUID from `LoginStarted` | +-| trusted start proof | `keys.signTrustedStart(email, credentialRequest, signedAt)` | `trusted_start_message`; `signedAt` in Unix seconds | +-| email | `normalizeEmail(email): string \| undefined` | | +- +-The OPAQUE export key never leaves WebAssembly: it is wiped when `finishRegistration` or `finishLogin` returns. It will be used inside the module when Advanced protection needs it. +- +-## Approving a device +- +-Both screens show the same six digits; the person confirms on each. The protocol and its exact message order are in `docs/architecture.md` (Accounts and devices). The calls, in order: +- +-| Step | Approver (trusted device) | New device | +-| --- | --- | --- | +-| 1 | `s = engine.startApproval(pendingDeviceJson, nowSecs)`; send `s.createJson` to `POST /v1/devices/approvals` | | +-| 2 | | read `GET /v1/devices/current`; `d = engine.joinApproval(approvalJson, nowSecs)`; send `d.nonceJson` to `…/{id}/nonce` | +-| 3 | read `GET /v1/devices/approvals/{id}`; send `s.reveal(approvalJson)` to `…/{id}/reveal` | | +-| 4 | show `s.sas()` and `s.deviceFingerprint()`; only on "They match": send `engine.confirmApproval(s, deviceListJson)` to `…/{id}/confirm` | read `GET /v1/devices/current`; show `d.sas(approvalJson)` | +-| 5 | | only on "They match": `code = engine.finishApproval(d, approvalJson, typedOrUndefined, nowSecs)`; send `{code}` to `…/{id}/code` | +- +-On "They don't match" either device sends `…/{id}/cancel`. Each session works once; a new attempt needs a new approval. `finishApproval` refuses until the approver has confirmed, so poll `GET /v1/devices/current` until `signature` is set. +- +-Protection level: `engine.signProtection('standard' | 'advanced', nowSecs)` gives the body of `POST /v1/keychain/protection`, the next link of the chain this device verified. After any change, and on start, fetch `GET /v1/keychain/protection?from=` with `engine.protectionFrom()` (the verified head, or for a device just approved the sequence its approver vouched for), page by page while `more`, and give each answer, with the device list, to `engine.verifyProtection(chainJson, devicesJson)`; it returns `{seq, state, level, stopped}`, moves the verified head forward over every link a trusted device signed or committed to, and throws `protection_rollback` or `protection_fork` without changing anything. `engine.protectionLevel()` is `{state, level}`: `verified` (the whole chain was walked to the server's head, from an anchor this device trusts, and nothing stopped it: the only state in which Standard may be acted on), `provisional` (a guess: show it, never act on it; `stopped` says why a walk stopped) or `unknown` (no level, not Standard). To move the chain past a link this device could not verify, show the person the setting and, only if they confirm, send `engine.resignProtection(level, serverHeadLinkJson, nowSecs)` as the same `POST`. On a 409 `protection_conflict`, fetch, verify and sign again; a head that is the setting you sent means it went through. +- +-Removing a device: send `engine.signRemoval(deviceJson)` as the body of `DELETE /v1/devices/{id}`. Deleting a zone: send `engine.deleteZoneProof(zone)` as the body of `DELETE /v1/zones/{zone}`. +- +-## Losing local data +- +-Clearing the site's data (or the browser's storage for it) deletes the sealed device keys, the snapshot and the wrapping key, and none of them can be recovered: the server never had them. If the person has another trusted device, sign in again as a new device, approve it from that device and let it share the zone keys again; the records come back from the server. If this was the person's only device, its encrypted data is lost for good until Standard or Advanced protection (with recovery) exists. +- +-## Sync engine +- +-`SyncEngine.create(keys, accountId, deviceId)` (from `SessionCreated`) or `SyncEngine.restore(keys, sealed, wrapSecret, nowMillis)`, then: +- +-- `snapshot(wrapSecret)`: the sealed state to store. +-- `tick(now)`: a `Stamp` for a new edit. `tickAt(editAt, now)`: a `Stamp` for an edit made at `editAt`, which may be earlier than `now` (queued while the app was closed). It is never earlier than the last stamp this device issued or saw (an older `editAt` is moved just after it), and an `editAt` after `now` is taken as `now`, silently: an edit cannot be in the future, and a stamp ahead of the clock would be dropped by other devices. `restore` no longer moves the clock to its own `now`, so a later `tickAt` can use the edit's own time; it keeps a record whose newest stamp is more than an hour ahead of `now` but does not let it move the clock, and `skippedFutureStamps()` says how many there were: log a non-zero count. `stage(zone, recordType, id, fieldsJson)`, `stageItem(zone, itemJson, beforeJson?, now)`, `delete(zone, id)`. +-- `record(zone, id)`: `{"record_type": ..., "fields": Fields}` (this was the bare `Fields` before), `item(zone, id)`, `token(zone)`. +-- `quarantined(zone)`: `[{"record_id": ..., "reason": ...}]` for the records waiting for a key, with the reason `missing_zone_key`, `open_failed` or `invalid_record`; never their bytes. +-- `buildUpload(zone)`: an `UploadChanges` (the same batch until answered), then `uploadSucceeded(zone, uploadResultJson)`, `uploadConflicted(zone, recordChangedJson, now)` or `abandonUpload(zone)`. +-- `applyChanges(zone, zoneChangesJson, now)` (and `applyChangesFrom(zone, since, zoneChangesJson, now)`, required while a zone is refetched after `tokenExpired`): returns `{changed, removed, trashed, quarantined, unverified_deletes, rejected, more}`. +-- `retryQuarantined(zone, now)` after new keys arrive; `trash(zone)` and `takeFromTrash(zone, id)`. +-- `checkZones(zoneListJson)`: returns `{stale, next}`; follow `next` as `?after=`. +-- Devices and keys: `verifyDevices(deviceListJson)` (the ids this device trusts: its approver, the devices that approver endorsed, and their approval chains, minus signed removals), `pinDevice(id, signingKey, agreementKey)`, `createZoneKey(zone)`, `rotateZoneKey(zone)`, `envelopesFor(zone, deviceListJson)` (a `PutKeyEnvelopes`, only to verified devices), `acceptEnvelopes(keyEnvelopeListJson, deviceListJson)`. +-- `itemToFields(itemJson, beforeJson?, stampJson)` and `itemFromFields(id, fieldsJson)`. ++# orbyn-wasm ++ ++JavaScript bindings, made with wasm-bindgen (MIT OR Apache-2.0), for the device crates: sign-in (`orbyn-auth-client`), device keys (`orbyn-crypto`) and the sync engine (`orbyn-client`). Data goes in and out as JSON in the `orbyn-contracts` wire shapes (their TypeScript is in `crates/orbyn-contracts/bindings`), and bytes as `Uint8Array`. A failure throws an `Error` whose message starts with a stable code, such as `missing_zone_key: ...`. ++ ++The crate also builds natively, so its tests run with `cargo test`. The JavaScript package itself is not built or tested in CI yet (that needs the `wasm-bindgen` CLI and a JavaScript runtime); CI only checks that the crate compiles for `wasm32-unknown-unknown`. ++ ++## Secrets stay inside WebAssembly ++ ++No function returns device keys, zone keys or the OPAQUE export key. What the host stores, the device keys and the engine snapshot, comes out sealed (XChaCha20-Poly1305) under a key derived inside WebAssembly with HKDF-SHA256 from a *wrapping secret* the host passes in. ++ ++The host gets the wrapping secret from a WebCrypto key that cannot be exported: ++ ++```js ++// Once per device: a non-extractable HMAC key, kept in IndexedDB (structured ++// clone keeps it non-extractable). ++const wrapKey = await crypto.subtle.generateKey( ++ { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); ++ ++// Each launch: the wrapping secret is the key's MAC over a fixed label. ++const label = new TextEncoder().encode("orbyn/local-wrap/v1"); ++const wrapSecret = new Uint8Array(await crypto.subtle.sign("HMAC", wrapKey, label)); ++ ++const sealedKeys = keys.export(wrapSecret); // store this ++const keys2 = DeviceKeys.import(sealedKeys, wrapSecret); ++const sealedState = engine.snapshot(wrapSecret); // store this ++const engine2 = SyncEngine.restore(keys2, sealedState, wrapSecret, Date.now()); ++wrapSecret.fill(0); ++``` ++ ++The functions that take the wrapping secret or the password (`export`, `import`, `snapshot`, `restore`, `startRegistration`, `finishRegistration`, `startLogin`, `finishLogin`) take them as owned buffers (`Vec` in Rust), which WebAssembly zeroes when the call ends, on every path including errors. A borrowed `&[u8]` would be copied into WebAssembly memory by the generated glue and freed without being zeroed. The `fill(0)` above clears only the JavaScript copy. Not covered: buffers inside `opaque-ke`, `argon2` and the hash functions while they run, which this crate does not own; a check that freed blocks held no copy of the password or secret found none on the error paths it exercised, but a full OPAQUE exchange was not part of it. ++ ++Why this and not AES-GCM through `crypto.subtle.encrypt` inside WebAssembly: it keeps every call synchronous and testable natively, and needs no `web-sys` or futures. The trade-off: the 32-byte wrapping secret is in JavaScript memory while it is used. Script running in the page could compute it anyway with the non-extractable key, so it reveals nothing that key does not; what it never sees is a device key or zone key. The snapshot is also bound to the device's public signing key, so one device's snapshot does not open with another's keys. ++ ++## Building for the web ++ ++Nothing in CI builds the JavaScript package yet; CI only runs `cargo check --target wasm32-unknown-unknown`. To build it by hand, install the `wasm-bindgen` CLI at the same version as the `wasm-bindgen` crate in `Cargo.lock` (the two must match), then: ++ ++```bash ++cargo build -p orbyn-wasm --release --target wasm32-unknown-unknown ++wasm-bindgen --target web --out-dir pkg \ ++ target/wasm32-unknown-unknown/release/orbyn_wasm.wasm ++``` ++ ++`pkg/` then holds `orbyn_wasm.js`, its `.d.ts` and the `.wasm` file. `.cargo/config.toml` already sets the `getrandom` backend for the browser. Optionally shrink the module with `wasm-opt -Oz`. ++ ++## The planner's `AuthCrypto` interface ++ ++| `AuthCrypto` | This crate | Notes | ++| --- | --- | --- | ++| `startRegistration(password)` | `startRegistration(password: Uint8Array): PendingRegistration` | `.request` is `RegisterStart.registration_request` | ++| `finishRegistration(...)` | `finishRegistration(pending, password, response): Uint8Array` | Returns `RegisterFinish.registration_upload`; works once | ++| `startLogin(password)` | `startLogin(password: Uint8Array): PendingLogin` | `.request` is `LoginStart.credential_request` | ++| `finishLogin(...)` | `finishLogin(pending, password, response): Uint8Array` | Returns `LoginFinish.credential_finalization`; a wrong password throws `auth_failed` | ++| device keys: create | `DeviceKeys.create()` | | ++| device keys: export | `keys.export(wrapSecret): Uint8Array` | Sealed; see above | ++| device keys: import | `DeviceKeys.import(sealed, wrapSecret)` | | ++| public keys | `keys.signingKey()`, `keys.agreementKey()` | Base64url, as `NewDevice` wants them | ++| `sign(context, message)` | `keys.sign(context, message): Uint8Array` | Any context; prefer the helpers below | ++| sign-up signature | `keys.signRegistration(email, upload)` | `register_message` under `orbyn/device-register/v1`; normalises the email | ++| sign-in signature | `keys.signLogin(loginId)` | `login_message` under `orbyn/device-login/v1`; `loginId` is the UUID from `LoginStarted` | ++| trusted start proof | `keys.signTrustedStart(email, credentialRequest, signedAt)` | `trusted_start_message`; `signedAt` in Unix seconds | ++| email | `normalizeEmail(email): string \| undefined` | | ++ ++The OPAQUE export key never leaves WebAssembly: it is wiped when `finishRegistration` or `finishLogin` returns. It will be used inside the module when Advanced protection needs it. ++ ++## Approving a device ++ ++Both screens show the same six digits; the person confirms on each. The protocol and its exact message order are in `docs/architecture.md` (Accounts and devices). The calls, in order: ++ ++| Step | Approver (trusted device) | New device | ++| --- | --- | --- | ++| 1 | `s = engine.startApproval(pendingDeviceJson, nowSecs)`; send `s.createJson` to `POST /v1/devices/approvals` | | ++| 2 | | read `GET /v1/devices/current`; `d = engine.joinApproval(approvalJson, nowSecs)`; send `d.nonceJson` to `…/{id}/nonce` | ++| 3 | read `GET /v1/devices/approvals/{id}`; send `s.reveal(approvalJson)` to `…/{id}/reveal` | | ++| 4 | show `s.sas()` and `s.deviceFingerprint()`; only on "They match": send `engine.confirmApproval(s, deviceListJson)` to `…/{id}/confirm` | read `GET /v1/devices/current`; show `d.sas(approvalJson)` | ++| 5 | | only on "They match": `code = engine.finishApproval(d, approvalJson, typedOrUndefined, nowSecs)`; send `{code}` to `…/{id}/code` | ++ ++On "They don't match" either device sends `…/{id}/cancel`. Each session works once; a new attempt needs a new approval. `finishApproval` refuses until the approver has confirmed, so poll `GET /v1/devices/current` until `signature` is set. ++ ++Protection level: `engine.signProtection('standard' | 'advanced', nowSecs)` gives the body of `POST /v1/keychain/protection`, the next link of the chain this device verified. After any change, and on start, fetch `GET /v1/keychain/protection?from=` with `engine.protectionFrom()` (the verified head, or for a device just approved the sequence its approver vouched for), page by page while `more`, and give each answer, with the device list, to `engine.verifyProtection(chainJson, devicesJson)`; it returns `{seq, state, level, stopped}`, moves the verified head forward over every link a trusted device signed or committed to, and throws `protection_rollback` or `protection_fork` without changing anything. `engine.protectionLevel()` is `{state, level}`: `verified` (the whole chain was walked to the server's head, from an anchor this device trusts, and nothing stopped it: the only state in which Standard may be acted on), `provisional` (a guess: show it, never act on it; `stopped` says why a walk stopped) or `unknown` (no level, not Standard). To move the chain past a link this device could not verify, show the person the setting and, only if they confirm, send `engine.resignProtection(level, serverHeadLinkJson, nowSecs)` as the same `POST`. On a 409 `protection_conflict`, fetch, verify and sign again; a head that is the setting you sent means it went through. ++ ++Removing a device: send `engine.signRemoval(deviceJson)` as the body of `DELETE /v1/devices/{id}`. Deleting a zone: send `engine.deleteZoneProof(zone)` as the body of `DELETE /v1/zones/{zone}`. ++ ++## Losing local data ++ ++Clearing the site's data (or the browser's storage for it) deletes the sealed device keys, the snapshot and the wrapping key, and none of them can be recovered: the server never had them. If the person has another trusted device, sign in again as a new device, approve it from that device and let it share the zone keys again; the records come back from the server. If this was the person's only device, its encrypted data is lost for good until Standard or Advanced protection (with recovery) exists. ++ ++## Sync engine ++ ++`SyncEngine.create(keys, accountId, deviceId)` (from `SessionCreated`) or `SyncEngine.restore(keys, sealed, wrapSecret, nowMillis)`, then: ++ ++- `snapshot(wrapSecret)`: the sealed state to store. ++- `tick(now)`: a `Stamp` for a new edit. `tickAt(editAt, now)`: a `Stamp` for an edit made at `editAt`, which may be earlier than `now` (queued while the app was closed). It is never earlier than the last stamp this device issued or saw (an older `editAt` is moved just after it), and an `editAt` after `now` is taken as `now`, silently: an edit cannot be in the future, and a stamp ahead of the clock would be dropped by other devices. `restore` no longer moves the clock to its own `now`, so a later `tickAt` can use the edit's own time; it keeps a record whose newest stamp is more than an hour ahead of `now` but does not let it move the clock, and `skippedFutureStamps()` says how many there were: log a non-zero count. `stage(zone, recordType, id, fieldsJson)`, `stageItem(zone, itemJson, beforeJson?, now)`, `delete(zone, id)`. ++- `record(zone, id)`: `{"record_type": ..., "fields": Fields}` (this was the bare `Fields` before), `item(zone, id)`, `token(zone)`. ++- `quarantined(zone)`: `[{"record_id": ..., "reason": ...}]` for the records waiting for a key, with the reason `missing_zone_key`, `open_failed` or `invalid_record`; never their bytes. ++- `buildUpload(zone)`: an `UploadChanges` (the same batch until answered), then `uploadSucceeded(zone, uploadResultJson)`, `uploadConflicted(zone, recordChangedJson, now)` or `abandonUpload(zone)`. ++- `purgeLocalZones(zonesJson)`: explicitly forgets whole local zone owners, including all their keys and queued uploads. Takes a JSON array of at most 1000 valid zone names and 64 KiB; returns a sorted JSON array of names actually removed. Validation is atomic; duplicate/missing names are harmless. Identity, trust, protection and other zones stay intact. The host must authorize the names, fence old work and persist the result. No remote delete or upload acknowledgment occurs, and external copies are not wiped. Only the live engine retains its clock unconditionally: a bare purge/snapshot/restore can lose its progression when no admissible stamped records remain. Stage fresh replacement tombstones before persisting when round-trip progression is needed, under the normal future-stamp policy. ++- `applyChanges(zone, zoneChangesJson, now)` (and `applyChangesFrom(zone, since, zoneChangesJson, now)`, required while a zone is refetched after `tokenExpired`): returns `{changed, removed, trashed, quarantined, unverified_deletes, rejected, more}`. ++- `retryQuarantined(zone, now)` after new keys arrive; `trash(zone)` and `takeFromTrash(zone, id)`. ++- `checkZones(zoneListJson)`: returns `{stale, next}`; follow `next` as `?after=`. ++- Devices and keys: `verifyDevices(deviceListJson)` (the ids this device trusts: its approver, the devices that approver endorsed, and their approval chains, minus signed removals), `pinDevice(id, signingKey, agreementKey)`, `createZoneKey(zone)`, `rotateZoneKey(zone)`, `envelopesFor(zone, deviceListJson)` (a `PutKeyEnvelopes`, only to verified devices), `acceptEnvelopes(keyEnvelopeListJson, deviceListJson)`. ++- `itemToFields(itemJson, beforeJson?, stampJson)` and `itemFromFields(id, fieldsJson)`. +diff --git a/crates/orbyn-wasm/src/lib.rs b/crates/orbyn-wasm/src/lib.rs +index ab850d9..ceda654 100644 +--- a/crates/orbyn-wasm/src/lib.rs ++++ b/crates/orbyn-wasm/src/lib.rs +@@ -673,6 +673,23 @@ impl SyncEngine { + Ok(self.engine.abandon_upload(zone)?) + } + ++ /// Forgets whole local zones, including their keys and queued uploads. ++ /// Takes a JSON array (at most 64 KiB / 1000 names); returns the sorted JSON ++ /// array of names actually removed. Invalid input changes nothing. The host ++ /// authorizes the names, fences late work and persists the snapshot. This ++ /// performs no remote deletion and leaves identity/trust/other zones intact. ++ #[wasm_bindgen(js_name = purgeLocalZones)] ++ pub fn purge_local_zones(&mut self, zones_json: &str) -> Result { ++ if zones_json.len() > 64 * 1024 { ++ return Err(error( ++ "invalid_input", ++ "the local purge request is too large", ++ )); ++ } ++ let zones: Vec = parse(zones_json)?; ++ to_json(&self.engine.purge_local_zones(&zones)?) ++ } ++ + /// Call on a `token_expired` answer to a fetch or an upload: the token is + /// forgotten, so `token(zone)` is empty and the zone is fetched from the start. + #[wasm_bindgen(js_name = tokenExpired)] +diff --git a/crates/orbyn-wasm/src/tests.rs b/crates/orbyn-wasm/src/tests.rs +index c4c220a..dafe72e 100644 +--- a/crates/orbyn-wasm/src/tests.rs ++++ b/crates/orbyn-wasm/src/tests.rs +@@ -8,6 +8,132 @@ const SECRET: [u8; 32] = [42; 32]; + + const NOW_S: f64 = 1_800_000_000.0; + ++#[test] ++fn local_zone_purge_is_bounded_atomic_and_survives_sealed_restore() -> Result<()> { ++ let keys = DeviceKeys::create()?; ++ let mut engine = SyncEngine::create(&keys, ACCOUNT, A)?; ++ for zone in ["notes-local", "notes-local-approval", "unrelated"] { ++ engine.create_zone_key(zone)?; ++ let stamp = engine.tick(5_000.0)?; ++ let fields = item_to_fields( ++ &to_json( ++ &Item::task("r", "synthetic marker", "home") ++ .map_err(|e| error("invalid_item", e))?, ++ )?, ++ None, ++ &stamp, ++ )?; ++ engine.stage(zone, "item", "r", &fields)?; ++ } ++ engine.build_upload("notes-local")?; ++ let before = engine.engine.snapshot(); ++ assert_eq!( ++ engine.purge_local_zones(&format!("[]{}", " ".repeat(65_534)))?, ++ "[]" ++ ); ++ for (input, code) in [ ++ ("[\"notes-local\",\"\"]".to_owned(), "invalid_zone"), ++ ("{}".into(), "invalid_json"), ++ ("[1]".into(), "invalid_json"), ++ ("[".into(), "invalid_json"), ++ (" ".repeat(65_537), "invalid_input"), ++ (to_json(&vec!["notes-local"; 1001])?, "invalid_input"), ++ ] { ++ assert_eq!( ++ engine ++ .purge_local_zones(&input) ++ .expect_err("invalid input") ++ .code, ++ code ++ ); ++ assert!(engine.engine.snapshot() == before, "bad input is atomic"); ++ } ++ assert_eq!( ++ engine.purge_local_zones(r#"["missing","notes-local","notes-local"]"#)?, ++ r#"["notes-local"]"# ++ ); ++ let mut expected = before; ++ expected.zones.remove("notes-local"); ++ assert!(engine.engine.snapshot() == expected); ++ let sealed = engine.snapshot(SECRET.to_vec())?; ++ let mut restored = SyncEngine::restore(&keys, &sealed, SECRET.to_vec(), 5_000.0)?; ++ assert!( ++ restored.engine.snapshot() == expected, ++ "identity and other zones retained" ++ ); ++ assert_eq!(restored.purge_local_zones("[]")?, "[]"); ++ assert_eq!(restored.purge_local_zones(r#"["notes-local"]"#)?, "[]"); ++ Ok(()) ++} ++ ++#[test] ++fn local_zone_purge_exports_no_old_keys_to_a_new_peer() -> Result<()> { ++ let (keys, peer_keys) = (DeviceKeys::create()?, DeviceKeys::create()?); ++ let mut engine = SyncEngine::create(&keys, ACCOUNT, A)?; ++ let old_key = engine.create_zone_key("notes-local")?; ++ engine.stage_item( ++ "notes-local", ++ &to_json( ++ &Item::task("r", "old queued marker", "home").map_err(|e| error("invalid_item", e))?, ++ )?, ++ None, ++ 5_000.0, ++ )?; ++ let upload: wire::UploadChanges = parse(&engine.build_upload("notes-local")?.expect("batch"))?; ++ let wire::RecordChange::Save { ++ record_id, ++ record_type, ++ ciphertext, ++ wrapped_key, ++ .. ++ } = &upload.changes[0] ++ else { ++ panic!("save") ++ }; ++ let page = wire::ZoneChanges { ++ records: vec![wire::Record { ++ record_id: record_id.clone(), ++ record_type: record_type.clone(), ++ version: 1, ++ ciphertext: Some(ciphertext.clone()), ++ wrapped_key: Some(wrapped_key.clone()), ++ author_device_id: A.into(), ++ modified_at: String::new(), ++ deleted: false, ++ delete_proof: None, ++ }], ++ token: "old-token".into(), ++ more: false, ++ zone_deleted: false, ++ }; ++ engine.purge_local_zones(r#"["notes-local"]"#)?; ++ assert_ne!(engine.create_zone_key("notes-local")?, old_key); ++ let sealed = engine.snapshot(SECRET.to_vec())?; ++ let mut engine = SyncEngine::restore(&keys, &sealed, SECRET.to_vec(), 5_000.0)?; ++ let mut peer = SyncEngine::create(&peer_keys, ACCOUNT, B)?; ++ engine.pin_device(B, &peer_keys.signing_key(), &peer_keys.agreement_key())?; ++ peer.pin_device(A, &keys.signing_key(), &keys.agreement_key())?; ++ let devices = ++ json!({"devices": [device(A, &keys, "trusted"), device(B, &peer_keys, "trusted")]}) ++ .to_string(); ++ let exported: Value = parse(&engine.envelopes_for("notes-local", &devices)?)?; ++ assert_eq!(exported["envelopes"].as_array().map(Vec::len), Some(1)); ++ assert_ne!(exported["envelopes"][0]["key_id"], old_key); ++ let delivered = json!({"envelopes": [{ ++ "sender_device_id": A, "zone": "notes-local", ++ "key_id": exported["envelopes"][0]["key_id"], ++ "envelope": exported["envelopes"][0]["envelope"], "created_at": "", ++ }]}); ++ peer.accept_envelopes(&delivered.to_string(), &devices)?; ++ let applied: Value = parse(&peer.apply_changes("notes-local", &to_json(&page)?, 5_000.0)?)?; ++ assert_eq!(applied["quarantined"], json!(["r"])); ++ assert!(peer.record("notes-local", "r")?.is_none()); ++ let retried: Value = parse(&peer.retry_quarantined("notes-local", 5_000.0)?)?; ++ assert_eq!(retried["changed"], json!([])); ++ assert!(peer.record("notes-local", "r")?.is_none()); ++ Ok(()) ++} ++ + /// `id` as the server lists it, in `state`, not yet approved. + fn device(id: &str, keys: &DeviceKeys, state: &str) -> Value { + json!({ +diff --git a/docs/architecture.md b/docs/architecture.md +index a13ca17..c01c13e 100644 +--- a/docs/architecture.md ++++ b/docs/architecture.md +@@ -1,273 +1,275 @@ +-# orbyn-engine architecture +- +-The plan lives in the internal Orbyn Platform Plan. This file is the contract the code follows. Change it in the same commit as any rule it states. +- +-## Rules +- +-- Each device keeps the whole planner and works offline. The server is never the only copy of a person's work. +-- Records are encrypted on the device before upload, at every protection level. The server stores ciphertext with a visible id, type, zone and version. +-- Keys never go in the database or the logs. Only the key service unwraps a Standard-protection key, and every use is audited. +-- Authorization is checked in the operation that does the work, not only in the route. +-- Every external effect (a push, an email, a key rotation) has an operation identity, so a retry never duplicates it. +-- Logs carry no content, keys or record payloads. +-- One Docker image runs every server role, chosen by subcommand. +- +-## Crates +- +-| Crate | Owns | Runs on | +-| --- | --- | --- | +-| `orbyn-contracts` | Wire types; generates TypeScript into `bindings/` | Everywhere | +-| `orbyn-crypto` | Symmetric sealing, device keys, key wrapping, signatures, recovery keys | Devices (native and WebAssembly) | +-| `orbyn-auth-client` | The OPAQUE ciphersuite, the device side of sign-in, the messages devices sign | Devices (native and WebAssembly); the server uses its ciphersuite | +-| `orbyn-sync` | Hybrid logical clocks and field-by-field merge | Devices | +-| `orbyn-planner` | Tasks, events, lists, repeat rules and their record fields | Devices | +-| `orbyn-client` | The device sync engine: record encryption, uploads, merging incoming changes, zone keys and envelopes. No IO | Devices (native and WebAssembly) | +-| `orbyn-wasm` | JavaScript bindings (wasm-bindgen) for sign-in, device keys and the sync engine | The web | +-| `orbyn-core` | Configuration, Postgres, migrations, job queue, accounts and devices | Server | +-| `orbyn-store` | The CloudKit-style record store: zones, encrypted records, change tokens, uploads, live notices | Server | +-| `orbyn-keyservice` | The key service library: the `Hsm` trait, service-key certification, opening service envelopes, and `orbyn keyservice init`. PKCS#11 (SoftHSM, cloud HSMs) is behind its `pkcs11` feature, and an in-memory HSM for tests behind `test-support`. No HTTP and no deployment yet | Key service | +-| `orbyn-translog` | The transparency log's RFC 9162 Merkle tree: roots, inclusion and consistency proofs and their verifiers, an append-only accumulator; and the path-compressed sparse Merkle map with membership and non-membership proofs and the witness's update function. The cosignature, its verification and the witness threshold rule (more than half of the pinned witnesses, each key counted once, no key pinned twice). Pure, no IO; the entry and unsigned-head encodings (see [key-service-3c.md](key-service-3c.md)) | Key service and devices (native and WebAssembly) | +-| `orbyn-keyservice-server` | The key service's store (a library for now; **Postgres 15 or later**, tested on 17, because it relies on schema `public` belonging to `pg_database_owner`): its own migrations and two database roles (an owner, and a runtime role with no `UPDATE`, `DELETE` or `TRUNCATE` on the append-only tables), the transparency log and the two maps on Postgres, and head building without signing. Its database is separate from the record server's | Key service | +-| `orbyn-witness` | The witness: checks each head the key service builds, including both map updates (through `orbyn_translog::map::update`) and each account's chain, keeps its state (with the whole newest signed head) with `fsync` before it answers, and cosigns with its own time. The HTTP service (`POST /v1/cosign`, `GET /v1/cosignature`), the `orbyn-witness` binary and the cosign client with a threshold and chunked catch-up. `TestWitness` behind `test-support` | Witness host; CI | +-| `orbyn-server` | HTTP API and the `/v1/sync` WebSocket | Server | +-| `orbyn-worker` | Job runner | Server | +-| `orbyn-cli` | The `orbyn` binary with every role | Server | +- +-Later phases add local storage for `orbyn-client` (SQLite on desktop and mobile), `orbyn-keychain`, and the key service's own deployment, store and routes (`orbyn-keyservice` is its library), as the plan describes. +- +-## Cryptography +- +-- `cargo audit` ignores RUSTSEC-2026-0387 (crypto-bigint 0.5 timing) in `.cargo/audit.toml`. It is pulled in only through opaque-ke 4.0's elliptic-curve support, whose group code is generic over NIST-style curves. Our suite is Ristretto255 on curve25519-dalek, and no such curve is in the build, so crypto-bigint arithmetic never runs. Remove the exception when opaque-ke depends on crypto-bigint 0.7.6 or later. +- +-- Records, zone keys and service keys are 256-bit keys used with XChaCha20-Poly1305 and a random 192-bit nonce. +-- Every seal and signature binds a context string such as `orbyn/record/v1` with a length prefix, so a value made for one purpose cannot be accepted as another. +-- A key handed to another device is wrapped with a one-time X25519 key and HKDF-SHA256 over both public keys, bound to a label such as the zone id, and signed by the sending device. The receiver names the sender it trusts; a wrap from anyone else, the server included, is refused before it is opened. +-- Secret keys compare in constant time, and intermediate key bytes are wiped. +-- Devices sign changes with Ed25519, verified strictly. +-- The recovery key is 28 Crockford base32 characters (140 bits), turned into a key with Argon2id (64 MiB, 3 passes). +-- Secret types wipe themselves on drop and never print their bytes. +-- An outside review of this crate is required before launch. +- +-## Accounts and devices +- +-- Passwords use OPAQUE (RFC 9807, `opaque-ke` 4.0): Ristretto255 for the OPRF and the key exchange, triple Diffie-Hellman with SHA-512, and Argon2id (64 MiB, 3 passes, one lane) run on the device. The password never reaches the server; it stores only the registration record. Changing the ciphersuite or the Argon2id cost needs a versioned migration, because every record depends on it. +-- Emails are trimmed and lowercased, and unique. Sign-in never tells whether an email has an account: an unknown email gets a fake answer from the server setup, the same limits count it, and every failure is `login_failed`. Sign-up never tells either (see Email verification). +-- `ORBYN_AUTH_SETUP` holds the OPAQUE server setup. It is a secret from `orbyn auth-setup`, the same on every replica and never in the database; a new one makes every password unusable. Without it the account routes answer 503. Three keys are derived from it with HKDF: one seals the server's half of a sign-in (and a sign-in waiting for its second factor), one keys the digests of IP addresses, rate-limit subjects, approval codes and recovery codes, and one (`orbyn/totp-seal/v1`) seals each account's TOTP secret. **Known limitation: it cannot be rotated today.** Replacing it makes every password unusable, and a leaked value cannot be replaced without every person resetting; what it seals (sign-in states, queued mail, second-factor secrets, push tokens) stops opening with it. Rotation is an open plan decision and is not designed here. +-- **Email verification: no account without a proven address.** Sign-up is two OPAQUE steps and then a code. The first step touches no account. The second (`POST /v1/accounts/register/finish`) issues no session and makes no account: it keeps the password record and the first device in `pending_signups` and answers 201 `{signup_id}`. A pending sign-up lives 30 minutes from its start; `expires_at` is set once. +- - **The throttle: nothing is capped per address (engine#42).** A cap keyed on an address is one a stranger can use up to lock the owner out, so none is: not on mail, not on starts, not on rows. Starts are limited by the **client's IP prefix** (an IPv4 /24, an IPv6 /64) alone: up to **10 an hour are free**, past that a start must carry a **proof of work** (428 `proof_required` with a `challenge` and `bits`; the client finds a `nonce` so that SHA-256 of `orbyn/signup-pow/v1`, the challenge and the nonce begins with that many zero bits, counting up from a random start; `orbyn_crypto::pow`, one solution buys one start, the challenge is for one prefix and good for 10 to 20 minutes), and past **60 an hour** it is refused (429). The same slots are taken for every start, whatever the address is. A prefix shared by many people (a carrier's NAT, an office) shares the allowance: that is an availability limit, accepted, and the proof of work is how a start gets through it. A prefix may hold 2 live rows for an address and the address 20: a newer row evicts the oldest, so a start always succeeds, **except that a row younger than 15 minutes is never evicted by the address's cap** (so twenty quick starts from other prefixes cannot push out the row of an owner who just started; the address may hold more than twenty for that long, bounded by the starts those prefixes are allowed). A proof of work is spent only on a start the hard cap lets through. +- - `POST /v1/accounts/email/verify {signup_id, code}` with the right code creates the account **only for the sign-up it names**, from that sign-up's own password record and device (the device is trusted), sets `email_verified_at`, answers `SessionCreated` (201), and deletes every other row of the address: the code stops working for all of them, and the first verified wins. Every account is therefore a verified account. `POST /v1/accounts/email/resend {signup_id}` takes the same step as a start (the active code is mailed again if the last mail is a minute old; a new code only if it expired or died), 3 an hour per sign-up id, 204 whatever the id is, and does not extend the thirty minutes. Migration 0010 adds `accounts.email_verified_at` (set at verification; existing rows are left NULL and not backfilled, and an existing account counts as taken for sign-up whether or not it is verified): a deployment with real accounts must set `email_verified_at = created_at` after it, before anything relies on the column. +- - **One code per address, derived, never stored.** An address with a live pending sign-up has one code record (`signup_codes`): a random 16-byte salt, an expiry (30 minutes from when it was made), when it was last mailed, and how many wrong guesses it has had **in its whole life**. The **8-character code** (Account Designs v62) is derived from the server secret, the address and the salt, so a mail can send the same code again without it being kept anywhere (not in a table, a log or an audit row). **Any start or resend re-sends the active code**, at most once a minute per address (the mail is the same code, so skipping one loses the owner nothing), and **a new code is made only when the active one has expired or has died** (10 wrong guesses), and then **not before the minute** since the last mail. So asking for a code never makes the owner's stop working, and the mail an address gets is at most one a minute, whoever asks and from however many prefixes. **Replacing a spent code has a ceiling per address** (a code is *spent* when it had a wrong guess against it and died or expired): the first 3 in a day are replaced as soon as anyone asks, after that one an hour. This bounds guessing at an address (10 guesses a code, so about 270 a day at most) whatever number of IP prefixes the guesses come from, which no per-prefix limit can do. With the code space below it is defence in depth, not the main protection. It is a **delay and never a refusal**: a start or resend still answers as always and stores its row; the next code comes when the hour is over. The price, stated plainly: while someone keeps spending an address's codes, its owner waits up to an hour for the next one, **and that has no end while the attack lasts**. Spending a code costs only its 10 wrong guesses, which the length of the code does not change: an attacker spraying about 1,200 wrong guesses an hour (about 40 IP /64 blocks at 30 wrong guesses an hour each) can keep an address's owner from signing up for as long as they keep spraying. "A delay, never a refusal" holds for each start, not for the whole attack. A code that expires with no guess against it is not spent and is replaced freely. A taken address gets the notice "Someone tried to sign up with your email" in the code's place, on the same schedule, and never a code. A start, a resend and a verification each take at least a fixed time (250 ms) to answer. +- - **The code: 8 characters, one format for the beta and for open sign-up.** The alphabet is the 30 symbols `23456789ABCDEFGHJKMNPQRSTVWXYZ` (Crockford's base32 without I, L, O and U, and without the 0 and 1 that would be left to confuse), drawn uniformly by rejection from the keyed digest (a byte is used only below 240, so no symbol is likelier). 30^8 is about 6.6 x 10^11 codes (about 2^39). It is mailed whole, shown in two groups (`K7QM-R4TW`: the subject, the plain text and the HTML), and read as people type it: case does not matter and hyphens and spaces (and the dashes a word processor makes of them) are ignored; no other character is turned into a symbol, so an `O` never matches. The authenticator codes of the second factor stay six digits. **Takeover odds.** At the ceiling (about 270 guesses a day against one code space of 6.6 x 10^11) a persistently targeted unused address is taken over with probability about 4 x 10^-10 a day, about 1.5 x 10^-7 a year; with no ceiling at all, 1,200 guesses an hour (29,000 a day) would still be about 4 x 10^-8 a day. Guessing a code to take an address is not realistic. The limit that remains is the owner's wait above. +- - **Every start is answered alike, and takes one path.** `register/finish` always answers 201 `{signup_id}`, whatever the address's state and whatever the limits say (past the free allowance it is 428, for every address alike). A free address, a taken one (its rows are *decoys*, which no code opens) and one with sign-ups pending run **the same statements in the same order**: the code record is found or made, one message is queued if it is time (the code or the notice), the row caps are applied, the row is stored. They differ only in the value of the code (a decoy compares against a code that was never mailed) and in which one message is queued. A refusal by a limit takes no shortcut. A test records the steps of a start for each state and compares them. +- - **Verification failures are all alike.** Any failure, for any id, real or made up, is 400 `invalid_email_code` with the same body: a wrong code, an expired sign-up, a decoy, a dead code and a refusal by a limit. A real sign-up and a decoy take exactly the same steps (the same limiters, the same address lock, the same row and code-record locks, the same update; tests compare the steps and the timing). Wrong guesses are counted **with the code, for its whole life**: 10 in all (a counter on the code record, kept under the address lock) and **5 from one IP prefix** (a limiter keyed on the code's salt and the prefix, whose window equals the code's life); an address's ids share the one budget, for a free address and a taken one alike, and a start or resend never renews it. A dead code is replaced by the next start or resend after the minute, within the ceiling above; burning guesses costs the attacker ten guesses from two prefixes each time, and can delay the owner (an hour per spent code past the third in a day) **for as long as the attack runs** (see the owner's wait above): each start is still answered and stored, but no new code is made until the gap is over. Wrong codes are also limited to 30 an hour per IP address, for any id. The code is bound to the address. Starts, the making of an address's code and the verifications that create its account are serialized per address (an advisory lock). +-- The server's half of a sign-in is sealed (XChaCha20-Poly1305, bound to the login id) and kept in Postgres for five minutes, so any replica can finish it. Finishing deletes it, so it works once. +-- A session token is 32 random bytes in base64url, returned once. Postgres keeps its SHA-256 digest with the account, device, creation, last use and expiry. Each use moves the expiry to 30 days later, never past one year after sign-in. A device can sign out itself; a trusted device can sign out every session. +-- Each device registers its Ed25519 and X25519 public keys with a name and platform, and signs a message that names the sign-up or the login id, so knowing the password is not enough to act as another device. The first device is trusted at sign-up. A device that signs in later is `pending` and can only see itself, answer an approval and sign out. +-- **Approving a device is a two-way code comparison**, so neither device takes the server's word for the other's keys. Every message binds the account id, the approval id, the approval time (Unix seconds, chosen by the approver), both device ids and both devices' public keys (`orbyn_auth_client::Pairing`). In this exact order, each step once: +- 1. The approver A picks the approval id, the time and a 32-byte nonce `r_A`, and sends `POST /v1/devices/approvals` with only the commitment `SHA-256(orbyn/approval-commit/v1 ‖ pairing ‖ r_A)`. The server refuses a time more than five minutes from its own clock or a used id. +- 2. The new device D, which then holds the commitment and has seen no reveal, sends its 32-byte nonce `r_D` (`POST …/{id}/nonce`). It refuses an approval older than ten minutes. +- 3. A, holding `r_D`, checks the server shows the same D keys it started with and reveals `r_A` (`POST …/{id}/reveal`). The server checks it against the commitment; a wrong one closes the approval. +- 4. Both devices show `SAS`: six digits from `SHA-256(orbyn/approval-sas/v1 ‖ pairing ‖ r_A ‖ r_D)`. A also shows D's key fingerprint. Only when the person taps "They match" on A does A sign `orbyn/device-approval/v1` over the pairing and attach its signed list of the devices it trusts (`POST …/{id}/confirm`); before that A wraps no key to D. +- 5. When the person taps "They match" on D (or types A's digits), D checks `r_A` against the commitment, the approval's age, A's signature and the endorsement, and only then pins A and the devices A endorsed. It sends the digits (`POST …/{id}/code`); the server checks them once and trusts D. +- A server that swaps either key, the approval id or a nonce makes the screens differ except by a one-in-a-million guess, and the commitment stops it from choosing `r_A` after seeing `r_D`. A mismatch, a wrong code or a cancel (`POST …/{id}/cancel`, from either device) closes the approval; another try needs a new one, so the server gets one guess per approval. Ten minutes also close it. The device list shows each device's approver, signature, approval id and time, so any device can check the signature later. +-- **Approving a device again.** The same steps approve a device the server lists as trusted, which replaces its approver, signature, approval id and time. The server cannot tell why and allows it for any device but the approver itself; the approver starts one only from its own view (`start_approval` refuses a device it pins or revoked, whatever state the server shows). This is how a device stranded by a removal (pinned under the removed device) and a device trusted before migration 0006 come back. Migration 0006 closes every open approval and clears old approval signatures; devices trusted before it keep no approval id, so no device verifies them until they are approved again. In an account where no device has a verifiable approval, each device still trusts itself, exactly as an account's first device does at sign-up: the person picks one and approves the others from it, confirming the digits on both screens. `GET /v1/devices/current` can therefore show an approval to a device that is already trusted; the host joins one (`join_approval`) only when the person asked to approve this device again, never on its own. +-- **Removing a device** needs the remover's signature over the account, the device id, its keys and a removal sequence (one above the highest the remover has counted, at most 2^53 − 1) under `orbyn/device-removal/v1`. It ends the device's sessions and closes its approvals at once, and the device list carries the signed removal (`removals`) so devices drop trust in it. The last trusted device cannot be removed. +-- **TOTP as a second factor** (RFC 6238: HMAC-SHA-1, six digits, 30-second steps, one step of drift either way). The secret is 20 random bytes made by the server. It is stored sealed (XChaCha20-Poly1305) under a key derived from `ORBYN_AUTH_SETUP` with the label `orbyn/totp-seal/v1` and bound to the account id, so a stored secret moved to another account's row does not open; it is never stored or logged in the clear. The account keeps the newest step a code was accepted for, and a code at or below it is refused, so no code works twice; the check and the update happen under the row's lock. +- - **Enrolling** (trusted device only, with its signature, see below): `POST /v1/accounts/totp` answers the base32 secret and an `otpauth://` link (issuer `Orbyn`, the email as the label), shown once and held pending; starting again replaces a pending secret, and an account that already has TOTP on must turn it off first (`totp_already_enabled`). `POST /v1/accounts/totp/confirm {code, device_signature, signed_at}` turns it on with a current code from the pending secret and answers 10 one-time recovery codes, shown once (`XXXXX-XXXXX`, 50 random bits each). They are stored as keyed digests (the digest key, label `totp-recovery`, bound to the account) and any earlier set is replaced. +- - **Disabling** (`POST /v1/accounts/totp/disable {device_signature, signed_at}`, trusted device only) needs the device's signature and no code: a device that holds its key is the person's, and a lost authenticator must not lock them out of turning the factor off. It removes the secret and the recovery codes and closes any sign-in waiting for its factor. +- - **Device proof.** Enrol, confirm and disable need more than a bearer token: the body carries `device_signature`, an Ed25519 signature by the session's own device key (the one registered for that device) under the context `orbyn/totp-manage/v1` over the account id, the device id, the action (`enrol`, `confirm` or `disable`; confirm also covers the code) and the time, which must be within five minutes of the server's clock. A token alone, a signature from another key, for another action, over another code or from another time answers 401 `device_proof_required`. `orbyn-auth-client` and `orbyn-wasm` (`DeviceKeys.signTotpManage`) make the signature. Each signed change is accepted once: its time must be later than the last one accepted for that device and action (`totp_manage_seen`), so a captured request cannot be sent again inside its window, and a device signs each change with a new second. The mark is kept even when the change then fails, so a failed request cannot succeed later in its window. A device whose clock ran fast has its changes refused until its clock passes the last time it signed. These three have their own limit of 10 per device in 15 minutes, counted only after the signature checked out, and never touch the sign-in limits below. +- - **Status.** `GET /v1/devices/current` answers `totp_enabled` (sign-in asks for a factor) and `totp_pending` (enrolled, not yet confirmed). +- - **Signing in.** After the password step succeeds, if TOTP is on and the start was not proven by a trusted device, `POST /v1/sessions/login/finish` issues no session and adds no device: it answers 401 `second_factor_required` with a `login_id`. The device that is signing in is kept sealed in `second_factor_states` for five minutes, like the OPAQUE state. `POST /v1/sessions/login/second-factor {login_id, code | recovery_code}` (exactly one of the two) then adds the device and issues the session as the password step would have. A start proven by a trusted device skips the factor, but only for that device: the proof's signing key is kept with the sign-in, and the factor is skipped only if the device that finishes registers exactly that key and is trusted on the account. A recovery code works once. +- - **Limits.** Five wrong codes close a sign-in. Sign-in code attempts are limited to 10 per account from one address and 30 per account from anywhere in 15 minutes, through the same limiter as the rest (a refused attempt is not recorded; a right code gives its attempt back, so the owner's own sign-ins never use it up). Enrol, confirm and disable are not counted here; they have the per-device limit above. Every wrong code, replayed code, used recovery code or malformed recovery code is `second_factor_failed`, with the same body, so nothing tells them apart; a state that is gone, expired or closed is `login_failed`. +- - **Audit.** `totp.enrolled`, `totp.disabled`, `totp.passed`, `totp.failed` and `totp.recovery_used`, with the account, device, time and address digest, and no codes or secrets. +-- **Deleting an account.** Deletion is asked for, waits seven days, and can be cancelled until then. +- - **Request.** `POST /v1/accounts/delete` (a `DeleteAccount` body, answer 202 `{deletes_at}`) from a trusted device only. The device signs the request with its own key (`orbyn/account-delete/v1` over the account id, the device id and `signed_at`, within five minutes of the server's clock) from a session that finished its sign-in in the last ten minutes (`sessions.authenticated_at`; an older one is 401 `reauthentication_required`, and the signature is not used up by that). A bearer token alone, a signature from another key, for another action or time is 401 `device_proof_required`; a device that is not trusted is 403 `device_not_trusted`. Each signature is accepted once (its time must be after the last one accepted for the device, `account_delete_seen`, and the mark commits before anything else runs, so a request that then fails cannot be sent again). 5 requests per device an hour, counted after the signature checked out, so bad signatures never use them up. With a second factor on, exactly one of a current `code` and an unused `recovery_code` is needed as well, checked as a sign-in checks it (same attempt limits, the same single answer 401 `second_factor_failed`). +- - **Order of locks.** The account row `FOR UPDATE` first, then the device `FOR SHARE`, so a removal of the device that commits first makes the request fail 403, and two requests (or a request and a cancel) take turns. +- - **Scheduling.** Sets `accounts.deletion_scheduled_at` to now plus seven days and queues one email (`deletion_scheduled`, sealed like every message, naming the date, with a cancel link) in the same transaction, and keeps the link's token as a keyed digest (`deletion_cancel_digest`). A second request while one is pending answers the same `deletes_at`: it does not restart the clock or send another email. At most 3 new schedules per account a day (429 `too_many_attempts`). Every device sees `deletion_scheduled_at` on `GET /v1/devices/current`. +- - **During the grace period** the account works as usual, so the person can still export. Changes that could lock the owner out answer 409 `deletion_scheduled`: removing a device, starting or confirming a device approval (`POST /v1/devices/approvals`, `.../confirm`: nobody joins the account during the grace period, and an approval already under way cannot finish), a zone key rotation (a key envelope with a `rotation`), turning the second factor off, enrolling or confirming a second factor, signing every device out (`DELETE /v1/sessions`), and a new protection setting (`POST /v1/keychain/protection`). Each takes the account row lock, shared, and reads the schedule under it, so it cannot slip in beside a scheduling. +- - **Cancel.** `POST /v1/accounts/delete/cancel` (a `CancelAccountDeletion` body, 204, also when nothing is scheduled) from any trusted device with its signature over the same message under `orbyn/account-delete-cancel/v1`. A device that is still pending may cancel too if its own session finished a sign-in in the last ten minutes (a pending device's session exists only after the password step and the second factor, when there is one), and it can do nothing else it could not before. The cancel link in the email goes to the sign-in page with a "cancel deletion" intent; it never carries a token. +- - **Cancel by mail.** The email's link (`/cancel-deletion#`; with no account URL set the email has no button and carries no token) carries a single-use token: 32 random bytes, valid until `deletion_scheduled_at`, stored only as a keyed digest. `POST /v1/accounts/delete/cancel-by-mail {token}` needs no session and does nothing but cancel (204). The link opens a planner page with a "Cancel deletion" button that posts the token; the token is in the fragment and no GET acts on it, so a mail scanner that opens the link consumes nothing. Any wrong, used, voided or expired token is one answer, 400 `invalid_cancel_token`. Only wrong tokens count against the limit of 10 an hour per address (an IPv6 /64 counts as one), and a right token is never refused or counted, so someone guessing from another address cannot lock the owner out of the link. A repeat request while a schedule is pending leaves the token alone (it returns the same time and sends no email); a cancel or the deletion ends it; a new schedule after a cancel makes a new token and a new email. This is the first thing the email tells the person to do, because it works when every signed-in device is in a thief's hands. +- - **Accepted trade-off.** Cancelling by signing in is open to anyone who has the password and can complete a sign-in (including the second factor, when on) from a new device within ten minutes: a pending device may cancel, and nothing else. Cancelling is never the dangerous direction, so this is accepted. +- - **The deletion.** The worker looks for due accounts every minute (it needs `ORBYN_AUTH_SETUP`; a worker without it, which only happens in development with `ORBYN_MAIL=log`, logs an error on every tick that finds a deletion due and skips it, and keeps purging, pruning and running jobs. Every writer of the account's devices, sessions, approvals or audit rows takes the account row lock first, then its rows, because the deletion holds it exclusively and then deletes those rows: the other order can deadlock with it. The lock is shared, except in `complete_login`, which takes it `FOR NO KEY UPDATE` to serialise sign-ins for the pending-device cap. The one writer without it is `submit_device_nonce`: it changes only a non-key column of one approval and waits on nothing after that row, so it cannot deadlock. Any audit write added there must take the account lock first.) For each it takes the account row lock, reads `deletion_scheduled_at` again, and only if it is still set and due runs the whole deletion in that transaction; a cancel that got the lock first wins. The transaction deletes the queued jobs whose payload names the account, any of its devices or zones, or a pending sign-up of its address (done or not), the pending sign-ups of the address, and the rate-limit rows keyed by the account, its devices and its address (sign-in, sign-in codes, sign-up mail caps, and the address pairs the account's own history shows); then the account row, whose cascades remove the devices, sessions, key envelopes, zones, records, subscriptions, second factor, sign-in states and approvals, so every device's next call is 401. The address is free for a new sign-up at once. Finished and failed jobs older than seven days are pruned by the worker. +- - **What stays.** The audit log keeps its history, scrubbed after the DELETE in the same transaction: `account_id`, `device_id` and `ip_hash` are cleared on every row of the account, and the rows carry a random `subject` made for this deletion and stored nowhere else, plus one `account.deleted` row (subject and time only). Kept rows cannot be linked back to the account, except through the tombstone, by a database reader who holds the account id (see "Deleted-account tombstone" below). An audit row for an account that no longer exists is not written, so a request that did not take the lock cannot add one afterwards. +-- **Deleting an unused account.** In a rare sign-up case the account is made but no device ever keeps its keys, so it cannot be used and its address cannot sign up again (recovery is Planned). An account that has **no key-holding device** and has **never stored a zone or record** can be deleted by a link sent to its address, so the address can sign up again. +- - **Request.** `POST /v1/accounts/delete-unused {email}`, no session. It **always answers 202 `{}`**, whatever the address is (an account or not, eligible or not, verified or not, a well-formed address or not), and the handler takes the same steps for all of them: the two limiters below, then it queues one `delete_unused_request` job that holds the address sealed like a mail (same key, bound to the job id), then commits. **It never reads the account**, so neither the answer nor the time can tell cases apart; the decision is made by the worker. A test holds an `ACCESS EXCLUSIVE` lock on `accounts` while an eligible account, an ineligible one, an unknown address and a malformed one are asked for (every one is answered), and compares their status, headers, body, recorded steps and, loosely, their timing. Limits, both counted for every request whatever the address is: 20 an hour per IP prefix (an IPv4 /24, an IPv6 /64), refused with 429 `too_many_attempts` (the same for every address, so it tells nothing); and 10 an hour per address (a keyed digest), where a request over the cap is answered 202 and queues nothing, so the owner is never told a limit was reached by someone else. **Accepted by design:** anyone can use up an address's 10 an hour and delay the owner's link until the window clears. +- - **The worker's decision.** The `delete_unused_request` handler takes the account row `FOR UPDATE` by address and queues the email only if the account exists, its address is verified (`email_verified_at` is set), no deletion is scheduled, and it is **eligible** (below). Otherwise it does nothing and sends nothing: an account that is in use is not told that a stranger asked, because the notice would be mail the stranger can trigger at will and would add nothing the owner can act on (say so if a notice is wanted). At most **3 emails an hour per account** go out, and only a mail that goes out counts. A new request while a token is live makes a **new token** and replaces the old one (one live token per account). The worker learns where the account site is from `ORBYN_ACCOUNT_URL` (the `Mailer` carries it); with none, the email has no link and no token is kept. A job is decided at least once, so a rerun makes a new token that replaces the first. +- - **Token and link.** `DELETE_UNUSED_TTL` = 24 hours, a single named constant in `orbyn-core` (the email's "24 hours" is rendered from it, not typed). 32 random bytes, stored only as a keyed digest (`accounts.delete_unused_digest`, `accounts.delete_unused_expires_at`; a unique index on the digest) and used once: confirming deletes the account, and a refusal at confirm (below) clears it. The email links to `/delete-unused#` (with no account URL the email has no link, says to ask whoever runs the server, and no token is kept), so the token is in the fragment, **no GET acts on it**, and a mail scanner that opens the link consumes nothing. The email is a new kind, `delete_unused` ("unused account deletion link"), subject "Delete your unused Orbyn account", one button "Delete this account", and a separate no-link version for an unset `ORBYN_ACCOUNT_URL`; its text is the designers' (Account Designs v30) and is not written until it is supplied. +- - **Confirm.** `POST /v1/accounts/delete-unused/confirm {token}`, no session, answers 200 `{invite_released}` (see Invites). It finds the account by the token's digest with `delete_unused_expires_at > now()`, takes the account row `FOR UPDATE` and **checks eligibility again under the lock** (and that no deletion is scheduled). Still eligible: it deletes the account **at once, with no grace period** (it is empty), through the same `erase` as the scheduled deletion (one transaction: the account row and its cascades, the jobs that name it, the pending sign-ups, the rate-limit rows keyed by it, the audit log scrubbed with a random `subject`, and the `account.deleted` row), after writing an `account.delete_unused_confirmed` audit row. That row is scrubbed like every other row of the account: it keeps its action, its time and the deletion's random `subject`, and **no id, address, address digest or network digest**, the same as the scheduled deletion (which also keeps none), so the address cannot be linked back to the deletion by anyone, the operator included. The address can sign up again at once. Not eligible any more (data or a keyed device arrived after the email, or a deletion was scheduled): 409 `delete_unused_not_possible`, and the token is cleared. A bad, used, expired or voided token is 400 `invalid_delete_unused_token` with one body. Only wrong tokens count against 10 an hour per IP prefix (the cancel-by-mail limit, its own bucket); a right token is never refused or counted. +- - **Key-holding device.** The server never sees a secret key, so the signal is a proof of one. A device **holds keys** if it was ever trusted (`approved_at` is set) and has ever made a **proof of its key after sign-up**, shown by any of: a `login.succeeded` audit row naming it (a sign-in checks the device's signature over the login id; the session that sign-up itself issues proves nothing, because it is a bearer token), a row in `account_delete_seen` or `totp_manage_seen` (a signed deletion or second-factor request was accepted), a `device_approvals` row naming it as the device or the approver, a `key_envelopes` row naming it as sender or recipient, `approved_by` set, a `protection_settings` row it signed, or a device it removed (`removed_by`). A pending device never counts, so a person locked out who tries a second browser (a pending device that signed in) is not shut out of this way; and an account with a second factor enrolled always has such a proof. This is the strongest signal the schema has and it needs no new column. It reads only rows the server already has. +- - **Ever stored.** Must count deleted data, and the purge physically removes deleted zones, so a zone count cannot say. Migration 0013 adds `accounts.stored_at timestamptz`, set by an `AFTER INSERT` trigger on `zones` the first time a zone is made (every record sits in a zone, so this is the first write of either), never cleared, and backfilled from existing zones. The trigger writes one non-key column once per account, so it takes no lock that the zone write path does not already take. An account is **eligible** only when `stored_at IS NULL`, it has no `zones` row and no `zone_participants` row, and no device holds keys. +- - **Audit and cost.** `account.delete_unused_confirmed` and the usual `account.deleted`. No widening of what the server reads: the new columns are a keyed digest, a time and a time. Tests (with Postgres): the 202 is identical, with the same steps and timing, for an eligible account, an ineligible one and an unknown address; an eligible account is deleted at confirm and the same address signs up again; an account with a zone, a deleted zone that the purge removed, a keyed device (each kind of proof) or a scheduled deletion is refused at confirm with a valid token (409); a token works once and not after 24 hours; each limit holds (per IP prefix 429, per address silent, per account 3 emails an hour, wrong tokens 10 an hour); no GET on either path does anything; the audit rows are written and unlinkable. +-- **Deleted-account tombstone.** A device whose account was deleted gets 401, the same as an expired session, so the planner cannot tell "signed out" from "deleted" and cannot wipe its local copy. A tombstone says so, and nothing more. +- - **What is kept.** When `erase` deletes an account, in the same transaction, it writes one row to `account_tombstones (subject bytea PRIMARY KEY, deleted_at date NOT NULL)`: `subject` is a keyed digest of the account id under **a key of its own** (HKDF label `orbyn/account-tombstone-key/v1`, separate from the state, digest and second-factor keys, so no audit, token, email or address digest can stand in for it; a test asserts the labels and keys differ), so the table cannot be read back into ids and only someone who holds an id can look it up, and `deleted_at` is the **UTC day** of the deletion, not the time. No email, no device, no address digest, no network digest. `erase` is the one place both deletion paths go through (the scheduled deletion and delete-unused), so both write it; a repeat is `ON CONFLICT DO NOTHING`. An address that signs up again gets a new random account id, so the tombstone of its old account never touches the new one. Accounts deleted before this migration have no tombstone: their devices keep getting 401. +- - **What a tombstone does and does not hide.** It names no person and no email: a keyed digest of a random id and a UTC day, and nothing else. It is **not** unlinkable from the deletion's kept audit rows for someone who has database access **and** an account id: the tombstone is written in the same transaction as the scrub of those rows (it must be, so a live account is never shown as deleted and a deleted one is never left without its tombstone), so the rows share a transaction id, and on a quiet server they share the day too. Writing it in another transaction would gain little (the transaction ids would be adjacent) and could show a live account as deleted. So: with an id in hand, a database reader can tie that account's tombstone to its scrubbed audit history. The kept audit rows still carry no id, address or digest, and the tombstone's `subject` is independent of their random `subject`; what the exception adds is only "this id was deleted, on this day, and these audit rows are from that deletion". Only the day is kept, not the time, because the designs show only the date. If they need the hour, say so. +- - **The status endpoint.** `POST /v1/accounts/status {account_id}`, no session (the device's session is gone; ids are random UUIDs, 122 bits, so learning that one is deleted needs the id already). Answers 200 `{deleted_at}` (an RFC 3339 date at 00:00 UTC) when a tombstone exists and **404 `not_found` for everything else**: an unknown id and a live account look the same, so it never says that an account exists. One indexed lookup either way, with no early return, and both answers are built the same way (a test compares the answers byte for byte and the timing). The id is unguessable: **account ids are random UUIDv4**, from `Uuid::new_v4()` at the sign-up that makes the account, with no sequence or time-ordered part (a test checks the version and variant of new ids). A body that is not a UUID is 400 `invalid_request`. It is limited to 60 an hour per IP prefix (an IPv4 /24, an IPv6 /64), 429 `too_many_attempts`. +- - **The planner's side.** On a 401 it posts its own `account_id` once and wipes only on a 200; a 404, a 429 or any failure leaves it signed out with its data, as today. It does not poll. +- - **How long.** **400 days, then pruned** by the worker's housekeeping (in batches, like finished jobs). A session lasts at most a year (`hard_expires_at`), so a device that was offline longer than that would be signed out by expiry anyway, and the 35 extra days cover clock and lease slack; keeping it forever would be a permanent register of deleted account ids for no device that could still use it. After the 400 days a stale device that comes back gets 404 from the status, looks signed out, and **keeps its local copy: this is the intended behaviour**, the safe default when the server can no longer say. +- - **Contract types and tests.** `AccountStatusRequest {account_id}` and `AccountStatus {deleted_at}` in `orbyn-contracts` (bindings regenerated). Tests (Postgres): a scheduled deletion and a delete-unused each leave exactly one tombstone with the day, the digest of the id and nothing else (no email, id or device in the row or anywhere in its table); the endpoint answers 200 for a deleted id and an identical 404 for a live account and a never-used id; a failed deletion leaves no tombstone; the limit holds per IP prefix; the same address signing up again is not reported deleted; a tombstone older than 400 days is pruned, and one exactly 400 days old, or younger, is not; a worker prune does not touch fresh rows. +-- **Invites: sign-up for the invite-only beta needs a code.** `ORBYN_SIGNUP=invite|open`, **`invite` unless set**. `open` is today's behaviour and stays unusable in production until the sign-up address probing (#42) and the account-existence signal (#66) are fixed. An invalid value stops the server at start. +- - **The code.** 16 characters of Crockford base32 grouped `XXXX-XXXX-XXXX-XXXX` (80 random bits). It is shown once, by the operator command, and **is never stored, logged or audited**: the `invites` table keeps the plain SHA-256 of the normalised code (enough, because the code is random, and it keeps invites independent of `ORBYN_AUTH_SETUP`), the expiry, `used_at`, `used_by_account`, `revoked_at`, `released_at`, an operator label and a four-hex short id. Normalising ignores case, spaces and dashes and reads `O` as `0` and `I` and `L` as `1`. No test or code path writes a code anywhere; a test searches every table, the logs and the audit table for it. +- - **Operator only, no HTTP route.** `orbyn invite create [--count N] [--expires-days D] [--label TEXT]` prints each code once (N at most 100, D 14 by default and at most 90); `orbyn invite list` shows id, created, expires, label and status (Active, Used, Expired, Revoked) and never a code; `orbyn invite revoke `. The label is the operator's own note and **refuses any value containing an at sign** (`@`, the full-width `@` U+FF20 and the small `﹫` U+FE6B), so it never holds an address; one over 100 characters has its own error. Every mistake in the arguments is refused before the database is asked anything: no command or an unknown one, a `--count` or `--expires-days` that is not a whole number in range (zero, over the limit and a word get the same message), and a `revoke` with no id, an option that does not exist, a `--label` with no value and an argument nothing wants. A database that cannot be reached prints `error: the database can't be reached (DATABASE_URL)` (the engine reads only `DATABASE_URL`) and never the address, the user or the password. The text is the designed text (Account Designs v58, orbyn-designs#67). +- - **`POST /v1/invites/check {invite}`** (no session) answers 204 or the same 400 `invite_invalid` as the finish. It uses the same failure limiter, spends nothing and records nothing. With sign-up open it answers 204. +- - **`register/start`** is unchanged and touches nothing. **`register/finish`** gains `invite`, judged **before** any address logic and before the slow work, in one indexed lookup by digest. An unknown, malformed, expired, used or revoked code, a missing one, and one that already has five live sign-ups waiting are **one answer**: 400 `invite_invalid`, one body. The steps taken are the same for all of them (a code that is not even well formed is looked up as a random digest), and a test compares the steps and the full responses. A valid invite leads to today's logic, real row or decoy, and the pending row **records the invite id, decoys included**, so a decoy takes the same steps. **The invite is not used at this step**: a decoy (an address that already has an account) must not burn a person's invite, or burning it would show that the address has an account. +- - **`email/verify`**, in the transaction that creates the account, runs `UPDATE invites SET used_at, used_by_account WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now()`. No row updated means no account (the transaction ends undone) and the answer is 400 `invite_invalid`; the first sign-up verified with an invite wins it, so two racing for one make one account. A sign-up started before invites were required has no invite and makes no account once they are. Decoys never get this far: their code digest never matches, so they fail as `invalid_email_code` as before. +- - **Limits.** 10 `invite_invalid` answers an hour per client address, then 429 `too_many_attempts`; **only failures count** (a code that works gives its attempt back), and past the limit even a good code is refused. At most 5 live pending sign-ups per invite; the sixth is `invite_invalid`, which tells only the holder of the code. The count is read outside any lock, so parallel finishes can go a little past five: **accepted**, since it only affects the holder of the code and every sign-up still needs its own email code. Guessing a code is infeasible: 80 bits against 10 tries an hour. Per-address limits and mail caps are unchanged. +- - **What the answers reveal.** Whether an address has an account: nothing, as before (the invite is judged before the address). Whether a code exists: nothing; one answer covers unknown, used, expired and revoked, and only the person holding a valid, unused code learns that it works, by seeing the sign-up continue. A side effect on #42: probing an address's sign-up now needs a valid invite, which narrows that signal to invite holders and does not fix it. +- - **Stranded invitees.** When delete-unused erases an account that was made from an invite, it **releases that invite once, in the same transaction**, keeping its original expiry: `used_at` and `used_by_account` go back to null and `released_at` is set. An invite that already has `released_at` is never released again, and **no other deletion releases an invite** (the link is cleared, the invite stays used). `POST /v1/accounts/delete-unused/confirm` answers **200 `{invite_released: bool}`** instead of 204: true only when this deletion released an invite, false for an account made without one and for an invite already released. Only the holder of the deletion token sees it. +- - **Not here.** No waitlist, request form or invite mail, no invites made by users, and no invite links: if they are ever added, the code goes in the URL fragment only, never a query string. +-- Rate limits live in Postgres as sliding windows and answer 429 `too_many_attempts`. A limit is refused only when its count is reached, never because another request for the same address is counting at that moment: attempts for one subject take turns in the process first, waiting in memory without a pool connection (at most 32 wait; a crowd beyond that is a flood and is refused), and the database lock is then contended only by another replica, which is looked at again a few times over about 75 ms before refusing. Two browsers on one network can therefore sign up at the same moment. Sign-in starts: 30 per email an hour from anywhere, 10 per email per address and 100 per address in 15 minutes. OPAQUE lets a client learn that a password is wrong without finishing, so every start counts. A successful sign-in gives its start back. A start signed by a device already trusted on the account (`trusted_device`: its Ed25519 signature over the normalised email, the credential request and the time, under `orbyn/trusted-login-start/v1`, valid for five minutes) counts in a per-email bucket of its own (30 an hour) and still under the per-(email, address) limit, so someone guessing cannot lock the owner out of the devices they have, and a stolen trusted device still cannot guess quickly. An invalid proof is ignored. Sign-up: 20 per address an hour. Approval codes: 10 per pending device and 50 per address in 10 minutes, on top of one try per approval. IPv6 addresses count by their /64. A refused attempt is not recorded. Attempts for one subject take a non-blocking advisory lock, and a busy lock refuses the attempt rather than waiting. Every five minutes the worker removes old attempts, expired sign-in states and pending devices older than a week, in batches, until it has caught up. An account may have at most 20 pending devices, counted under a lock on the account; a trusted device can remove pending ones at any time. +-- The audit log records sign-ups, sign-ins (and failures), sign-outs, approvals, wrong codes and removals with the account, device, time and a keyed digest of the address. It holds no emails, tokens, keys or plain addresses. +-- Behind a proxy, `ORBYN_CLIENT_IP_HEADER` names the header with the client address (its last entry is used). Set it only when every request comes through that proxy, or a caller can choose its own address. +- +-### Threat model +- +-- **Server compromise.** An attacker running the server sees no passwords. It can add a device of its own to the account, but devices verify approval chains before they send it keys (see Keychain). It can refuse service, issue sessions for pending devices it invents, and run an offline guessing attack on records with the setup secret, slowed by Argon2id on each guess. +-- **Stolen database.** Records cannot be tested against guesses without `ORBYN_AUTH_SETUP` (OPAQUE needs the OPRF key). Token digests cannot sign anyone in. Sealed sign-in states cannot be finished, and each is bound to its id and its account, so moving one to another account's row makes it fail to open. Code, address and email digests are keyed, so small spaces cannot be reversed by trying every value. +-- **Phished code.** The digits are not a secret to pass on: they only prove both screens computed the same pairing. Someone who tricks a person into confirming still needs the password and a pending device the person chose to approve; the trusted device shows the pending device's name, platform and key fingerprint before the person confirms. +-- **Known risk: new devices can be blocked.** Thirty unproven starts an hour from one address use up an email's untrusted bucket, so while someone keeps that up, a new device, or an owner who has lost every trusted device, cannot sign in. Recovery-key and email-verified sign-in will skip that bucket when they ship; until then this is accepted and documented. +-- **Second factor.** A stolen or guessed password alone does not sign a new device in on an account with TOTP on: the attacker also needs a code, with five tries per sign-in, 10 per account from one address and 30 per account from anywhere in 15 minutes, and a code cannot be replayed. The sign-in limits count only sign-in codes. Enrolling, confirming and disabling are not sign-in: they need the trusted device's signature, so a stolen session token cannot turn the factor off, replace a pending secret or use up the owner's attempts, and a bad signature costs the owner nothing because the per-device limit is counted after it is checked. A stolen database holds only the sealed secret and keyed recovery digests, useless without `ORBYN_AUTH_SETUP`; an attacker who holds both the database and that setup can read the secrets (the setup already protects every sealed state), so the setup stays in the secret store. A recovery code is 50 bits, tried at the same limits, and each one works once. A trusted device that signs its start skips the factor for itself: whoever holds a trusted device's key is already trusted on the account, and a proven start finished by any other device is still asked for the factor. Not covered: a phished code relayed in real time to a sign-in the attacker started with the right password (TOTP cannot stop that; passkeys would), and an account whose owner has lost the app and every recovery code, which has no recovery until recovery-key sign-in exists. +-- **Brute force.** Each password guess needs an OPAQUE start, and starts are limited per email from anywhere, per email per address and per address, with no difference between known and unknown emails. Codes are limited per approval, pending device and address. +-- **Sign-up enumeration.** Sign-up and sign-in say nothing about whether an email has an account: every start is answered with 201 and a `signup_id`, no session or token exists to follow up on, a decoy id is refused like a wrong code and takes the same steps and the same time as a real one's, a limit is never visible in an answer (the one 428 depends on the client's prefix alone), and every answer takes at least a fixed floor of time, so the time of the start does not tell an address's state either. The address's owner gets a message at most once a minute. An attacker who starts sign-ups for a victim's address cannot read the codes, so cannot finish them, and cannot keep the victim out: whatever they start, the one code goes to the victim's inbox and works, a resend or a start brings it again, and the victim's own start from their own prefix always succeeds. +-- **Mail.** A queued message holds its address and code sealed under a key derived from `ORBYN_AUTH_SETUP`, bound to its job id, kind and account, so a stolen database shows only the kind and account and a body moved to another job does not open; the worker never logs an address or a code, only that a message was not sent, with its kind and account. Anyone who can read a mailbox can complete the verification of an address, which is what the code proves. Guessing a code blind, as someone who cannot read the mailbox must, is limited to 5 wrong guesses per code per IP prefix and 10 per code, so at most 100 guesses a day per address against a million: about 3.6% a year for a sustained attacker. A code is valid only while a sign-up of the address is live (30 minutes). Mail is sent only over TLS (see Running), so neither a code nor the SMTP password crosses the network in the clear. +- +-Next: passkeys (WebAuthn) as a second sign-in method and recovery-key sign-in for Advanced protection. +- +-## Record store +- +-Modelled on CloudKit's private database. `orbyn-store` owns the behaviour; the routes only parse and map. +- +-- **Zones.** Each account has a private database of zones, one per list, project or calendar. The client names a zone (1 to 64 characters, no control characters); the name is unique per account. A zone has an owner, and a `zone_participants` table waits for sharing (designed in [sharing.md](sharing.md)); until then only the owner reaches a zone, and another account's request answers `zone_not_found` as if it did not exist. At most 1000 zones per account, deleted ones included until the purge removes them (see Purging), so creating and deleting new names cannot grow without limit (`too_many_zones`); a deleted zone can be brought back until it is purged. `GET /v1/zones` returns up to 500 zones by name and a `next` cursor for `?after=`. +-- **Records.** A record has a zone, an id (1 to 128 characters), a type (1 to 64), a version, ciphertext (1 byte to 256 KiB), a wrapped record key (at most 1 KiB), the author device and a time. A tombstone keeps the id, type and version and drops both byte fields. At most 100,000 records per zone, tombstones included until the purge removes them (`too_many_records`), and 1 GiB of ciphertext and wrapped keys per account (`quota_exceeded`). Tombstones hold no bytes, so only the record and zone limits depend on the purge; all three count what is retained. The byte quota is checked under the zone's lock, so concurrent uploads to different zones can overshoot it by at most one batch each. +-- **What the server sees.** Zone names, record ids and types, versions, sizes, times and which device wrote what. That an account id was deleted, and the day, for 400 days. The push provider of a device and, sealed, its token, while it is registered and for at most 90 days after its last refresh. Never the content: the ciphertext and the wrapped key are opaque bytes it never parses. Notices and jobs carry a zone and a token, never record data. +-- **Authorization.** Every store operation checks in its own transaction that the session's device belongs to the account and is trusted right now. Writes hold the device row (`FOR SHARE`), so a removal waits for an upload in flight and the next request is refused. Pending and removed devices get `device_not_trusted`. +-- **Versions and change tokens.** Each zone has a sequence. A write locks the zone row and takes the next values, so writes in a zone commit one after another, in sequence order. A record's version is the sequence of the write that produced it, and a change token is 16 bytes of zone id and 8 of sequence, base64url. `GET /v1/zones/{zone}/changes?since=` returns the latest state of each record changed after the token, oldest first, at most 200 records or about 4 MiB (always at least one), with the next token and `more`. Without `since` it starts at the beginning. A token from another zone, past the zone's sequence or malformed is `invalid_change_token`; one older than the zone's purge floor is `token_expired` (410), see Purging. A record changed twice appears once, at its last position. +-- **Uploads are atomic.** `POST /v1/zones/{zone}/changes` takes up to 200 saves and deletes for distinct records, at most 4 MiB of bytes. Each names the version it replaces (`null` to create; a delete names one). If any record's version differs, nothing is written and the answer is 409 `record_changed` with every conflicting record and the server's copy (a tombstone included, `null` if it never existed), like CloudKit's `serverRecordChanged`. We chose all or nothing over per-record results because a batch is usually one user action spanning records (a task and its list order), and a half-applied batch would leave other devices with a state no device ever had; the client merges the conflicts with `orbyn-sync` and uploads again. A save over a tombstone names the tombstone's version. A delete may carry a `proof` (at most 256 bytes): the deleting device's signature, stored with the tombstone and returned as `delete_proof`. The server never reads it; devices check it (see Device sync engine). Tombstones left by deleting a zone have none. +-- **Upload limits.** Each upload counts against its device (30 per minute) and then its account (60 per minute), in the sliding-window table sign-in limits use, shared by every replica. A window of N is a burst of N that refills as attempts age out. Only trusted devices are counted, so a pending device cannot use up the account's share. A refused upload is `too_many_uploads` with status 429 and a `Retry-After` in whole seconds (the time until the oldest counted attempt ages out), and is not counted; when the account refuses, the device's attempt is given back. A failed or replayed upload counts like any other. Unlike sign-in limits, concurrent uploads of one subject wait for each other's short count instead of being refused, so a retry racing its first attempt, or two devices at once, is not mistaken for a flood. Each such waiter holds a pool connection (the pool has `ORBYN_DATABASE_POOL_SIZE` connections, 10 by default), so two further bounds keep one account from holding the pool: uploads running at once on a replica are capped, counted in memory before any connection is taken: at most 4 per account, at most 16 per client address (an IPv4 address, or an IPv6 /64, the key the sign-in limits use; `ORBYN_MAX_UPLOADS_PER_ADDRESS` sets it, 1 to 1000; higher than the account cap because carrier NAT, offices and VPNs put many honest people behind one IPv4 address; never more than the replica cap less one, so one address always leaves room for another; unknown addresses are held to the other caps only), and at most half of the pool left after the live listener (which holds one connection for good) across the replica (so 4 with the default pool of 10, 99 with the largest; `ORBYN_MAX_UPLOADS_IN_FLIGHT` sets it, 1 to 1000, and the server uses at most the pool size less three, with a warning). When `ORBYN_CLIENT_IP_HEADER` is set but a request lacks it, the connection's address is used and the server logs a warning once. All three are checked together and a request over any of them is refused at once with `too_many_uploads` and `Retry-After: 1`. The address cap keeps many accounts made in one place from holding the replica, so another address is not refused (measured: 30 accounts flooding from one address, another address's upload under 200 ms); the replica cap bounds the pool whatever the mix, and a replica filled from many addresses answers the rest with the same 429 at once, never a pool timeout, and a wait for the subject's lock gives up after 2 seconds with the same answer. `NOTIFY` still serialises commits across the database, so the account limit is also what bounds how much of that one account can use. +-- **Idempotency.** Every upload carries a client-made `operation_id` (a UUID). The applied result is stored with a SHA-256 of the batch for seven days; the same id and batch again returns that result with `replayed: true` and writes nothing. The same id with a different batch is `operation_reused`. The zone lock makes concurrent retries wait for the first. A failed upload stores nothing, so it can be retried as is. +-- **Deleting a zone** turns every live record into a tombstone, each with a new version, takes one more step for the deletion itself, and marks the zone deleted. Changes still list the tombstones with `zone_deleted: true`; uploads answer `zone_deleted`. `PUT` brings the zone back empty with its sequence moved on, so old tokens keep working. +-- **Live notices.** After each committed change, `pg_notify` on `orbyn_zone_changes` inside the writing transaction carries the account, author device, zone and token. Postgres delivers it only on commit, to every replica. Each replica runs one listener and forwards notices to the account's other devices on `/v1/sync`. The listener puts a notice only in its own account's queue (128 notices, made when the account's first socket connects here and dropped with its last), so one account's flood can only make that account's own slow sockets fall behind, never another account's. The listener never waits on a queue, so a stalled socket cannot hold it up. The socket's first message is `{"type":"auth","token":…}` within three seconds, so the token never appears in a URL or header; then the server sends `ready`, `zone_changed` and `resync`. Notices are a hint: on `ready`, on `resync` (sent after the listener reconnects or when a device falls behind), and after any gap, a device compares every zone's token from `GET /v1/zones`. The socket checks its session and device every 30 seconds and closes when either ends. **Keep-alive.** The server sends a WebSocket Ping every 30 seconds, because a tunnel or proxy (Cloudflare Tunnel among them) drops a connection that carries nothing for a while, and notices alone can be hours apart. A socket from which no frame at all has arrived (a Pong, or anything else) for two intervals is closed with the normal code 1000 and the reason `idle`, so its client reconnects and resyncs as after any gap. The interval is `AppState::with_sync_keepalive`, 30 seconds unless a test sets it shorter. Closing drops the socket's place in the account's cap and every permit on every path out of the connection. Each send gives up after ten seconds, so a peer that stops reading cannot hold the connection. At most 16 open sockets per account per replica (a further one is refused with `too_many_connections` and closed 1008), and 8 sockets per address (IPv4, an IPv4-mapped address counted as IPv4, or an IPv6 /64) and 4096 per replica may wait at once, so one client cannot shut others out. The periodic check is read-only, so an open socket never extends its session. +-- **Silent push.** A device that subscribed to a zone (`subscriptions`, all zones when `zone_id` is null) gets a `zone_changed` job enqueued in the writing transaction, with the idempotency key `:`, unless it wrote the change itself. The worker's handler wakes those devices through a sender trait. This covers the part that is not the provider: the device's push token, how a device registers it, and a handler that wakes the devices behind a sender trait whose default sends nothing. APNs and FCM come later as senders, with their own configuration and their own operator text; this slice needs none. **Web Push is Planned, not built, and no browser gets wake-ups from this**: its token is a URL the worker would call, so it waits for an allowlist against server-side request forgery. Reminders still fire on the device itself: a push only wakes the app so it can sync. +- - **What a push says: nothing.** The sender is given the provider, the device's token and a fixed collapse key (`orbyn-sync`), and a message id made from the job and the device (a name for the wake-up in the logs of whoever debugs it; **the providers do not de-duplicate on it**, so a retry is guarded by the per-device progress below, not by this id). The message itself is constant: APNs `{"aps":{"content-available":1}}` as a background push (priority 5), FCM a data message `{"orbyn":"sync"}` with no notification part. **No zone, name, change token, sequence, record, account, device id, author or count is in it.** The woken device syncs through its ordinary authenticated path (changes since its token), exactly as after a WebSocket `zone_changed`. A test hands the handler a recording sender and checks that everything it was given is the provider, the token, the key and the id, and that the job's own payload (which does name the zone) never reaches it. +- - **What the server stores.** `push_tokens (device_id PRIMARY KEY, account_id, provider, token_digest UNIQUE, sealed_token, created_at, refreshed_at)` and `push_deliveries (job_id, device_id)`, the devices one job has already reached. `provider` is `apns` or `fcm` (Web Push is not in this slice: its token is a URL the worker would call, which needs an allowlist against server-side request forgery first). The token is **sealed** under the same server key as queued mail but its own context (`orbyn/push-token/v1`), bound to the device id (a sealed token copied onto another device's row does not open there, and the row is deleted), so a copy of the table shows the provider and the time and not the tokens; `token_digest` is a keyed digest used only to keep one token on one device. Nothing else about the device is added: no model, name, app version, language or location. +- - **What a token reveals, and to whom.** To Orbyn it says which provider a device uses and gives Orbyn the means to wake that device, which only the operator's own provider credentials can use (a token alone sends nothing). To the provider it already says which app install it belongs to; what the provider learns from Orbyn's pushes is **that** a wake-up was sent to that device and **when**, never what changed. A person with a stolen session could register a token of their own for that device and so learn when the account's other devices write: metadata of the kind the WebSocket already shows to the same session, and the same session can already read everything, so registering needs only a trusted device's session, no signature. +- - **Registering.** `PUT /v1/devices/current/push-token {provider, token}` from a trusted device's session answers 204 and replaces the device's token; it also creates the device's all-zones subscription in the same transaction. `DELETE /v1/devices/current/push-token` answers 204, also when there is none, and ends the subscription. A token is 1 to 512 printable ASCII characters with no space; 10 registrations an hour per device (429 `too_many_attempts`); a pending device is 403 `device_not_trusted`. The same token registered by another device or account **moves**: the OS token follows the install, so the old holder loses it. Registering and removing are audited as `device.push_registered` and `device.push_removed` (device and time, never the token). +- - **How long it is kept, and how it goes.** It is deleted: by the person (`DELETE`); **when the device signs out** (`DELETE /v1/sessions/current` deletes that device's token and subscription, `DELETE /v1/sessions` those of every device, in the same transaction, because a signed-out device should not be woken and cannot delete its own); in the same transaction as **removing the device** (removed devices stay in `devices` for their history, so this is an explicit delete, not a cascade); with the **account** (the row cascades from the account and the device, so the account's deletion, scheduled or by delete-unused, leaves none, and a test checks the account's residue); when a **provider says the token is no longer valid** (at once, by device **and** token digest in one transaction with its subscription, so a token the device registered a moment ago is not deleted); when **another device registers the same token** (the old holder's subscription goes too); and by **housekeeping after 90 days without a refresh** (a device re-registers each time the OS hands its app a token, so one that has not in 90 days is gone; the worker prunes in batches like its other tables). A pending device that is pruned after a week takes its row with it. +- - **The handler.** For a `zone_changed` job it reads the account's **trusted** devices that have a token and subscribed to the zone, excluding the author, that this job has not reached yet (`push_deliveries`), at most 50 of them per attempt (a full page makes the attempt a retry, and the next one takes the next 50, since the devices already reached are in `push_deliveries`), and asks the sender once per device with a 10-second timeout. The sender answers sent (remembered per device), token invalid (the row is deleted as above) or try again later (the job is retried with the queue's backoff, and **a retry wakes only the devices that failed**; a duplicate wake-up is harmless but not guaranteed away). `push_deliveries` rows are pruned after 7 days. **The default sender, `NoPush`, sends nothing and logs nothing**, and the job is done: a deployment with no provider configured behaves exactly as today, and no operator text is added by this slice. +- - **The sealing key and its rotation.** The token is sealed with the key the queued mail uses (derived from `ORBYN_AUTH_SETUP`). That setup has no rotation window: a new one makes every password unusable, and the mail queue simply gives up on a message it cannot open. Tokens work the same way and **there is no re-seal step**: a token this setup cannot open is treated as an invalid token (the row and the subscription are deleted at the next push, without anything being sent) and the device registers again at its next launch, when the OS hands the app its token. A test registers a token under another setup and checks exactly that. **The limitation, plainly: `ORBYN_AUTH_SETUP` cannot be rotated today. Replacing it makes every password unusable, and a leaked value cannot be replaced without a reset for everyone. Push tokens inherit that; they add no way to rotate it and none is designed here (it is an open plan decision).** +- - **Nothing logs a token.** No code path logs a token, its digest, or what a provider answers; a sender must not either, and a failure is a fixed line (`the devices to wake could not be read`, `a wake-up could not be sent yet`). The audit rows name the device and the action only. Tests check the handler's answers, the audit rows and `Push`'s `Debug` for a token. +- - **Tests (Postgres).** Registering replaces and moves a token; a pending device and a missing session are refused; the limit holds; deleting the token, removing the device, deleting the account (scheduled and delete-unused) and pruning at 90 days each leave no row, 89 days leaves it; the sealed column does not contain the token and the digest is not the token; the audit rows hold no token; the handler gives a recording sender one push per subscribed device with a token, none to the author or to a device without a token, nothing but the four values, and removes a row on "token invalid"; a retry reuses the message id; with the default sender a job is done and nothing is stored or logged. +-- **Lock order.** Every transaction that locks both takes the account row before the device row, so creating zones and removing a device can never deadlock. +-- **Purging.** The worker (`orbyn worker`) purges every 10 minutes; `ORBYN_PURGE_AFTER_DAYS` sets the retention window (1 to 3650, default 30). It removes tombstones older than the window from zones in use, and deleted zones older than the window whole, so their names are free again. A deleted zone whose purge has started is always finished, even if the window is raised meanwhile, because it can no longer be restored. The purge's selections have no optional parameter (no `IS NULL OR` branch), so a prepared statement cannot settle on a generic plan that ignores the tombstone-age and deleted-zone indexes; a test checks with `EXPLAIN` on 150,000 records that both selections use their indexes as custom and as generic plans. Each step removes at most 1000 rows from one zone under the zone's row lock (the lock every write takes), skips a zone that is busy for the next-oldest one, and a run takes at most 50 steps, so a backlog drains over several runs without holding the database. The counters the limits read change in the same transaction as the rows. Each zone keeps a `purge_floor`, the highest version of any tombstone removed from it (oldest versions go first). A fetch with a token below the floor answers `token_expired` (410), since the device may have missed a delete; a fetch without `since` always works. A fetch reads the zone row and its records in one repeatable-read snapshot, so a purge is either wholly visible to it (the floor is raised and the tombstones are gone: 410) or not at all (the tombstones are still there); it can never see the new floor's absence of tombstones with the old floor. A deleted zone's first purge step raises its floor to the zone's sequence and marks it `purging`: from then on a restore (`PUT`) answers `zone_purging` (409), because some of its records are gone, and the name is free again once the zone row is removed. Paging a fetch from the start below the floor hands out tokens that carry the floor they were made under, so the pages of a refetch are not mistaken for a stale token; such a token expires only if the floor moves before the refetch ends. An upload that names a version at or below the floor for a record the server no longer has gets `token_expired` too, so a device that missed a delete cannot bring the record back by writing over it; any other missing record is an ordinary conflict. The cost: a device offline longer than the window refetches the zone, and a purged delete leaves nothing for it to verify (see Device sync engine). +-- **Request limits.** The upload route accepts bodies up to 6 MiB; every other route 64 KiB. A WebSocket message from a device is at most 16 KiB. +- +-## Device sync engine +- +-`orbyn-client` is the device side of the record store. It does no IO: the host makes the requests, stores the engine's snapshot and passes in the wall clock, so the same code runs natively and in the browser. +- +-- **State.** The account and device ids, the devices this device has verified (pinned with their keys), and per zone: the zone keys, the current key and the rotations that led to it, the change token, a cache of records (merged fields, record type, last server version seen, `dirty`), a quarantine and a trash. `Engine::snapshot` gives all of it to the host; it holds zone keys, so the host seals it before it leaves the process (`orbyn-wasm` does, see its README). +-- **Sealing a record.** The fields are serialized as JSON with sorted keys and sealed with a new random record key (XChaCha20-Poly1305, context `orbyn/record/v1`), bound to the account, zone, record id, record type and the version the write replaces (0 for a new record). The record key is sealed with the zone key (`orbyn/record-key/v1`), bound to the account, zone, record id, key id and that base version. The wrapped key is a format byte (2), the 16-byte key id, the base version (8 bytes) and the sealed key. Every bound value is length-prefixed or fixed-size. Opening refuses changed bytes, bytes moved to another account, zone, id or type, and a base version at or above the record's own version. The final version is assigned by the server after sealing, so the base version is the closest bound the device can make. +-- **Outgoing.** `stage` merges an edit (stamped by the engine's clock) into the cache and marks the record dirty. `build_upload` seals up to 200 dirty records, within 4 MiB, with their expected versions and a new operation id, sealed with the zone's current key. Until the answer is applied it returns the same batch, so resending after a lost answer replays instead of writing twice. On success the new versions apply; a record edited while the batch was out stays dirty. On `record_changed` the batch is dropped first, then each server copy is merged with `merge_remote` and the records go in the next batch with the server's versions, so no local field is lost. A server copy that does not open is quarantined and its record is left out of uploads until it opens, so the same conflict is never sent twice. +-- **Incoming.** `apply_changes` opens each record on its own. One that does not open (missing key, wrong key, changed bytes) goes to the quarantine with its raw bytes and the reason, the rest apply, and the token moves on, so one bad record never stalls a zone. The quarantine holds at most 1000 records and 16 MiB per zone; beyond that the oldest (lowest version) go first and are reported as `evicted`. `retry_quarantined` tries them again after new keys arrive; a newer genuine version also clears them. A record with local edits stays pending (`has_pending`) while a newer server copy of it is quarantined, and waits out of uploads until that copy opens. Each copy merges with `merge_remote` using the host's clock; far-future fields are left out and reported. A record whose merge differs from the server's copy is dirty, so the merge goes back up. A copy no newer than the cached version is ignored. The result lists changed, removed, trashed, quarantined, evicted and unverified-delete record ids for the host. +-- **Signed deletes.** A record delete carries the deleting device's signature (`orbyn/record-delete/v1`) over the account, zone, record id, the version it deletes and the current zone key id; the proof is a format byte (1), the key id, that version and the signature. Deleting a zone sends `delete_zone_proof`: a signature (`orbyn/zone-delete/v1`) over the account, the zone and the newest version the device has seen (format byte 2, that version, the signature), which the server puts on every tombstone the deletion makes. A tombstone removes a record only when its proof verifies against its author's pinned keys and the deleted version is at least the version this device holds and below the tombstone's, so an old genuine tombstone cannot be replayed over a newer record. An unsigned or unverifiable tombstone is reported and ignored, and the device uploads its copy over it. A verified delete wins over concurrent edits; a record that had edits not yet stored goes to a local trash for 30 days (`trash`, `take_from_trash`). A planner item's soft delete (`deleted_at`) is an ordinary field. +-- **Expired tokens.** On `token_expired` from a fetch or an upload, the host calls `Engine::token_expired(zone)`: it forgets the token and any upload waiting for an answer, so the next fetch starts from the beginning and pages as usual. During a refetch the host applies each page with `apply_changes_from(zone, since, page)`, naming the `since` it fetched with; a page fetched with any other token (a slow answer from before the expiry) is refused, and the plain `apply_changes` is refused until the refetch ends, so the refetch ends only on the last page of its own fetch. The engine remembers which records it held at a server version; when that last page is applied, each one the full listing did not contain was deleted and purged. As with any delete, it wins over local edits: the copy, edits included, goes to the local trash for 30 days (listed in `trashed`) and is not uploaded again; no tombstone is left to verify, so the trash is what keeps a wrong answer from losing data. If the floor moves while a refetch runs, the server refuses its page token, and the host calls `token_expired` again: the refetch restarts from the beginning, keeping the records it still has to check so none is trashed early. After 3 restarts in a row the engine returns `ExpiredAgain` with its state already reset to a clean start, so the host can stop and the next sync begins afresh instead of retrying a token the server will not accept. A restart in the middle of a refetch continues it, because the state is in the snapshot. +-- **Zones.** `check_zones` compares a page of `GET /v1/zones` with the device's tokens and returns the stale zones and `next`; the host follows `next` until it is empty. +-- **Clock and edit time.** `tick(now)` stamps an edit at `now`. `tick_at(edit_at, now)` stamps an edit the person made at `edit_at`, which may be earlier than `now` (queued while the app was closed). It keeps the clock's rules: a stamp is never earlier than the last one issued or seen, so an older `edit_at` is moved just after it. An edit cannot have been made in the future, so an `edit_at` after `now` is taken as `now`, silently; this keeps a device with a fast clock from failing its edits, and from stamping edits that other devices would drop as more than an hour ahead of their own clocks (`merge_remote`), which would lose them for good. `restore` puts the clock at the newest stamp in the cache, not at its own `now`, so edits staged after a restore can still carry their own times. A record whose newest stamp is more than an hour ahead of `now` is kept but does not move the clock; the engine counts them (`skipped_future_stamps`) for the host to log. +-- **Reading the cache.** `record_type(zone, id)` gives a live record's stored type next to its fields, and `quarantine(zone)` lists the records waiting for a key with their reasons (`missing_zone_key`, `open_failed`, `invalid_record`), without their bytes. +-- **Secrets in WebAssembly.** The wrapping secret and passwords cross into `orbyn-wasm` as owned buffers that are zeroed when the call ends, so a memory dump of a long-lived page does not hold them; see its README for what this does not cover. +-- **Planner items** are records of type `item`, staged with `Item::to_fields` or `changed_fields` and read with `Item::from_fields`. +-- **Tests** run devices against an in-memory store with the record store's rules and a server that lies: 500 random runs of concurrent edits, deletes, lost answers and paged fetches converge and keep every field's latest edit; forged devices, envelopes, rotations and tombstones are refused, and bad records are quarantined. +- +-## Keychain +- +-For now every key is held by devices. Standard and Advanced protection and the key service come later (see the end of this section); the Standard design is in [key-service.md](key-service.md). +- +-- **Key hierarchy as built.** Each zone has one or more zone keys, each named by a random 16-byte key id; one of them is current. Every record has its own random record key. The record key seals the record; the zone key seals the record key. Zone keys never reach the server in the clear. +-- **Which devices count as trusted.** Never the server's word. A device pins anchors directly: itself; the device that approved it, learned through the two-way code comparison (see Accounts), never from the device list; the devices that approver listed in its signed endorsement (`orbyn/approval-endorsement/v3`, bound to the approval and the new device), each pinned under the device the approver pinned it under, so a removal cascades through them as it does on the approver; and any device the person pins by hand (`pin_device`). Any other device counts only when a chain of `orbyn/device-approval/v1` signatures links it to a pinned device that the list shows as trusted with the same keys; it is pinned under that approver. An approver missing from the list links no new device. A pinned device whose keys the list reports differently is not trusted. A signed removal (`orbyn/device-removal/v1`) from a pinned device revokes the removed device and every device pinned under it; a revoked device is never pinned again, and its envelopes, rotations and approvals are refused. A device the server still lists as trusted that this device no longer pins (it was pinned under a removed device) is stranded until it is approved again (see Accounts). +-- **Removals resolve the same in any order.** The removals of one list are taken as a set, with the devices pinned when it arrives as removers: strongly connected groups of removers go in dependency order, a removal counts when its remover is not revoked by then, and a group that no counting removal reaches but whose members remove each other (directly, around a cycle, or a device removing itself) is revoked whole. There is no trusted time, so a removal signed by a revoked device cannot be placed before or after its revocation; the rule is that a device revoked from outside its cycle, or in an earlier list, has none of its other removals counted. The costs, accepted: a thief holding one trusted device can take out an honest device that removes it back, at the price of its own; and if the server hides a removal from a device until its remover is itself removed, that device never counts it (hiding removals is already in the server's power, below). +-- **Endorsements carry a removal sequence.** Each device keeps the highest sequence of a removal it counted, signs its next removal one higher, and puts its own in every endorsement. A new device drops an endorsed list whose sequence is below its own: the approver made it before it knew of that removal, so it may name a device removed since. It still pins the approver. Approval chains carry no sequence; a removed device stays out of them because revocation is by id and permanent. +-- **What protects against an old endorsement.** The permanent revoked-id check is the real protection: a device that was removed is never pinned again, however old or new the endorsement that names it. The per-device sequence is kept as defence in depth, not relied on: a new device starts at 0, so for the first endorsement it sees, the sequence guards nothing. It is not made account-wide or enforced by the server: the server is not trusted to order removals (see the threat model), a counter it kept could be held back or advanced by it, and only the signed removals devices count are evidence. Decision on engine#11. +-- **The protection level is a chain of signed settings.** A trusted device signs `orbyn/protection/v1` over the account, the signing device, the level (`standard` or `advanced`), a sequence (the first is 1), the hash of the previous setting's signed message (32 zero bytes for the first) and the time. `POST /v1/keychain/protection {level, seq, prev, signed_at, device_signature}` is accepted only from a trusted device of the session, with a signature that verifies under that device's registered key and a time within five minutes of the server's clock (`invalid_signature`), when `seq` is the head's plus one and `prev` is the hash of the head (`protection_conflict`, 409, whose body carries the head). Settings are rate limited per account, 10 an hour and 50 a day (429 `too_many_changes`), counted after the signature verified and only for a setting that is stored: a request that is not validly signed by a trusted device costs nothing and learns nothing, and one that does not fit the chain (a conflict, a replay) gives its slot back, so a thief's requests that never fit cannot use up what the owner's valid next link needs. The chain has no cap: a link is a few hundred bytes. The server stores the whole chain (`protection_settings`) and answers the stored link (201). It keeps no table of seen requests: the chain is a counter that moves once per setting, so a signed request can be accepted once, and replaying it later, or racing another request for the same sequence, is a conflict; the time window bounds how long a captured request that was never sent stays usable. A conflict whose head is the sender's own setting means the first request went through. `GET /v1/keychain/protection?from=N` returns the settings from sequence N (default 1), oldest first, 100 to a page with `more`, to any trusted device. All settings of an account are serialized on its row, in the same order every other writer takes its locks. A verified downgrade signed by a device a thief holds stands until a trusted device signs over it: the server cannot tell it from the person's, so every device shows "protection changed by " for review (the planner's part) and `resign_protection` is how the person moves the chain on. +-- **Devices verify the chain themselves, and say how sure they are.** A device keeps the newest setting it verified (sequence, hash, level), and whether it is anchored and complete, in its snapshot, and only moves forward. `protection_level()` is `Verified(level)`, `Provisional(level)` or `Unknown`, and **only `Verified` allows escrow**. A link counts when a device this device trusts right now (the view envelopes use) signed it; the head moves to the newest link a trusted device signed, and every link before it is accepted because that link commits to them through `prev`, so a setting signed by a device removed since does not freeze the chain once a trusted device has signed after it. The state is `Verified` only when the walk reached the end of the server's chain (`more` false) with nothing stopped and from an anchor: what its approver vouched for, or a setting this device signed itself. It is `Provisional` when the walk stopped at a link it could not verify (`untrusted_signer`, `invalid_signature`, `gap`; the head stays at the last link that verified and the stop is reported), when it has not reached the head, or when nothing anchors the chain (the approver knew of no setting, so a server could have cut the chain short): a guess to show, never to act on. A chain whose newest link is older than the head is refused (`protection_rollback`); a link at a verified sequence with another hash, or the first link after the head with another `prev`, is refused (`protection_fork`); neither changes anything. `sign_protection(level)` builds the next link from the verified head. `resign_protection(level, server_head)` signs the link after the server's head even when this device could not verify it, so the chain moves past a link signed by a removed device or a setting the device refused: the app must show the person the setting and ask before calling it, and never signs on its own. `Unknown` is not Standard: escrow, when it exists, happens only on a verified Standard. +-- **A new device starts from its approver's view, and the approval covers it.** Since endorsement format 3 the approver's signed endorsement carries the sequence and hash of its newest verified setting (zero when it knows none), and the approver's approval signature is the `orbyn/device-approval/v2` message: the v1 approval message plus the SHA-256 of the endorsement, so a server cannot drop the endorsement or put another in its place. A new device refuses an approval with no endorsement when the signature is v2, and a format 3 endorsement under a v1 signature. Other devices check the approval from the device list, which carries `approval_endorsement_hash` for a v2 approval; a v1 approval has none. The new device accepts no chain whose newest link is older than the vouched sequence, or whose link at that sequence has another hash, so an old genuine Standard replayed after a switch to Advanced is refused. The link the approver vouched for is taken without checking its signature, because its signer may have been removed since; every later link is checked. Format 2 endorsements, from approvers that predate settings (under the v1 approval signature), are still accepted and say no setting is known: such a device is `Provisional` until it signs a link itself. A device that has a vouched sequence but has not yet seen the link cannot sign a setting: it fetches from the vouched sequence and verifies first. The server rejects an approval whose signature is neither under the v2 message over the endorsement it holds nor under the v1 message. +-- **Envelopes.** A device seals a zone key to another with `orbyn_crypto::wrap_key`: sealed to the recipient's X25519 key and signed by the sender, labelled with `orbyn/zone-key/v1`, the account, the zone and the key id, so an envelope cannot be moved to another account, zone or key id. The envelope bytes are the one-time public key (32), the signature (64), then the sealed key. `envelopes_for` seals every key of the zone, with its rotation record, only to verified devices; `accept_envelopes` opens only envelopes from verified devices. +- - **Service envelopes (storage, migration 0018).** A zone key can also be sealed to the account's service key (`wrap_key_p256`), and the server stores it beside the device envelopes in `key_envelopes` with `recipient_kind = 'service'`: no recipient device, one per (account, zone, key id) (a retry replaces it), and the **statement hash of the certification it was sealed against**. The server never opens one. It checks only that the sender is a trusted device, that the account's **newest stored protection setting is not Advanced** (409 `protection_advanced`), that it holds a certification for the account (409 `service_key_missing`) and that the envelope names that certification's statement hash (409 `service_key_changed`); the whole batch is refused and nothing written on any of them. The service recipient has its own cap, as a device has (5000). `account_service_keys` holds the certification as the key service answered it (all public; devices check it themselves, the identity's signature and then the transparency log). `service_missing` lists up to 100 (zone, key id) pairs the account's devices hold and the service does not, and is empty under Advanced. Every existing row is a device row and nothing is made for an existing account: only a trusted device can wrap a zone key. +- - **Routes (slice 8b).** `GET /v1/keychain/service-key` (trusted device; **10 asks an hour per account**, counted before the key service is called) asks the key service for the account's service key, making it if the account has none, keeps the certification (under the account lock) and answers it. **Before the key service is called** the route reads the newest setting and the deletion schedule: Advanced is 409 `protection_advanced` and a scheduled deletion 409 `deletion_scheduled`, with no call and no ask counted, so an Advanced account never gets a key made in the log. That read is not under the lock: if the person switches while the key is out, keeping the certification is refused (`protection_advanced`) and a `service_key_retire` job is queued for the key just made (the switch's own job may have run already and found nothing). It answers 202 `service_key_pending` while no published head holds the entry (the certification is kept, so envelopes may already be sent), 409 `service_key_retired` when the key service says the key was retired, and 503 `key_service_unavailable` when none is configured (`orbyn serve` builds the client from the same three settings as the worker), when it cannot be reached, or when its answer is for another account or its hash is not the hash of the statement the server rebuilds. The server checks only that; the **identity's signature and the transparency log are the device's to check** before it escrows anything. `POST /v1/keychain/envelopes` takes `service_envelopes` beside `envelopes` in one all-or-nothing batch (100 in all): a service envelope that names another certification, a missing certification or Advanced fails the whole batch, device envelopes included. `GET /v1/keychain/service-missing` (trusted device) lists the pairs to send. Nothing here lets the server open a service envelope, and the key service has no call that opens one for the record server. What is not built: the client that verifies and wraps (8e), the verifier (8d), and a new key for an account that went Advanced and back (8c-2). +- - **Advanced removes the copy (slice 8c).** When a setting of level Advanced is stored, the same transaction, under the account's lock, deletes the account's service envelopes and its cached certification and queues a `service_key_retire` job (one per setting, **retried every ten minutes without a limit** until the key service has retired the key: a limit would leave the key alive under Advanced once it ran out). From then on nothing is written to the service's copy (`protection_advanced`) and the certification is not remembered again (a key service answer that was in flight when the person switched cannot bring it back). The worker tells the key service to retire the key (`ORBYN_KEYSERVICE_URL`, `ORBYN_KEYSERVICE_CA_FILE`, `ORBYN_KEYSERVICE_CLIENT_FILE`: all three or none; the worker and the server use the same settings, over mutual TLS); retiring is idempotent there, so the job may run more than once. A job whose account is Standard again by the time it runs is skipped, and a worker with no key service retries it rather than finishing it. The skip is **not atomic**: a job that read Advanced just before the person switched back can still destroy the key afterwards, and the key service then answers 409 `service_key_retired` for that account until the generation work (8c-2) lands; going back to Standard right after Advanced is therefore not safe yet. A key service that is down delays the retire; it does not undo the switch. **What it does not undo:** backups made while the account was Standard (as in the design: deletion is built, rotation is Planned). The store computes the certification's statement hash itself, and refuses any write to the service's copy while the account's deletion is scheduled, as it refuses a zone key rotation then. Advanced back to Standard needs a new service key (a generation on the key service's row): not built, the key service still answers 409 `service_key_retired` for such an account. +-- **What the server stores.** `key_envelopes` holds the account, recipient, sender, zone, key id, envelope bytes (at most 1 KiB) and an optional rotation record, and nothing it can open. `POST /v1/keychain/envelopes` takes up to 100 envelopes from the session's device, all or nothing; the sender and every recipient must be trusted devices of the session's account right now, and no envelope may be addressed to its sender (`recipient_not_trusted`, `invalid_envelope`). Sending the same recipient, zone and key id again replaces the envelope, so a retry is harmless. A recipient holds at most 5000 envelopes (`too_many_envelopes`). `GET /v1/keychain/envelopes` returns only the session device's own envelopes, from senders that are still trusted, oldest first. +-- **Pinned keys and rotation.** A key id, once held, keeps its key: an envelope with other bytes for it is refused (`conflicting_key`). The first key a device accepts for a zone (or creates) is current. Later plain keys, such as one another device made for the same zone at the same moment, open records but never become current. The current key moves only through a rotation: `rotate_zone_key` makes a new key and signs the account, zone, new key id and previous key id under `orbyn/zone-key-rotation/v1`; a device accepts it only from a signer it verifies and the server lists as trusted now, and the current key follows accepted rotations from the current one. +- +-### Threat model for synced data +- +-- **What a malicious server can do.** Refuse service, withhold or reorder changes, envelopes, devices and removals, keep listing a removed device whose removal it hides, list a stolen device's signed removal of an honest one (both are revoked if they removed each other), and see metadata (zone names, record ids and types, versions, sizes, times, which device wrote what). It can tombstone records, but devices ignore an unsigned or stale tombstone and put the record back. It can try to sit between two devices during an approval, but then the two screens show different digits except with one chance in a million per approval, and each approval allows one comparison. +-- **What it cannot do.** Read records, zone keys or envelopes; make a new device trust a device of its own, or make a trusted device approve one, without the person confirming matching digits; get a key sealed to a device no pinned device approved; make a device accept a key from, or a rotation, approval or delete by, a device it has not verified; replace a key a device already holds; change which key is current; replay an old delete over a newer record; or move a record or envelope to another account, zone, id or type. +-- **Still open.** A server that withholds a newer link from a device that already holds an older one leaves that device on its older level: it stays `Verified` for the chain it walked, and only another device's different level, shown through the protection-changed notice, reveals it. A device with nothing verified, or whose approver knew of no setting, just has no level or a `Provisional` one. A link signed by a device nobody trusts any more stops a walk at the link before it until a trusted device signs over it. Rollback: the server can serve an older ciphertext of a record under a higher version number, as long as that version is above the base the ciphertext was sealed against. Merging makes this harmless for fields (old edits never beat newer ones a device holds), but a device that has never seen the newer copy cannot tell it is missing. A per-zone signed version log would close it. Also open: rotating every zone key when a device is removed, and recovery for a person who loses every device (Standard and Advanced protection). +-- **Planned.** Standard protection adds a service key held in an HSM by `orbyn-keyservice`: zone keys are also wrapped to it, so a person who loses every device can sign in on a new one and get their keys back after checks, and every unwrap is audited. Advanced protection replaces the service key with the recovery key (`RecoveryKey::derive`), so only the person can recover. Rotating every zone key when a device is removed, and signed device removal, come with them. +- +-## Merging +- +-Anything received from another device or the server goes through `merge_remote`, which leaves out any field stamped more than an hour ahead of this device's clock and reports it, so one bad clock cannot win a field forever. +- +-Each field carries a hybrid-logical-clock stamp (milliseconds, counter, device). A merge keeps the larger stamp per field, so merging is commutative, associative and idempotent. If two edits carry exactly the same stamp but different values, the one with the greater canonical JSON string wins, so even a reused stamp merges the same way everywhere. A delete is the `_deleted` field and merges like any other edit. +- +-A clock refuses a received stamp more than one hour ahead of its own wall clock (`ClockError::TooFarAhead`) and stays unchanged, so one bad device cannot push every later edit into the future. When the counter of a millisecond is full, the clock moves to the next millisecond instead of repeating a stamp. +- +-## Jobs +- +-Email is a job kind, `email`: its payload names the kind and account in the clear and holds the address and code sealed with the job's id as associated data. A failed send is retried with the queue's backoff (2, 4, 8 … seconds, at most 10 minutes) and the job is given up on when it is 24 hours old, or at once when the address cannot be one or the server refuses it for good; it gets 200 attempts, enough for those 24 hours. +- +-The worker also runs housekeeping on a schedule, not as queued jobs: expired sign-in data every 5 minutes and the purge (see Record store) every 10. Jobs live in Postgres. A worker claims one with `FOR UPDATE SKIP LOCKED` and a 60-second lease; an expired lease returns the job to the queue. Only the lease holder can complete or fail a job. Failures retry after 2, 4, 8 … seconds (at most 10 minutes) until `max_attempts`, then the job is `failed`. A lease that expires after the job's last attempt marks it `failed` instead of reclaiming it. While a handler runs, the worker renews the lease every third of its length; if a renewal finds the lease lost, the worker abandons the result and does not complete the job. An idempotency key, unique per job kind, makes enqueueing the same effect twice a no-op. +- +-Delivery is at least once: a crash or a lost lease runs a job again. Handlers must be idempotent, passing the job id or its idempotency key to every provider as the operation identity. +- +-## Running +- +-`docker compose up -d` starts Postgres, runs `orbyn migrate`, then `orbyn serve` on `127.0.0.1:8080` and one `orbyn worker`. Sign-in needs `ORBYN_AUTH_SETUP` in the environment, from `orbyn auth-setup`. Sign-up needs an invite code unless `ORBYN_SIGNUP=open` (see Accounts and devices); make codes with `orbyn invite create`. The image is distroless and runs as a non-root user; `orbyn health` is its health check. Logs are JSON in the image and never include request bodies. +- +-**A database that ran the pre-merge invite branch must be recreated.** Migration `0016_invites.sql` was edited after that branch applied it (the label check and the `invites_used_by_account` index), so such a database fails to migrate with `VersionMismatch(16)`. Nothing is deployed, so there is no `0017`; drop and recreate local and staging databases made before #80 merged. CI starts fresh and is unaffected. +- +-**Sizing the database pool.** `ORBYN_DATABASE_POOL_SIZE` (4 to 200, default 10) is how many connections one process may open; on the server one of them is held for good by the live listener. `orbyn serve` and `orbyn worker` are separate processes, each with a pool of that size, and every replica runs both, so the connections the fleet can open are +- +- replicas × (server pool + worker pool) +- +-and that must stay below Postgres's `max_connections`, less what Postgres keeps for superusers (`superuser_reserved_connections`, 3 by default) and for migrations, backups and people with a console (leave about ten). Worked example, with the default `max_connections = 100`: three replicas with the same setting for both processes and `ORBYN_DATABASE_POOL_SIZE=12` open at most 3 × (12 + 12) = 72 connections, which leaves 28 for the reserve, `orbyn migrate` and admin tools. A pool of 20 would be 3 × 40 = 120, more than Postgres accepts, and the replicas would fail to connect under load. A connection pooler in front (PgBouncer in session mode) lifts the ceiling, not the rule. The upload caps follow the pool, so size it first: the replica cap is half the pool (pool 12 gives 6), the address cap is 16 or the replica cap less one if that is lower (5 at pool 12), and the account cap stays 4. A worker needs few connections (jobs, the purge and the limiters), so give it a smaller pool by setting the variable differently on its container. +- +-**Email.** The worker sends mail over SMTP (lettre, rustls), and only over TLS. `ORBYN_SMTP_URL` is either `smtps://user:password@host:465` (TLS from the first byte) or `smtp://user:password@host:587?tls=required` (STARTTLS, and a server that does not offer it is not used, so a STARTTLS stripped by something on the path sends nothing and the message is retried later). Any other URL, such as `smtp://host:587` with no `tls=required`, `tls=opportunistic`, or `tls=required` that is really part of a fragment (`smtp://host#?tls=required`), stops the worker at start with the designed message for that mistake (not an address, no host, not a mail scheme, no `tls=`, a value that can send without encryption, more than one `tls=`, `tls=required` after a `#` or inside another setting, the mail library refusing the address), printed to stderr with exit status 1; none of them shows the password (it reads `***`); the URL is parsed with the `url` crate, as lettre parses it. The other start-up errors about mail settings are designed the same way: `ORBYN_SMTP_URL` without `ORBYN_MAIL_FROM`, `ORBYN_SMTP_CA_FILE` without `ORBYN_SMTP_URL`, a worker with `ORBYN_SMTP_URL` but no `ORBYN_AUTH_SETUP`, and a worker with no `ORBYN_SMTP_URL` and no `ORBYN_MAIL=log`. The worker's email log lines name the job and the kind in words (`verification code`, `sign-up notice`, `account deletion notice`) and never the address or the body: `email job () gave up: the message couldn't be built (…)`, `… gave up: the mail server refused it for good ( )`, `… gave up: it waited in the queue for