Skip to content

engine: an owner action can target a superseded scope root after a write rotation #1358

Description

@FSM1

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 #1346create_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    comp:enginecrates/engine — the stateful client brainv2-buildv2 rewrite build slice

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions