Skip to content

Transport v3, phase 2a: host credential store - #210

Merged
whoisaldo merged 8 commits into
feat/transport-v3from
feat/transport-v3-p2a
Nov 8, 2026
Merged

whoisaldo merged 8 commits into
feat/transport-v3from
feat/transport-v3-p2a

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Phase 2a of transport v3: the host credential store (design §4.3), as a new crate credstore/ (eternal-credstore). It implements HostCredentials and isn't wired into the host or the iPad app; shipped v2 behaviour doesn't change.

What it does

  • Records. Each record is pending or active, with at most one active per slot. Caps are 1 active + 3 pending per slot and 64 in all (MAX_CREDENTIALS), enforced when a pairing adds its pending record. A pairing over a cap gets TooMany, and no existing record changes.
  • The three operations. Finalize (promote, replace or conflict), Remove and Expire (pending records 7 days old). Every invalidation goes through one procedure:
    1. record the intent in the keyring (step 2 starts only after that store succeeded);
    2. write the database without the invalidated records, under the base key (below);
    3. commit the keyring: the new key, with the same base.
      Retired dev_ids become tombstones, the last 8 per slot. A rotation past the generation bound is refused before any change.
  • Wrapping key and keyring. Keys are stored only wrapped under the generation's W, using ChaCha20-Poly1305 with (dev_id, g) as associated data. The file is authenticated with HMAC-SHA-256 under a key derived from W by HKDF; everything comes from ring. The keyring is one OS-protected item:
    • on macOS, a data-protection Keychain item, after first unlock, this device only, never synchronized; an existing item is updated in place (its data and protection class);
    • on Windows, a DPAPI-protected HKCU value, flushed before the store returns.
      CredentialStore is the only writer.
  • Durable base key. The keyring carries a base generation next to the committed one: the newest generation known to be durable. The database is always written under the base key, and every keyring blob that may survive a crash holds that key. So after any restart, every write that returned Ok is there; an operation that was killed is exactly before or after; nothing an operation didn't touch is discarded; timestamps never go backwards. The one exception is the first run: a fresh keyring lost before it's durable takes that session's pairings with it.
  • Promotion. A rotation commits a new key at once, but the database moves to it only when it's known to be durable: right after the flushed commit on Windows, and on macOS at an open in a later boot than the one the keyring blob records it was stored in (each blob carries its boot id). A reopen in the same boot, or a boot id that can't be read, changes nothing. On macOS the old base key, and so an older copy of the base file, stays readable to the keyring's owner until that open. At most two database files exist, however often a promotion is interrupted, and an open reads at most two.
  • The head. The database header records the newest committed generation its contents need, with a fingerprint of that generation's key; every write after a commit records at least that. A file whose head is older than the keyring's committed generation is a copy from before a rotation, and a file from another history whose head is the keyring's committed or staged generation is told apart by its fingerprint: neither is used. A rotation goes past every generation this keyring or its database names. A head above the committed generation is taken as lost keyring stores (the open rotates again); after a keyring rollback, which is out of scope, that path can also admit an older file. An open whose surviving keyring is older than the head (its rotation's keyring stores were lost) rotates again.
  • What an open reads is durable. An open syncs the directory before reading, so a reopen in the same boot never serves a state a later power cut could take away.
  • Startup recovery. Recovery follows the design's table: it finishes a rotation, or discards when no current file authenticates under the keyring: a database restored from before a rotation (in the same boot or after a reboot), a rolled-back keyring, a foreign or corrupt database, or a missing keyring. The discard removes every other file. The discard runs through the full procedure, lists the dropped records as needing pairing again, and keeps every existing tombstone. A keyring blob that exists but can't be read (for example, a newer format) is left untouched, and the store reports it instead of opening.
  • Parser. The database parser enforces every store invariant, including the per-slot pending cap and the replace rules, and both formats share one generation bound.
  • Reads without I/O. The session table reads an immutable snapshot that holds the unwrapped keys as boxed Zeroizing values, built when the store opens and after each operation. key() and the other HostCredentials methods never touch the disk. Keys in the store and snapshot are boxed, and the keyring blob and key wraps are encoded into buffers of their final size.
  • Durability. A write is a temporary file, a sync, a rename and a directory sync:
    • macOS uses a barrier sync on the file and a full sync on the directory;
    • Windows writes the rename through and flushes the registry key after each keyring write.
  • Proto. The table of remembered message 1s is sized from HostConfig::max_credentials and two per-device budgets: answered (stale_per_second) and unanswered (unanswered_per_second, new). Each dev_id adds a bounded number of entries per second. A table filled by more dev_ids than the cap refuses new answers and never evicts an unexpired entry.

