Skip to content

feat(phylake): rotate root and tenant keys, crypto-shred, and verify restores - #89

Merged
forkwright merged 58 commits into
mainfrom
feat/phylake-rekey
Sep 25, 2026
Merged

forkwright merged 58 commits into
mainfrom
feat/phylake-rekey

Conversation

@forkwright

@forkwright forkwright commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What changed

Key lifecycle for the D5 custody store (crates/phylake/src/store/{keyring,rekey,root_rotation,shred,compact,backup}.rs), as specified in docs/design/custody-store.md.

Root rotation (rotate_root)

  • One transaction on a stopped daemon (&mut self). A current key that fails the key check gives StoreLocked.
  • It draws a fresh salt, rewraps every tenant data key (retiring ones included) and every set of addressing subkeys, then re-seals and moves every store-sealed record under the new root-derived keys: tenants, tombstones, grants, revocations, sessions, invocations, audit stubs, rekey records, locators and ledgers.
  • It then rewrites meta.
  • An orphaned record stops the rotation with Inconsistent, and nothing changes.

Tenant data-key rotation, resumable

  • begin_rekey makes the new key active and writes a rekey record.
  • rekey_batch re-seals at most N records per transaction, walking idem, artifacts, blobs, session_index and audit in that order. The cursor advances in the same transaction.
  • rekey_status reports progress. The final retire deletes the old wrapped key.
  • Reads accept both key ids during a rotation. Every writer loads its keyring inside its own transaction, so nothing can be sealed under the retiring key after begin commits.
  • The index and blob-address subkeys never rotate, so record keys and blob addresses stay stable. They are wrapped at the first rotation under their own AAD label.

Crypto-shred (shred_tenant)

  • One transaction deletes the tenant's data keys, addressing subkeys and rekey record, and replaces the tenant record with a tombstone. It refuses with TenantBusy while any of the tenant's invocations, or any in a session it owns, is still running.
  • Afterwards, reads return nothing, operations return TenantShredded, audit scans skip the tenant, a capture into a session it owned is refused as NotFoundOrDenied, and audit stubs survive.
  • The shred is complete only after compact(). fjall keeps deleted bytes in its journal until the journal passes 64 MB, and major_compact does not purge the journal. So compact() rewrites every live entry into a staging directory and swaps it in with one renameat2(RENAME_EXCHANGE), holding fjall's advisory lock on both directories. It then fsyncs the parent directory and removes the old store.
  • The store path holds a complete store at every step. An unsupported exchange fails closed with ExchangeUnsupported; there is no two-rename fallback.

Tenant key cache

  • A keyring is cached only if its key ids match the latest committed tenant and rekey records, checked under the cache lock. A reader on an older snapshot therefore can't put back a retired or shredded key.

Backup and restore

  • verify_restored checks the key and the schema version of a copied store directory.
  • The design doc states two consequences: a backup taken before a shred still restores the tenant's data, and a backup taken before a root rotation opens only with the old root key.

Failpoints

  • New boundaries: RekeyBegin, RekeyBatch, RekeyRetire and RootRotation.

Why

