diff --git a/engine/guest/src/lib.rs b/engine/guest/src/lib.rs index 849958f..813e015 100644 --- a/engine/guest/src/lib.rs +++ b/engine/guest/src/lib.rs @@ -1318,12 +1318,13 @@ async fn pickup_devices(doc: &[u8]) -> Result, String> { Ok(devices) } -/// Publish one member's K_p: the name-key keychain + device list, sealed -/// to a prekey DH (see `seal_to_member`). -async fn publish_kp(st: &S3Cfg, kh: &Kh, doc: &[u8], member: &[u8]) -> Result<(), String> { - let my_id_bytes = with_state(|s| s.my_peer.as_bytes().to_vec())?; +/// The bootstrap payload as this device currently holds it: the WHOLE +/// name-key chain plus the honest author set. Shared by the grantee K_p +/// and by the self-addressed chain drop (#110), which differ only in +/// where they are written and to whom. +async fn kp_payload_for(doc: &[u8]) -> Result { let devices = pickup_devices(doc).await?; - let payload = with_state(|s| { + with_state(|s| { let b = s.buckets.get(doc).ok_or("no bucket state".to_string())?; Ok::<_, String>(KpPayload { name_keys: b @@ -1334,7 +1335,44 @@ async fn publish_kp(st: &S3Cfg, kh: &Kh, doc: &[u8], member: &[u8]) -> Result<() .collect(), devices, }) - })??; + })? +} + +/// Open a `KpObject` addressed to THIS device: look up the secret for +/// the recorded member prekey and DH against the writer's. +/// +/// Factored out of `s3_pull`'s link-tier arm so the chain drop's read +/// path is the same code rather than a second copy of it — the drop and +/// the grantee K_p are the same bytes under the same `kp-wrap` info and +/// the same doc AAD, so anything that could open one opens the other. +async fn open_kp_object(kh: &Kh, doc: &[u8], blob: &[u8]) -> Result { + let obj: KpObject = bincode::deserialize(blob).map_err(|e| format!("kp decode: {e}"))?; + let pairs = my_prekey_pairs(kh).await?; + let sk = pairs + .get(&obj.member_pk) + .ok_or("K_p not sealed to any of my prekeys")?; + let ikm = sk.derive_new_secret_key(&obj.owner_pk).to_bytes(); + let aead = ikm_aead(&ikm, b"kp-wrap").await?; + bincode::deserialize(&aead_open(&aead, doc, &obj.sealed).await?) + .map_err(|e| format!("kp payload decode: {e}")) +} + +/// Is `doc` this device's own account document? +/// +/// The chain-drop machinery (#110) turns on this and nothing else: every +/// doc's chain lives IN the us-doc, so a CONTENT doc's rotation is +/// learned through ordinary us-doc sync and strands nobody. Only the +/// us-doc's own rotation can hide the chain that would have announced +/// it, which is why the drop exists for exactly one document. +fn is_us_doc(doc: &[u8]) -> Result { + with_state(|s| s.us.doc.as_deref() == Some(doc)) +} + +/// Publish one member's K_p: the name-key keychain + device list, sealed +/// to a prekey DH (see `seal_to_member`). +async fn publish_kp(st: &S3Cfg, kh: &Kh, doc: &[u8], member: &[u8]) -> Result<(), String> { + let my_id_bytes = with_state(|s| s.my_peer.as_bytes().to_vec())?; + let payload = kp_payload_for(doc).await?; let obj = seal_to_member( kh, doc, @@ -1354,6 +1392,209 @@ async fn publish_kp(st: &S3Cfg, kh: &Kh, doc: &[u8], member: &[u8]) -> Result<() .await } +// --- the self-addressed chain drop (#110) ------------------------------- +// +// THE STRAND IT CLOSES. `store_revoke` on the us-doc appends a name-key +// epoch; the rotator's later flushes land under the NEW epoch's names; a +// sibling holding only the OLD chain — bucket-only, no wire — scans its +// keychain newest-first, finds the rotator's STALE old-epoch manifest, +// and reads stale account state silently forever. Its chain could only +// be refreshed by reading the us-doc, whose newest objects are exactly +// the ones it cannot name. A content doc's rotation strands nobody +// (the new chain arrives through us-doc sync); only the us-doc's own +// rotation hides the announcement of itself. +// +// THE ANSWER, and it is deliberately the dullest one available: at +// us-doc rotation the rotator writes each non-revoked account device a +// copy of the payload it already builds for grantees, at a location +// that device can derive with no help at all — its OWN id in both the +// owner and member slots. One name per (doc, member); any rotator +// writes it; the reader probes exactly one object. +// +// SIBLINGS OF THE GRANTEE K_p, not a new mechanism: same `KpPayload`, +// same `seal_to_member` under the same `kp-wrap` info and doc AAD, same +// `open_kp_object` on the way back. ONLY THE ADDRESSEE DERIVATION +// DIFFERS — `kp_location(doc, granter, member)` for a grant this device +// issued, `kp_location(doc, member, member)` for a drop addressed to +// whoever can already name themselves. +// +// NO COLLISION IS POSSIBLE with an existing object, and the argument is +// short: a grantee K_p sits at `kp_location(doc, granter, member)` where +// `granter` is the device that ran `store-grant`, and a device never +// grants a pickup to itself — an account device reads the chain out of +// the us-doc (SYNC.md §1) and needs no pickup, and the link tier's +// grantees are outside parties. So `owner == member` names a slot that +// nothing else has ever written. +// +// CONCURRENT ROTATORS last-writer-win the drop. That is not a defect +// introduced here: the chain itself is ONE `ScalarValue::Bytes` register +// merged wholesale (usdoc's `BUCKET_CHAINS`), so concurrent rotation is +// already last-writer-wins on the authoritative copy. The drop is a +// best-effort FRESHNESS channel for a device that cannot reach the +// truth channel; us-doc sync over the wire remains the truth. + +/// Where a device's own chain drop lives on S3. Self-addressed: owner +/// and member are both `member`. +async fn drop_location_s3(doc: &[u8], member: &[u8]) -> Result { + kp_location(doc, member, member).await +} + +/// Write every non-revoked account device its own chain drop, and delete +/// the revoked member's if one is there. +/// +/// Called from `store_revoke`'s S3 arm, immediately after the rotation +/// that creates the hazard and with the same payload the grantee +/// republish below it uses. +/// +/// FAILURES ARE PER-DEVICE AND NON-FATAL. The revocation itself has +/// already landed — membership revoked, K_p deleted, epoch rotated — and +/// it is correct. A drop is a freshness convenience for a sibling that +/// may not even exist offline right now; losing one to a transient PUT +/// would be a bad reason to report a completed revocation as failed. The +/// sibling's other recovery path (a wire sync with any current device) +/// is unchanged, and the next rotation writes a fresh drop. +async fn publish_chain_drops( + st: &S3Cfg, + kh: &Kh, + doc: &[u8], + revoked: &[u8], +) -> Result<(), String> { + if !usdoc::has_account()? { + return Ok(()); + } + // `PM_NO_CHAIN_DROP` exists for the same reason `PM_NO_ROTATE` does + // in usdoc.rs: to keep the MEASUREMENT re-runnable rather than a + // claim in a comment. With it set, `just recover` reproduces the + // pre-fix strand — the lagging sibling reads the rotator's stale + // old-epoch manifest and misses everything after the rotation — and + // the recovery battery's chain-drop act fails, which is the point. + if std::env::var("PM_NO_CHAIN_DROP").is_ok() { + eprintln!("[drop] PM_NO_CHAIN_DROP set: writing no drops (the pre-#110 behaviour)"); + return Ok(()); + } + let payload = bincode::serialize(&kp_payload_for(doc).await?).map_err(|e| e.to_string())?; + let devices = usdoc::devices_list().await?; + let mut written = 0usize; + for d in &devices { + // The member being revoked gets no drop (its own is deleted + // below), and neither does a row already flagged revoked: a + // revoked device is not a sibling to keep current. + if d.agent_id == revoked || d.revoked { + continue; + } + // The rotator's own drop is written too, deliberately. It costs + // one PUT and removes a case: this device may itself be the + // lagging sibling next time, restored from a checkpoint that + // predates a rotation somebody else performed. + let name = match drop_location_s3(doc, &d.agent_id).await { + Ok(n) => n, + Err(e) => { + eprintln!("[drop] location for {}: {e}", &hex::encode(&d.agent_id)[..8]); + continue; + } + }; + // Prekeys this keyhive does not hold should not happen for an + // ENROLLED device — enrollment ingests the card — so a skip here + // is worth saying out loud rather than swallowing. + let obj = match seal_to_member(kh, doc, &d.agent_id, b"kp-wrap", &payload).await { + Ok(o) => o, + Err(e) => { + eprintln!( + "[drop] cannot seal a chain drop to enrolled device {}: {e}", + &hex::encode(&d.agent_id)[..8] + ); + continue; + } + }; + let body = match bincode::serialize(&obj) { + Ok(b) => b, + Err(e) => { + eprintln!("[drop] serialize: {e}"); + continue; + } + }; + match put_object(st, &EngineFetch, &EngineSigner, &name, body).await { + Ok(()) => written += 1, + Err(e) => eprintln!( + "[drop] write for {} failed (revocation stands): {e}", + &hex::encode(&d.agent_id)[..8] + ), + } + } + // The revoked party's drop goes away beside its K_p. A stale drop + // only carries the OLD chain, which that party already had, so this + // buys no secrecy — but pull-now hygiene is the K_p discipline and + // the drop is the K_p's sibling, so it follows the same rule rather + // than acquiring an exception nobody would remember. + if let Ok(name) = drop_location_s3(doc, revoked).await { + if let Err(e) = delete_object(st, &EngineFetch, &EngineSigner, &name).await { + // Absence is success: S3 answers a delete of a missing key + // with 204, and anything that does not is a hint, not a + // failure of the revocation. + eprintln!("[drop] revoked member's drop delete (absence is fine): {e}"); + } + } + eprintln!("[drop] wrote {written} chain drop(s) for the account's devices"); + Ok(()) +} + +/// THE PROBE. Read this device's own chain drop and adopt the carried +/// chain IF IT IS LONGER than the one held locally. +/// +/// US-DOC ONLY, and called from the SIBLING branch of the account pull +/// before any manifest is named — the whole point is to fix the names +/// the scan is about to derive. +/// +/// ADOPT-IF-LONGER IS THE IDEMPOTENCE. A chain only ever extends +/// (`rotate_bucket_chain` pushes; nothing truncates), so "longer" is a +/// total order on the versions a device can hold, and a second probe +/// after adoption compares equal and writes nothing. That guard is doing +/// real work: `adopt_bucket_chain` writes through to the us-doc's chain +/// register, so an unguarded adopt would author one us-doc change per +/// pull, forever, carrying a value that did not change. +/// +/// ABSENCE IS THE ORDINARY ANSWER: no rotation has happened since this +/// device last flushed, or none ever has. One small GET per us-doc pull +/// buys it, on S3 — the only provider that rotates, and so the only one +/// that calls this (see `gd_pull`'s sibling branch for why Drive does +/// not, and for what re-adding it would take). The change-board +/// optimization, where a rotator patches an epoch ordinal and the probe +/// runs only when it exceeds the local chain length, is PARKED, not +/// built. +/// +/// The body below is provider-neutral on purpose: it takes the fetched +/// bytes rather than fetching them, so a second provider's probe is its +/// own two-line fetch and nothing else. +/// +/// A drop that fails to open is a HINT THAT FAILED, never a pull that +/// failed: the scan below it works exactly as well as it did before this +/// existed, which is the whole safety argument for adding a fetch to a +/// path that was already correct for every non-rotating account. +async fn probe_chain_drop(doc: &[u8], blob: Option>) -> Result, String> { + let Some(blob) = blob else { return Ok(None) }; + let kh = with_state(|s| s.kh.clone())?; + let payload = match open_kp_object(&kh, doc, &blob).await { + Ok(p) => p, + Err(e) => { + eprintln!("[drop] unreadable, pulling with the chain in hand (hint only): {e}"); + return Ok(None); + } + }; + let mut carried: Vec<(u32, [u8; 32])> = payload.name_keys; + carried.sort_by_key(|(e, _)| *e); + let local = with_state(|s| { + s.buckets + .get(doc) + .map(|b| b.name_keys.len()) + .unwrap_or_default() + })?; + if carried.len() <= local { + return Ok(None); + } + adopt_bucket_chain(doc, carried.iter().map(|(_, nk)| *nk).collect()).await?; + Ok(Some(carried.len())) +} + // --- the Dropbox provider --- // // The protocol itself (RPC shapes, uploads, downloads, link mint/revoke, @@ -2258,6 +2499,27 @@ async fn gd_pull(doc_id: Vec, owner: Vec, pickup: Option) -> Res let (keychain, devices, folder) = if sibling { // The chain is already reconciled against the account by the // `ensure_bucket_state` above; no adoption, nothing to unseal. + // + // NO CHAIN-DROP PROBE HERE, and the absence is the ruling + // (#110). S3's sibling branch probes a self-addressed drop + // because a us-doc rotation can leave it naming an epoch behind; + // this provider CANNOT reach that state, because its + // `store_revoke` arm deliberately never rotates (see the ruling + // there: names on this provider blind an observer's labels and + // are not access control, so a fresh epoch would draw no + // boundary). No rotation means no drop is ever written, so a + // probe here would be one resolve + one download per us-doc + // pull, forever, for an object that cannot exist — a standing + // bill for a readiness that may never be called on. + // + // If Drive ever does grow a rotation trigger, the drop is + // re-added on BOTH sides together: `publish_chain_drops` gains a + // gdrive arm writing to `gd_pickup_name(doc, member, member)` in + // the flat pickup folder, and this branch gains the mirror of + // `s3_pull`'s probe — `gd_download` of that name into + // `probe_chain_drop`, before `doc_keychain` below. Three lines + // each, and the reading half (`probe_chain_drop`, + // `open_kp_object`) is already provider-neutral. let keychain = doc_keychain(&doc_id)?; let owner_vk = arr32(&owner, "pull owner device id")?; @@ -2465,6 +2727,21 @@ async fn s3_pull(doc_id: Vec, owner: Vec) -> Result { // pull answers events=0 chunks=0 — the ordinary "nothing new" // the boot-time fan-out will hit for most (partition, sibling) // pairs. + // + // THE CHAIN DROP PROBE (#110), before a single name is derived. + // `ensure_bucket_state` above reconciled this device's chain + // against its own copy of the us-doc — which is exactly the copy + // a rotation this device missed has not updated — so on the + // us-doc the reconciliation can hand back a chain that is one + // epoch behind the names it is about to derive. One GET at a + // self-addressed location fixes that or answers absent. + if is_us_doc(&doc_id)? { + let name = drop_location_s3(&doc_id, &my_id).await?; + let blob = get_object_unsigned(&st, &EngineFetch, &name).await?; + if let Some(epochs) = probe_chain_drop(&doc_id, blob).await? { + eprintln!("[drop] adopted a longer chain from my own drop: {epochs} epoch(s)"); + } + } (doc_keychain(&doc_id)?, vec![arr32(&owner, "pull owner device id")?]) } else { // NON-ACCOUNT OWNER — the link tier. Unchanged, byte for byte: @@ -3719,6 +3996,19 @@ impl DriverGuest for Component { // there (SYNC.md §1), so the owner's other devices flush // under it without being told separately. rotate_bucket_chain(&doc_id).await?; + + // THE SELF-ADDRESSED CHAIN DROP (#110), beside the + // grantee republish below and for the case that + // republish structurally cannot cover: the account's own + // devices hold no K_p — they read the chain out of the + // us-doc — so a sibling that is offline across THIS + // rotation has no object naming the new epoch and, for + // the us-doc alone, no way to learn one. See the drop + // section above for why only this document needs it. + if is_us_doc(&doc_id)? { + publish_chain_drops(&st, &kh, &doc_id, &member).await?; + } + let remaining = with_state(|s| { let b = s .buckets diff --git a/engine/host/src/main.rs b/engine/host/src/main.rs index 936ab86..2c962c0 100644 --- a/engine/host/src/main.rs +++ b/engine/host/src/main.rs @@ -617,7 +617,18 @@ async fn recover_scenarios( make_store: &StoreFactory<'_>, probe: &resume_acts::S3Probe, ) -> Result<()> { - let mut store = make_store(&[]); + // The strand's NEGATIVE CONTROL, forwarded rather than hardcoded: + // `PM_NO_CHAIN_DROP=1 just recover` makes the guest write no chain + // drops (#110's pre-fix behaviour) and the chain-drop act below then + // FAILS, which is what turns "the strand was real" from a sentence + // into something anybody can re-run. + let no_drop = std::env::var("PM_NO_CHAIN_DROP").unwrap_or_default(); + let env: Vec<(&str, &str)> = if no_drop.is_empty() { + Vec::new() + } else { + vec![("PM_NO_CHAIN_DROP", no_drop.as_str())] + }; + let mut store = make_store(&env); let account = bindings::Engine::instantiate_async(&mut store, component, linker).await?; let restored = bindings::Engine::instantiate_async(&mut store, component, linker).await?; let double = bindings::Engine::instantiate_async(&mut store, component, linker).await?; diff --git a/engine/host/src/recover_acts.rs b/engine/host/src/recover_acts.rs index 4a7d90c..6265ffd 100644 --- a/engine/host/src/recover_acts.rs +++ b/engine/host/src/recover_acts.rs @@ -617,5 +617,160 @@ pub(crate) async fn recover_act( Err(e) => refused("act 8: double restore (file)", &e, "kp missing")?, } + // === act 9: a rotation does not strand a bucket-only sibling (#110) === + // + // THE STRAND THIS CLOSES. `store_revoke` on the us-doc appends a + // name-key epoch. The rotator's later flushes land under the NEW + // epoch's names. A sibling holding only the OLD chain scans its + // keychain newest-first, finds the rotator's STALE old-epoch + // manifest, and reads stale account state — silently, forever, + // because the chain that would correct it lives in the us-doc whose + // newest objects are exactly the ones it cannot name. + // + // THE CAST IS ALREADY RIGHT. `restored` is a real account device + // that is CURRENT as of its consume, and no wire exists between it + // and `account` anywhere in this battery — so "bucket-only lagging + // sibling" is what it is, not what it is pretending to be. + // + // THE NEGATIVE CONTROL IS RUNNABLE, not quoted: with + // `PM_NO_CHAIN_DROP=1 just recover` the guest writes no drops and + // this act fails at the profile comparison below, the lagging device + // still holding the pre-rotation profile. That is the pre-fix + // behaviour, reproducible on demand rather than asserted in prose. + + // (a) A fresh kit, revoked — a us-chain rotation `restored` was not + // present for. File kits, so no bundle object joins the object + // set and the deltas below stay about pickups and drops alone. + // The account's registry may still carry act 8's row: that kit was + // consumed by the RESTORING device, and this device has not pulled + // the clear. Harmless and not this act's business — so the two new + // kits are identified by DIFFERENCE against what was already listed, + // rather than by assuming an empty registry. + let pre_kits: std::collections::HashSet> = + step!("act 9: account.recovery-kits (before)", a.call_recovery_kits(acc)) + .into_iter() + .map(|k| k.agent_id) + .collect(); + let kit_a = step!( + "act 9: account.recovery-kit-create-file(rotation trigger)", + a.call_recovery_kit_create_file( + acc, + "the rotation trigger".to_string(), + FILE_PASSPHRASE.to_string(), + ) + ); + let kit_b = step!( + "act 9: account.recovery-kit-create-file(the drop witness)", + a.call_recovery_kit_create_file( + acc, + "the drop witness".to_string(), + FILE_PASSPHRASE.to_string(), + ) + ); + if kit_a.is_empty() || kit_b.is_empty() { + bail!("act 9: a kit ceremony returned no bytes"); + } + let fresh: Vec> = step!("act 9: account.recovery-kits", a.call_recovery_kits(acc)) + .into_iter() + .map(|k| k.agent_id) + .filter(|id| !pre_kits.contains(id)) + .collect(); + if fresh.len() != 2 { + bail!("act 9: expected two NEW kits before the rotation, got {}", fresh.len()); + } + let trigger = fresh[0].clone(); + let witness = fresh[1].clone(); + + step!( + "act 9: account.recovery-kit-revoke(rotation trigger)", + a.call_recovery_kit_revoke(acc, trigger.clone()) + ); + + // (b) A us-visible change AFTER the rotation, and a flush. Both the + // registry (the revoke cleared a row) and the profile move, so + // the assertion does not rest on one key. + step!( + "act 9: account.us-profile-set(after the rotation)", + a.call_us_profile_set( + acc, + UsProfile { + display_name: "Rose, post-rotation".to_string(), + hue: 42, + icon: None, + }, + ) + ); + step!( + "act 9: account.bucket-flush(us) [under the NEW epoch]", + a.call_bucket_flush(acc, Vec::new()) + ); + + // (c) The lagging sibling pulls. ONE pull: the probe adopts the + // longer chain before a single manifest name is derived, so the + // scan that follows is the scan it would have done had it never + // lagged. No second pull is permitted here — needing one would + // mean the probe landed after the names it was supposed to fix. + let summary = step!( + "act 9: restored.bucket-pull(us, account) [one pull, lagging by an epoch]", + r.call_bucket_pull(acc, Vec::new(), a_id.clone(), None) + ); + println!(" us: {summary}"); + + let profile = step!("act 9: restored.us-profile-get", r.call_us_profile_get(acc)); + if profile.display_name != "Rose, post-rotation" || profile.hue != 42 { + bail!( + "act 9: THE STRAND — the lagging sibling read stale state across the rotation: \ + got {:?}/{}, expected \"Rose, post-rotation\"/42", + profile.display_name, + profile.hue + ); + } + let kits = step!("act 9: restored.recovery-kits", r.call_recovery_kits(acc)); + if kits.iter().any(|k| k.agent_id == trigger) { + bail!("act 9: the lagging sibling still lists the revoked kit: {kits:?}"); + } + if !kits.iter().any(|k| k.agent_id == witness) { + bail!("act 9: the lagging sibling lost the surviving kit: {kits:?}"); + } + ok("act 9: one pull across a missed rotation — profile AND registry are current", Instant::now()); + + // (d) THE REVOKED PARTY'S DROP IS DELETED, beside its K_p. Asserted + // by set difference, the act-6 discipline: the witness kit was + // enrolled BEFORE the rotation above, so that rotation wrote it a + // drop, and revoking it now must take exactly two objects away — + // its pickup and its drop. + // + // EXACTLY TWO is the whole assertion, and it is a sharp one: + // nothing else in this flow deletes anything, so the count + // distinguishes the two deletions from the one a build without + // the drop would perform. + // + // WHAT IS DELIBERATELY NOT ASSERTED is that nothing is ADDED. It + // is not true and must not be: this revoke rotates, and the + // account's flush right after it rewrites the us-doc's oplog, + // manifest and chunks under the NEW epoch's names. That rewrite + // is exactly the phenomenon that stranded the sibling in the + // first place — asserting it away would be asserting the absence + // of the bug this act exists to prove is handled. The drop + // REFRESHES for surviving devices do land on the names they + // already occupy, but they are indistinguishable host-side from + // the flush's own opaque names, so the claim is left to the + // mechanism rather than dressed up as a measurement. + let before = probe.keys().await?; + step!( + "act 9: account.recovery-kit-revoke(the drop witness)", + a.call_recovery_kit_revoke(acc, witness.clone()) + ); + let after = probe.keys().await?; + let gone: Vec<&String> = before.iter().filter(|k| !after.contains(k)).collect(); + if gone.len() != 2 { + bail!( + "act 9: revoking a kit that had a drop removed {} object(s), expected exactly 2 \ + (its pickup + its drop): {gone:?}", + gone.len() + ); + } + ok("act 9: the revoked kit's pickup AND its chain drop are gone (exactly two)", Instant::now()); + Ok(()) } diff --git a/runtime/SYNC.md b/runtime/SYNC.md index 72d37a3..f69165b 100644 --- a/runtime/SYNC.md +++ b/runtime/SYNC.md @@ -61,6 +61,33 @@ chain for NON-account readers (S3's link tier), unchanged. holding the user's OAuth is already outside this provider's threat reach (DRIVE.md §1's honest revocation note), so chain rotation adds little until that story changes. +- THE SELF-ADDRESSED CHAIN DROP (added 2026-08-25, closing #110). A + us-doc rotation used to strand a bucket-only lagging sibling + PERMANENTLY: the new epoch's objects sit under names only the new + chain derives, and the chain lives in the very document those names + gate — the sibling silently reads the rotator's stale old-epoch + manifest forever, until wire contact. Now `store_revoke` on the + us-doc writes every NON-REVOKED account device a sealed copy of the + new chain at `kp_location(us, member, member)` — the K_p machinery + self-addressed: one derivable location per member, any rotator + writes it, payload sealed to the member's contact-card prekeys + exactly like a grantee K_p (same payload, same sealing, different + addressee derivation) — and the S3 sibling pull PROBES ITS OWN DROP + before deriving any name, adopting a strictly longer chain + (idempotent: chains only extend). The revoked member's drop is + deleted beside its K_p. Concurrent rotators last-writer-win the + drop, exactly as the chain register itself is LWW under concurrent + rotation — the drop is a best-effort freshness channel; us-doc sync + over the wire remains the truth channel. Drive writes and probes no + drop BECAUSE its store-revoke arm never rotates (names there are not + access control, by its own ruling) — the reading half is + provider-neutral and the re-add is three lines per side if a trigger + ever exists. Gates: the recover battery's act 9 (one pull across a + missed rotation, with a runnable `PM_NO_CHAIN_DROP` negative control + reproducing the strand), devstore row 67 (the same claim through the + worker's own pull path; row 63's no-revocation pair constraint is + now historical). Parked: the change-board epoch-ordinal + optimization (probe only when the board says the chain moved). THE FIRST RULING — derive from keyhive epoch secrets, G4's coupling sketch — is RECORDED AS THE BLOCKED IDEAL, not deleted: it would make diff --git a/runtime/tests/devstore/page.ts b/runtime/tests/devstore/page.ts index c61e6bc..30704b1 100644 --- a/runtime/tests/devstore/page.ts +++ b/runtime/tests/devstore/page.ts @@ -2118,6 +2118,41 @@ const ops: Record Promise> = { return { siblings: sibs.length, usPulled: us.filter(Boolean).length }; }, + /** + * PULL THE ACCOUNT DOCUMENT FROM ONE NAMED SIBLING, and hand back the + * guest's own summary string. + * + * `rc-pull-now` fans out over the whole directory, which is right for + * a device catching up in general and wrong for a row whose claim is + * about ONE pull from ONE device: an account that has minted kits and + * restored devices carries several non-revoked members, and most of + * those pairs are 404s that say nothing. + * + * THE SUMMARY IS THE EVIDENCE. `bucket-pull` answers + * `pulled s3(account) epochs=N devices=… events=… chunks=…`, and + * `epochs` is the length of the name-key chain the pull actually + * derived names from — so a chain that GREW between two pulls is + * visible from here without exposing a single key. + */ + "rc-pull-us-from": async (arg: { id: string; agentPrefix: string }) => { + const conn = conns.get(arg.id)!; + const devices = await conn.driver.usDevicesList(); + const target = devices.find((d) => hexOf(d.agentId).startsWith(arg.agentPrefix)); + if (!target) return { found: false, summary: "", error: "no such sibling" }; + const r = await attemptValue(() => + conn.driver.bucketPull(new Uint8Array(0), target.agentId, undefined) + ); + return { + found: true, + ok: r.ok, + summary: r.ok ? r.value : "", + /** `epochs=N` out of the summary — the chain length this pull + * derived names from, which is the whole observable of #110. */ + epochs: r.ok ? Number(/epochs=(\d+)/.exec(r.value)?.[1] ?? -1) : -1, + error: r.ok ? "" : r.error.message.slice(0, 140), + }; + }, + /** The account's device directory, as a sheet would render it. */ "rc-devices": async (arg: { id: string }) => { const conn = conns.get(arg.id)!; diff --git a/runtime/tests/devstore/run.ts b/runtime/tests/devstore/run.ts index 9a9e3f7..0536f15 100644 --- a/runtime/tests/devstore/run.ts +++ b/runtime/tests/devstore/run.ts @@ -4402,20 +4402,31 @@ async function main() { // suppression is engine-side, so `profile-changed` arriving there // means B learned it from somewhere other than itself. // - // THE PAIR IS ROW 62'S, and the choice is a FINDING rather than a - // convenience. Rows 59-61 put the S3 account through a + // THE PAIR IS ROW 62'S, and the reason is now HISTORICAL — the + // constraint it was chosen under is gone. It was a FINDING when it + // was written: rows 59-61 put the S3 account through a // `recovery-kit-revoke`, which rotates the us-doc's NAME-KEY EPOCH - // (that is the "hard forward" half of the guarantee note), and a - // sibling that has not yet caught up derives object names from the - // chain it holds — which it can only refresh by reading the us-doc, - // whose newest objects now sit under the NEW epoch's names. Measured - // here: after that revocation the restored device kept reading the - // origin's stale epoch-0 manifest and never saw the profile change. - // That is a pre-existing property of rotation-plus-a-lagging-device - // (SYNC.md's territory, not this round's), and pinning this row to a - // pair that has crossed no revocation keeps it a measurement of the - // us-doc riding the cycle rather than of that separate question. - // Flagged in the track report. + // (the "hard forward" half of the guarantee note), and a sibling + // that had not caught up derived object names from the chain it + // held — which it could only refresh by reading the us-doc, whose + // newest objects now sat under the NEW epoch's names. Measured here + // first: after that revocation the restored device went on reading + // the origin's stale epoch-0 manifest and never saw the profile + // change. That became issue #110. + // + // #110 IS CLOSED, and ROW 67 OWNS THE CLAIM: a rotation now writes + // every non-revoked account device a sealed CHAIN DROP at a + // self-addressed location, and a us-doc pull probes its own drop + // before deriving any name (engine/guest/src/lib.rs's + // `publish_chain_drops` / `probe_chain_drop`). A lagging sibling + // catches up in ONE pull, which is exactly what row 67 asserts + // against a pair that HAS crossed a revocation. + // + // So the pinned pair stays for this row's own FOCUS and nothing + // else: row 63 is about the account document riding the ordinary + // flush/pull cycle with no button pressed, and a pair with no + // rotation in its history keeps that the only variable. It is no + // longer dodging anything. await guard(async () => { const idA = rcGdOrigin; const idB = rcGdRestored; @@ -4555,6 +4566,131 @@ async function main() { await probe(page, "hc-close", { id }); }); + // --- 67: a missed rotation does not strand a bucket-only sibling (#110) - + // + // WHAT ROW 63 HAD TO DODGE, now asserted head-on. `store-revoke` on + // the us-doc APPENDS a name-key epoch, and the rotator's later + // flushes land under the NEW epoch's names. A sibling holding only + // the OLD chain scans its keychain newest-first, finds the rotator's + // STALE old-epoch manifest, and reads stale account state — silently + // and permanently, because the chain that would correct it lives in + // the us-doc whose newest objects are exactly the ones it cannot + // name. Measured in this matrix first (row 63's pinned pair) and + // filed as #110. + // + // THE FIX IS SELF-ADDRESSED (engine/guest/src/lib.rs's + // `publish_chain_drops` / `probe_chain_drop`). A rotation writes + // every non-revoked account device a sealed CHAIN DROP at a location + // only that device derives — `kp_location(us, member, member)`, the + // K_p machinery pointed at itself — carrying the new chain. A + // sibling's us-doc pull probes its OWN drop BEFORE it derives a + // single name, adopts a longer chain if one is there, and then runs + // the ordinary scan it would have run had it never lagged. So the + // catch-up costs ONE pull, not two, and needing two would mean the + // probe landed after the names it was supposed to fix. + // + // THE CAST IS REAL, not arranged: A is the origin device and B is + // the device restored from a kit in row 58 — a genuine account + // member, current as of its consume, with NO WIRE to A anywhere in + // this matrix. "Bucket-only lagging sibling" is what B IS. + // + // THE NEGATIVE CONTROL IS THE ENGINE BATTERY'S, cited rather than + // re-run: `PM_NO_CHAIN_DROP=1 just recover` makes the guest write no + // drops and fails the recovery battery's act 9 at the profile + // comparison, the lagging device still holding pre-rotation state. + // That switch is a guest env var; wiring one through a SharedWorker + // in a browser would be new plumbing for a control that already + // exists where the mechanism lives, so this row asserts the fixed + // behaviour and points at the reproduction. + await guard(async () => { + const idA = rcDevice; + const idB = rcRestored; + const agentA = ((await probe(page, "hc-status", { id: idA })).agentId ?? "").slice(0, 12); + + // (a) BOTH CURRENT. B catches up on everything the matrix has done + // to this account so far, so the only thing it can be behind + // by afterwards is the rotation this row performs. + await probe(page, "rc-pull-now", { id: idB }); + const settled = await probe(page, "rc-pull-us-from", { id: idB, agentPrefix: agentA }); + const beforeA = await probe(page, "rc-profile-get", { id: idA }); + const beforeB = await probe(page, "rc-profile-get", { id: idB }); + + // (b) THE ROTATION B IS NOT PRESENT FOR. A file kit, minted and + // immediately revoked: `recovery-kit-revoke` runs + // `store-revoke` on the us-doc, which is the reachable + // rotation trigger. File kind so no bundle object joins the + // store and the pull below is about pickups and drops alone. + // The new kit is identified by DIFFERENCE — this account's + // registry carries rows from earlier rows of this matrix. + const kitsBefore = await probe(page, "rc-kits", { id: idA }); + const before = new Set(kitsBefore.kits.map((k: { agent: string }) => k.agent)); + await probe(page, "rc-kit-create", { + id: idA, + spec: { kind: "file", label: "the rotation trigger", passphrase: FILE_PASS }, + }); + const kitsAfter = await probe(page, "rc-kits", { id: idA }); + const minted = kitsAfter.kits + .map((k: { agent: string }) => k.agent) + .filter((a: string) => !before.has(a)); + const revoked = minted.length === 1 + ? await probe(page, "rc-revoke", { id: idA, agentPrefix: minted[0] }) + : { attempt: { ok: false } }; + + // (c) A us-VISIBLE CHANGE AFTER THE ROTATION, flushed under the + // new epoch. Profile AND registry both move, so the assertion + // does not rest on one key. + const NEW_NAME = "Renamed across a rotation B never saw"; + // B AUTHORS NOTHING ANYWHERE IN THIS ROW. Everything it learns has + // to come out of the bucket, or the row would be measuring a + // merge instead of a pull. + await probe(page, "rc-profile-set", { id: idA, displayName: NEW_NAME, hue: 3 }); + const flushed = await probe(page, "rc-flush-now", { id: idA }); + + // (d) ONE PULL. No second attempt is permitted here. + const pulled = await probe(page, "rc-pull-us-from", { id: idB, agentPrefix: agentA }); + const afterB = await probe(page, "rc-profile-get", { id: idB }); + const kitsB = await probe(page, "rc-kits", { id: idB }); + const stillListed = kitsB.attempt.ok && + kitsB.kits.some((k: { agent: string }) => k.agent === minted[0]); + + const ok = settled.found === true && settled.ok === true && + beforeB.displayName === beforeA.displayName && + minted.length === 1 && revoked.attempt.ok === true && + flushed.us.refused === false && + pulled.ok === true && pulled.epochs > settled.epochs && + afterB.displayName === NEW_NAME && afterB.hue === 3 && + kitsB.attempt.ok === true && stillListed === false; + record( + "67 sync", + "a missed us-chain rotation does not strand a bucket-only sibling — one pull, self-addressed (#110)", + ok, + `A and B are one account on one S3 bucket with NO WIRE between them (B is the device ` + + `restored from a kit in row 58 — a real member, not a stand-in). Both were brought ` + + `current first: B's us pull derived names from a ${settled.epochs}-epoch chain and ` + + `both read the same profile (${j(beforeB.displayName)}). A then ROTATED the us-doc's ` + + `name-key chain in the way a user reaches: it minted a file kit and revoked it, and ` + + `\`recovery-kit-revoke\` runs \`store-revoke\` on the us-doc — the "hard forward" ` + + `half of the guarantee note it returned (${j(String(revoked.attempt.value ?? "").slice(0, 60))}). ` + + `B was not there for it. A then changed the account profile and flushed, so every new ` + + `us object sits under the NEW epoch's names — the exact configuration that used to ` + + `strand B forever, reading A's stale old-epoch manifest with no way to learn the ` + + `chain, because the chain lives in the document whose current objects it cannot name. ` + + `ONE pull later (no second attempt, and needing one would mean the probe landed after ` + + `the names it was meant to fix) B derived from a ${pulled.epochs}-epoch chain — it ` + + `GREW — and B now reads ${j(afterB.displayName)}/hue ${afterB.hue}, with the revoked ` + + `kit gone from its registry (still listed: ${stillListed}). The mechanism is a ` + + `SELF-ADDRESSED drop: the rotation writes every non-revoked device a sealed copy of ` + + `the new chain at \`kp_location(us, member, member)\` — the K_p machinery pointed at ` + + `itself, a location only that member derives — and a us pull PROBES ITS OWN drop ` + + `before deriving any name, adopting a longer chain if one is there ` + + `(\`publish_chain_drops\` / \`probe_chain_drop\`). NEGATIVE CONTROL, cited rather ` + + `than re-run: \`PM_NO_CHAIN_DROP=1 just recover\` writes no drops and fails the ` + + `recovery battery's act 9 at this comparison, the lagging device still holding ` + + `pre-rotation state. That switch is a guest env var and belongs where the mechanism ` + + `lives; this row measures the same claim through the WORKER's own pull path.`, + ); + }); + await probe(page, "hc-close", { id: rcDevice }); await probe(page, "hc-close", { id: rcRestored });