Tests

  • Store behaviour. Caps, duplicate and retired ids, replace rules, every Finalize outcome, Expire boundaries, timestamps that only grow, the snapshot and HostCredentials, the tombstone limits, and name truncation.

  • Restore. These cases are covered:

    • a database restored from before an invalidation: after promotion, in the same boot before it, and after a reboot before the first open (never used, discarded, nothing removed comes back, unless a power cut also lost that rotation's keyring stores);
    • a database from another keyring history whose head is the keyring's committed or staged generation (never used), including a reused generation that is only staged;
    • a Remove of several ids retiring each in one rotation;
    • an older keyring blob restored on its own (confirms nothing unsafe), and an unreadable boot id (nothing promoted, every write kept);
    • promotions interrupted again and again (never more than two files);
    • a same-generation restore, which only loses additions;
    • a keyring rolled back past a promoted rotation (discarded), and a rotation whose keyring stores were lost (its data kept, and a new rotation);
    • a keyring blob that can't be read, and a keyring that can't be loaded (both left untouched);
    • the generation bound, and the seeding of a fresh keyring;
    • promotion only in a later boot without a flush, and at once with one; the boot id in the blob; at most two files after a hundred waiting rotations, with at most two read at open; file names that aren't a generation ignored;
    • a foreign keyring and no keyring;
    • a database that is corrupt but parseable, and one that is unparseable;
    • a lost database;
    • a stray temporary file.
  • Crashes. tests/crash.rs kills every operation, alone and in unsettled sequences of rotations and plain writes on one open store, at every disk and keyring step:

    • add pending, plain and replacing;
    • Finalize: plain, replacing and conflict;
    • Remove, Expire and timestamp persistence;
    • startup discards.

    Each operation and sequence also runs to completion. Then the process restarts in the same boot, or the power fails: unsynced renames are kept or lost, interrupted writes leave half a file, and the keyring keeps any blob that was visible but not durable (a failed store may have happened, and without a flush any store since the last confirmed one may be lost). Everything runs with a keyring that flushes and one that doesn't, and with a same-boot reopen in the middle of a sequence. After every restart, every operation that returned Ok is there, and a killed one is exactly before or after. In memory, a failed operation leaves exactly before, or exactly after (with writes refused until reopen) once its intent is visible, including when the keyring can't be read back or reads back unparseable. Each recovery is then killed at every step, under every outcome, and the next restart stays within the same states. A staged rotation whose files are all damaged is discarded the same way.

  • Fuzzing. Two new cargo-fuzz targets:

    • credstore_formats: the database and keyring parsers, with round trips, MAC checks, and an independent check of every store invariant on each accepted file;
    • credstore_ops: sessions with one store open across many operations, crashes, restarts in the same boot or after a power loss with a chosen keyring outcome (settling only when the input says so), both keyring kinds, damage at rest or mid-operation, and restores of an earlier authentic copy of the database files or of an older keyring blob on their own. It checks memory after each failed operation, after each restart that every acknowledged write is there, and that an Ok Remove leaves neither id held and an Ok expire no pending record 7 days old. It works out from the keyring and files alone what each open must do (load, empty, or discard), and checks that the store's tombstones match an independent model of the operations and of each discard (the last 8 per slot), that a tombstoned dev_id is never re-added, that no retired record is served again unless a keyring rollback or keyring stores lost to a power cut allow it (a keyring from before its retirement, or after a rollback a file whose head is above the keyring's generation), and that what a reopen after a restore serves survives a further power cut.

    The CI fuzz job lists them, runs the checked-in regression inputs (proto/fuzz/regressions/) first, and also runs when credstore/ changes.

  • Keyrings. The in-memory keyring separates visible from durable and models boots. On Windows, a native round trip, a test that a failed flush is reported, and the open-time flush. On macOS, the boot id, and an add-then-update round trip that checks the protection class, ignored without a signed build that has a keychain access group.

  • Proto. The remembered-message table fits every credential at full rate, with every answer budget honoured, and a table filled past the cap refuses without evicting.

  • Bench. cargo run --release -p eternal-credstore --features test-hooks --example credstore_bench -- <dir> times persisting a committed timestamp with 64 records.

Adds the credstore crate (eternal-credstore), the host's v3 credential
store, not yet used by the host:

- Pending and active records with the per-slot and host-wide caps,
  permanent tombstones, and Finalize, Remove and Expire.
- Keys wrapped under a per-generation wrapping key held in OS-protected
  storage (macOS data-protection Keychain, Windows DPAPI), with one
  writer, the invalidation procedure and startup recovery.
- An in-memory snapshot implementing HostCredentials.
- Store, restore and crash tests that stop every operation at every
  write, two fuzz targets, and a persistence bench.

The remembered message 1 table in proto is now sized from the credential
cap and per-device budgets for answered and unanswered message 1s.
@whoisaldo whoisaldo added the 0.4 Part of the 0.4 release (transport v3) label Nov 8, 2026
… fixes

