Skip to content

engine: harden vault settings freshness against suppression and cold-device replay #904

Description

@FSM1

The vault settings load (#870, PR #903) reports why it fell back to defaults — DefaultsReason::{Suppressed, RolledBack, TimedOut, Unreadable} — but nothing acts on any of them yet, and the freshness bound on a cold device is weaker than the rest of the resolve plane's.

Surfaced by the /security-review and /crypto-privacy-review gates on PR #903. None of these is exploitable for plaintext disclosure — the record is HPKE-sealed and only the login-secret holder can sign at its name — but each lets an adversary who controls the record plane revert a member's explicit configuration.

The residuals

1. A degraded load still applies the hosted-pinning default. A member who chose PinMode::External ("never put my bytes in CipherBox's store") gets Hosted any time the record is withheld or its head block is unreachable. Suppressed names that case but there is no last-known-good settings copy to prefer instead, and no policy for what a host should do when it sees it. Wanted: cache the last gate-passing settings behind the existing SnapshotCache-shaped durable store and prefer them over VaultSettings::default(), so suppression degrades to stale but chosen rather than reverted.

2. A fresh device accepts an arbitrarily old settings record. Rollback defence is the per-name sequence floor, which is device-local. A new install has no floor, so any owner-signed record the network serves is admitted — including one captured before the member rotated a BYO access_token, which the engine would then present as a bearer credential to an endpoint they stopped trusting. Two bounds are available:

  • EOL. IpnsRecord::verify checks the signature chain only; verified.validity is available and net/eol.rs::is_expired exists but has one caller (net/liveness.rs). Checking it on this resolve bounds replay to the 90-day EOL window. Deliberately not done in engine: publish and resolve the vault settings record #870 because no resolve path checks EOL today and special-casing one record would be inconsistent — so the decision is "check it everywhere, or here with a stated reason", and it belongs with this issue rather than that slice.
  • A body revision. The seal binds [domain, v, structTag, ownerTag] — no name, no sequence, no time — so every settings record the owner ever wrote opens forever. A monotonic revision inside the sealed body, with the highest seen persisted per account rather than per device, survives a fresh floor store and is the only bound that does.

3. A same-sequence fork stays admissible forever. publish mints floor + 1 and the floor advances only behind a confirmed publish, so an Unconfirmed publish followed by a retry yields two distinct owner-signed records at the same sequence. The load admits >= floor with no byte comparison, so a chosen-record adversary picks which one every device sees, permanently. The body revision in (2) disambiguates this; the outer sequence cannot.

Scope

  • Durable last-known-good settings, and a stated host policy for Suppressed / RolledBack.
  • The EOL decision for the resolve plane, applied consistently.
  • A monotonic revision in the settings body (a SETTINGS_RECORD_V bump or a body-schema addition, plus its KAT vectors).
  • Tests: a withheld record must not downgrade External to Hosted; a fresh device must refuse a record below the account's highest known revision; an expired EOL is not authoritative.

Gate: Engine Tests (plus Core KATs if the body schema changes).

Split

Decomposed by the scoping pass in this issue's comment thread. This issue is now an umbrella; the work lands in its slices.

Two corrections the scoping pass established, against this issue's original framing:

  • A body revision is not "the only bound that survives a fresh floor store" — there is no per-account durable store in the engine at all, so EOL is the only bound that survives a fresh device.
  • The revision costs no SETTINGS_RECORD_V bump and no KAT regeneration: crates/core treats the settings body as opaque (crates/core/src/seal/settings_record.rs:21).

Depends on

Blocks

Part of #655

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