What
Every owner action names its target scope root by re-deriving derive_write_name(cachedWriteScopeSeed, scopeId). The cache is never re-checked against the root this session actually adopted, so in the window after a write rotation the derivation names the superseded root — a name the revokee can still author at.
Mechanism
Engine::vault_root_scope (crates/engine/src/facade.rs) returns the derived name; create_invite_link, rotate_now, revoke_grant and grant all anchor on it. deposit_write_seed proves the seed derives the adopted root name at deposit time only.
A write revoke mints a fresh writeScopeSeed and moves the scope to a new name. Until a resolve tick re-adopts and re-deposits, the cache still holds the pre-rotation seed. blueprint/engine.md ("Residuals") is explicit that the old name is hijackable by the revokee — they hold its Ed25519 write-name key and can replay the last owner-signed commitment there.
A second Revoke/RotateNow issued in that window would therefore:
- resolve the hijacked record at the dead name,
- pass
authorize_cut (the replayed owner signature is genuine, and commitment.ipns_name matches the dead name it was signed for),
- drive a full cut against a name no survivor reads,
- return
Ok(CommandOutcome::Done) for a revocation that did not happen,
- and raise the durable read-epoch floor for that
scope_id off an attacker-published record.
Why it is not fixed in #1346
The fix needs the engine to retain the root IpnsName it actually adopted and assert the derivation against it on read. install_cold_start/install_mint already produce that name and open_tick_loop already threads one; making it an engine-held invariant is its own change with its own tests, and the same guard shape (deposit_write_seed's) applies on read rather than only on write.
The condition predates #1346 — create_invite_link derived the same way before it — but #1346 widens the blast radius from "an invite is minted at a dead name" to "a revocation reports success without revoking".
Acceptance
- The engine holds the root
ipnsName it adopted, and the scope-root derivation refuses when derive_write_name(heldSeed, scopeId) does not equal it — MalformedInput { check: "held-write-seed-does-not-name-the-current-root" }.
- A test drives a write rotation, leaves the cache stale, and asserts the next owner action refuses rather than cutting the superseded name.
- The refusal clears once a tick re-adopts.
Part of #635
What
Every owner action names its target scope root by re-deriving
derive_write_name(cachedWriteScopeSeed, scopeId). The cache is never re-checked against the root this session actually adopted, so in the window after a write rotation the derivation names the superseded root — a name the revokee can still author at.Mechanism
Engine::vault_root_scope(crates/engine/src/facade.rs) returns the derived name;create_invite_link,rotate_now,revoke_grantandgrantall anchor on it.deposit_write_seedproves the seed derives the adopted root name at deposit time only.A write revoke mints a fresh
writeScopeSeedand moves the scope to a new name. Until a resolve tick re-adopts and re-deposits, the cache still holds the pre-rotation seed.blueprint/engine.md("Residuals") is explicit that the old name is hijackable by the revokee — they hold its Ed25519 write-name key and can replay the last owner-signed commitment there.A second
Revoke/RotateNowissued in that window would therefore:authorize_cut(the replayed owner signature is genuine, andcommitment.ipns_namematches the dead name it was signed for),Ok(CommandOutcome::Done)for a revocation that did not happen,scope_idoff an attacker-published record.Why it is not fixed in #1346
The fix needs the engine to retain the root
IpnsNameit actually adopted and assert the derivation against it on read.install_cold_start/install_mintalready produce that name andopen_tick_loopalready threads one; making it an engine-held invariant is its own change with its own tests, and the same guard shape (deposit_write_seed's) applies on read rather than only on write.The condition predates #1346 —
create_invite_linkderived the same way before it — but #1346 widens the blast radius from "an invite is minted at a dead name" to "a revocation reports success without revoking".Acceptance
ipnsNameit adopted, and the scope-root derivation refuses whenderive_write_name(heldSeed, scopeId)does not equal it —MalformedInput { check: "held-write-seed-does-not-name-the-current-root" }.Part of #635