D17.15 (closes #35) and custody-store.md require rotation with crash recovery and observable progress, backup and restore, and crypto-shredding for the first durable store.

Dependencies

No new crates. The workspace rustix entry now also serves renameat_with(RENAME_EXCHANGE), which rustix exposes as a safe call. Its justification comment says so.

Stage

Phase 01 S2, the custody key lifecycle.

Proof (toolchain 1.97.1)

Test suite: 449 tests pass across the workspace; phylake has 38 new ones. Crash coverage:

  • Tenant rotation: crashes before and after begin, batches 1, 5 and 11, and retire. After each, every record opens and is sealed under exactly one of the two key ids, the rotation resumes, and the old key is deleted at the end.
  • Root rotation: a crash before the commit leaves only the old key working; after it, only the new key works.
  • Shred: after compact(), a raw-disk scan finds zero bytes of the wrapped keys, which were present before. audit_query still works after a shred.

Other checks:

  • Compaction swap: it refuses a directory another handle holds, and it maps unsupported filesystems to ExchangeUnsupported.
  • Cache race: readers on older snapshots cannot re-cache a retired or shredded key.
  • Backup: round-trip, wrong key, empty directory, newer schema.

Static checks: clippy -D warnings, cargo deny check, cargo tree -d (documented skips only), fmt and the doc checks are clean.

Independent adversarial review: it closed a window in which compaction could remove another process's database, gave the unsupported-filesystem case a clear error, and fixed the stale-key re-caching race.

Phase 01 S2 workspace: syntheke, epitrope, phylake, dioptron (bin +
lib), and xenos (publish = false). Shared lints follow kanon RUST.md;
toolchain pinned to 1.97.1 and rust-version 1.97, the toolchain CI
builds. No external dependencies yet. The daemon binary exits with
failure until commands land, and a process test pins that behavior.
deny.toml follows the zetesis posture (multiple-versions and wildcards
denied, unknown registries and git sources denied) and scans the
all-features graph the gate builds. The license allowlist covers the
workspace license and the licenses of the planned Phase 01
dependencies. .cargo/audit.toml and osv-scanner.toml are derived from
the deny.toml advisory ignore list.
The hybrid gate slots now run cargo fmt, check, clippy, nextest, and
doctests with --all-features; the toolchain comes from
rust-toolchain.toml. The corpus checks move to a docs job that runs on
every PR and on main. The docs-only exemption is on, which is sound
only while branch protection requires docs next to gate / gate.
.kanon-ci.toml gains the Rust stages ahead of the doc stages.
security.yml calls the fleet reusable (cargo-deny, cargo-audit,
osv-scanner) on PRs, main, and daily. CodeQL gains a Rust job and now
runs on PRs so it can be a required check. Dependabot also watches
Cargo dependencies.
release-please keeps release-type simple and updates the workspace
version, versioned path dependencies, and local Cargo.lock entries
through extra-files, as zetesis does.
The operator decision of 2026-09-25 starts the implementation phase.
AGENTS.md scopes crates to the Phase 01 plan; CLAUDE.md, README.md,
llms.txt, and _llm describe the workspace and the live build gate.
README.md's license section now matches LICENSE and LICENSE-DOCS.
Allow only the workspace's own license in deny.toml until dependencies
arrive, drop the private-plan S1 reference from the syntheke crate
docs, remove an unrecorded decision name from AGENTS.md, and drop a
copied comment with a nonstandard tag from the CodeQL rust job.
Implement the encryption-at-rest layer of the custody store design:

- keyfile: 32-byte root key load that refuses a missing file, a
  non-regular file, any group/other permission bit, or a wrong length;
  generation via O_CREAT|O_EXCL with mode 0600 from getrandom. Key held
  in secrecy::SecretBox.
- crypto: HKDF-SHA256 store subkeys (dioptron/v1/{blob,meta,audit,
  index,kek,check}) under a per-store salt; key check
  HMAC(k_check, "dioptron-key-check") verified in constant time, with
  StoreLocked on mismatch; random per-tenant data keys wrapped by the
  kek with XChaCha20-Poly1305, AAD binding kek id, data key id, and
  tenant id; tenant subkeys (blob, blob-addr, meta, audit, index).
- Sealed value format rec_ver u16 | key_id u32 | nonce[24] | ct|tag,
  AAD binding label, schema version, record kind, key id, and
  u16-length-prefixed keyspace and record key. Random 24-byte nonces.
- Tenant-keyed blob address (HMAC-SHA256) and plain SHA-256 provenance
  digest for sealed metadata only.

Dependencies stay on one RustCrypto generation (digest 0.11,
crypto-common 0.2): chacha20poly1305 0.11, hkdf 0.13, hmac 0.13,
sha2 0.11, plus getrandom 0.4, secrecy 0.10, zeroize 1, snafu 0.9;
tempfile 3 for tests. deny.toml allows MIT, Apache-2.0, Unicode-3.0.
rkyv 0.8 with bytecheck is the wire format fixed by topology.md; the
format features are named so a conflicting layout fails the build.
snafu is the fleet error library. toml is a dev-dependency for the
contract fixture test. deny.toml allows MIT, Apache-2.0, and
Unicode-3.0 for these crates and skips the syn 2 copy that snafu-derive
and munge_macro still build with.
Identifiers with ULID text form, the capability, mode, scope, and
budget vocabulary, request and reply payloads for every capability,
the outcome taxonomy, the frame header codec, handshake frames, the
authentication transcript, and a decoder that bounds length before
copying and validates the archive before any field is read.
Every message type round-trips through a full frame; corrupted and
truncated bodies are rejected. The fixture test maps each file under
docs/contract/fixtures onto syntheke types and fails when the
directory or a declared fixture is missing.
Open the root key with O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC | O_NOCTTY
through rustix and check the opened descriptor: a symbolic link at the
key path, a FIFO, socket, or device node, or a file whose owner is not
the effective uid is refused before any read. Key generation also
creates with O_NOFOLLOW. rustix is already in the lock file through
tempfile, so no new crate version enters the tree.

External errors (io, getrandom) move to an `error` source field per the
kanon snafu convention, and a shrinking file reports its current length.
Add the draft-irtf-cfrg-xchacha-03 A.3.1 AEAD vector and byte-exact
seal and wrap vectors computed with an independent reference. Wipe the
HKDF PRK returned to the crate, record the stack residue the RustCrypto
primitives leave as an accepted WARNING, and restate the plaintext bound
in terms of what bounds a stored value.
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/phylake/Cargo.toml
#	crates/phylake/src/lib.rs
#	deny.toml
Request gains a grant field naming the one grant a call acts under, in
execute and dry-run mode. GrantIssueRequest drops parent_grant: the
designated grant is the parent, so the two can no longer disagree.
Fault frames now refuse any failure other than ProtocolError and
AuthFailed on encode and decode, and VersionChoice is non-exhaustive.

Fixtures carry grant as a wire field on every capability request; the
cast gives tnt0f grant grn0f, and neg_foreign_grant covers a request
naming another tenant's grant. The fixture conformance test splits into
load, mapping, and test modules.
Every capability request names the grant it acts under; the daemon
authorizes against that grant's chain only. A missing or foreign
designated grant returns the same NotFoundOrDenied, and the idempotency
digest covers the grant. The wire section now lists the eight frame
kinds with their bytes, restricts Fault to connection-level failures,
fixes the auth transcript layout, and states time units. Fixture keys
move grant to the wire fields. The contract stays at version 1.
Names the crates on each side of the syn 2/syn 3 split, the one
alternative (holding rkyv at 0.8.17) and why it is rejected, and the
retirement condition. The license list takes the same entries and order
as the storage branch so the later merge touches only comments.
Inline #[cfg(test)] modules move to sibling tests.rs files, and
test-only helpers (fixed entropy, shared key fixtures, from_bytes
constructors) move to test_support.rs files, so path-scoped security
scanning can exclude test code. Test names and count are unchanged.
Add the pure authorization crate over the syntheke contract types:

- narrowing of a child grant against the designated parent on every
  axis, reporting the first failing NarrowingAxis;
- chain validity at an injected clock against revocation records, so an
  ancestor's revocation invalidates descendants without fan-out;
- designated-grant authorization where a grant the connection's tenant
  does not hold reads exactly as a missing one;
- reservation and settlement arithmetic with checked ledgers and a typed
  overrun;
- the invocation transition table and restart recovery actions;
- a dry-run planner over a read-only snapshot trait;
- D17.7 audit scope defaults and a rule view with no audit accessor;
- a minimal origin parser for target scopes that refuses every
  construct where parsers could disagree about the host.

proptest (dev only, no default features) checks that a child passing
the narrowing check is never broader than its parent on any axis.
Add .github/codeql/codeql-config.yml with paths-ignore for tests.rs,
tests/ and test_support.rs, reference it from the Rust init step, and
record the test layout convention in CLAUDE.md.
…hylake-custody

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
Add DenyCode::BudgetUnavailable for exhaustion on a ledger the caller
does not own; it names no dimension. Add NarrowingAxis::AuditScope.
Failure::Denied carries axis: Option<NarrowingAxis>, set exactly for
NarrowingViolation; Response::check refuses any other combination in a
reply or a plan's refusal (DeniedAxisMismatch). Move the outcome unit
tests to outcome/tests.rs.
Upstream budget exhaustion is Denied{BudgetUnavailable}. A narrowing
refusal carries its axis on the wire, and the audit scope is its own
axis. The chain walk and designated-grant lookups refuse a view answer
filed under another id, and the walk re-checks each link's depth
against its parent's maximum. The narrowing property now checks both
directions.
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
The Entropy trait now fills &mut [MaybeUninit<u8>] and returns the
initialized slice; OsEntropy calls getrandom::fill_uninit. A new
random_array helper draws nonces, salts, tenant data keys and root keys
from an uninitialized buffer with no initializing literal and no unsafe,
then scrubs the scratch copy. Key material is wrapped in SecretBox at
once. Known-answer tests are unchanged.
…hylake-custody

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
Key draws now use random_secret_array, which returns Zeroizing<[u8; N]>.
TenantDataKey and RootKey copy it into the heap SecretBox by reference
via init_with_mut while the source is alive, so no unwiped by-value copy
of the key remains. random_array delegates to it for public values. The
WARNING residual records the possible try_from stack temporary.
# Conflicts:
#	Cargo.lock
#	deny.toml
…hylake-custody

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
- Store each terminal as a typed record: Settled{failure},
  Released{reason}, or UnknownEffect. Released(Abandoned) is no longer
  recorded as a Cancelled failure; the invocation and its audit entry
  carry the reason as given, and the Cancelled reply for an abandoned
  call is derived, not stored.
- Assign the artifact id from the invocation id, so two invocations
  cannot claim one artifact and a roll-forward publish cannot wedge
  recovery on a conflict.
- Bind the idempotency entry to the designated grant, capability,
  session, target, and declared cost in the store, and drop the request
  digest from the store-sealed invocation record, where it would have
  survived a crypto-shred.
- Match a repeated grant issue against the stored child before any
  decision, so a parent that has spent budget since does not turn a
  repeat into a refusal.
- Refuse a B2 settlement whose failure no started producer reports.
- Add a scoped audit read (All, OwnAndOwnedSessions, session narrowing,
  paging across partitions) for the lifecycle slice.
- Race eight reservations on a ceiling of three behind a barrier.
- Document the sealing-scope table, the shred residue, the blob key,
  the artifacts keyspace record kinds, directory idempotency, the key
  cache, and the deferred migrate command; state the UnknownEffect
  charge as the whole reservation; correct the hashbrown skip
  retirement conditions.
…phylake-rekey

# Conflicts:
#	crates/phylake/src/store/audit.rs
#	crates/phylake/src/store/mod.rs
#	crates/phylake/src/store/tests.rs
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
# Conflicts:
#	crates/phylake/src/crypto/mod.rs
#	crates/phylake/src/error.rs
#	crates/phylake/src/store/audit.rs
#	crates/phylake/src/store/codec.rs
#	crates/phylake/src/store/directory.rs
#	crates/phylake/src/store/failpoint.rs
#	crates/phylake/src/store/invocation.rs
#	crates/phylake/src/store/mod.rs
#	crates/phylake/src/store/read.rs
#	crates/phylake/src/store/record_key.rs
#	crates/phylake/src/store/records.rs
#	crates/phylake/src/store/test_support.rs
#	crates/phylake/src/store/tests.rs
#	crates/phylake/src/store/transition.rs
#	docs/design/custody-store.md
Compaction now takes the fjall lock file of both directories after
closing the databases and holds them through the exchange and the
removal of the replaced store, so a process that opened either one in
that window fails the compaction with StoreInUse instead of having its
database renamed away and deleted. A filesystem without
RENAME_EXCHANGE fails with ExchangeUnsupported; there is still no
two-rename fallback.

The tenant key cache now inserts a keyring only when its key ids match
the latest committed tenant and rekey records, checked under the cache
lock. A reader on an older snapshot could otherwise re-cache a retired
or shredded tenant's keys after the rotation or shred evicted them.

The design doc records both, and that backups taken before a shred or
a root rotation keep the old keys until destroyed.
@forkwright
forkwright merged commit 6f24dd6 into main Sep 25, 2026
13 checks passed
@forkwright
forkwright deleted the feat/phylake-rekey branch September 25, 2026 21:20
@forkwright forkwright mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make encryption at rest part of the first durable-store acceptance gate

1 participant