- Each generation has its own database file, kept until an open sees the
  keyring committed past it, so a restart lands exactly before or after an
  operation whichever keyring blob survives. Step 2 starts only after the
  keyring store succeeded; a failed store is read back, and an unknown
  outcome fails closed until reopen.
- A keyring blob that can't be read is never replaced; the store reports
  it instead.
- Windows opens the keyring value for set and query so the flush runs, and
  reports a failed flush. macOS adds the Keychain item, or updates only its
  data.
- Keys are boxed in the store and snapshot, and the keyring blob and key
  wraps are encoded into buffers of their final size.
- One generation bound for both formats; rotations past it are refused
  before any change, and a fresh keyring is seeded with room to rotate.
- The database parser enforces every store invariant, including the
  per-slot pending cap and the replace rules.
- Tests: a keyring model that separates visible from durable, in the crash
  matrix (flushing and not) and the ops fuzzer; fail-closed read-back,
  damage during a staged rotation, unreadable keyrings, generation bounds,
  and parser invariants.
- The keyring carries a base generation next to the committed one: the
  newest generation known to be durable. The database is always written
  under the base key, which every keyring blob that may survive a crash
  holds, so every write that returned Ok survives a restart.
- A rotation commits a new key, and the database moves to it once it is
  durable: after the flushed commit on Windows, and at the first open in a
  later boot on macOS, from a boot-id marker each open records first. A
  reopen in the same boot changes nothing.
- The database header records the newest generation its contents need;
  an open whose surviving keyring is older rotates again, and a staged
  intent the database already reflects isn't replayed.
- An open syncs the directory before reading. At most two database files
  exist, and an open reads at most two.
- The Keychain update keeps the protection class and borrows the blob
  without copying it; new records hand over a boxed key; an unreadable
  read-back after a failed store is unknown.
- Tests: unsettled sequences, same-boot and power-loss restarts with every
  keyring outcome in the crash matrix and the ops fuzzer, the boot marker,
  promotion, and the file bound.
…anup

- A database file whose head is older than the committed generation is a
  copy from before a rotation: it is never used, and with no current file
  the open discards and lists it, in the same boot or after a reboot.
- Each keyring blob records the id of the boot that stored it, atomically
  with the commit; an open confirms durability only in a later boot than
  that (or after a flush on Windows). The separate boot marker is gone. An
  unreadable boot id confirms nothing, and the store still opens.
- A promotion removes every file but the base's before writing the next,
  and a startup discard removes every other file, so at most two files
  exist however often cleanup is interrupted.
- Tests: restores before promotion, older keyring blobs restored alone,
  unreadable boot ids, repeated interrupted promotions, and two restore
  modes in the ops fuzzer; test opens are seeded from the state they open.
- The database header records a fingerprint of its head generation's key
  next to the head. An open never uses a file that names the committed or
  staged generation with another key, so equal generation numbers from
  different keyring histories are told apart.
- A rotation goes past any staged generation the keyring holds, as well as
  the committed one and every generation the database names, so no
  generation number names two keys.
- Promotion happens only when the keyring reports a durable flush; any
  other result, including an unreadable boot id, waits instead of failing
  the operation or the open.
- Tests: a database from another keyring history, a discard while a
  rotation is staged, and every procedure with an unreadable boot id. The
  ops fuzzer forgives a retired record only for a landed re-add, and
  forgets retirements only at steps that roll the keyring back.
- A keyring blob stored while the boot id couldn't be read is stored again
  tagged with the boot at the next open that can read it, so the boot after
  that confirms and promotes it as usual.
- The fingerprint is described as what it checks: a file from another
  history whose head is the keyring's committed or staged generation is
  never used; a rotation goes past every generation this keyring or its
  database names.
- The ops fuzzer keeps an independent model of the tombstones the
  operations leave and checks the store against it after every operation;
  at every open it allows a retired record back only from a file the
  keyring's history vouches for, after a keyring rollback below its
  retirement. Two checked-in regression inputs run first in CI.
…ks Remove and expire

The fuzz oracle works out from the keyring and files alone whether an
open loads, is empty or discards, using the store's candidate rule
exactly. A discard's tombstones and retirements are modelled from the
files it reads. Retirements are dated by step rather than generation,
and an Ok Remove or expire is checked against what it promises.

Adds regressions for a discard's tombstones and a two-id Remove, unit
tests for a reused generation that is only staged and for a two-id
Remove, and words the staged-floor comment precisely.
@whoisaldo
whoisaldo marked this pull request as ready for review November 8, 2026 10:06
@whoisaldo
whoisaldo merged commit 4984316 into feat/transport-v3 Nov 8, 2026
11 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.4 Part of the 0.4 release (transport v3)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants