feat(phylake): add root key file and at-rest sealing primitives - #81
Merged
Merged
Conversation
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.
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
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 .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.
# 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.
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
# Conflicts: # Cargo.toml
# Conflicts: # Cargo.lock # Cargo.toml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The encryption-at-rest layer for D5 custody (
crates/phylake:keyfile,crypto::{keys, seal, wrap}), as specified indocs/design/custody-store.mdand D17.15. This PR adds no store; the fjall custody store is the next slice.Root key file. The file must hold exactly 32 bytes. It is opened with
O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC | O_NOCTTY, and every check runs against the open descriptor:The loader refuses symlinks, FIFOs, sockets, devices, files owned by another user, and wrong lengths, each with its own error. Generation creates the file with
O_CREAT | O_EXCL | O_NOFOLLOWat mode 0600, fsyncs the file and its directory, and never overwrites an existing file. Keys live insecrecy/zeroizetypes, andDebugprints them redacted.Key hierarchy. HKDF-SHA256 derives one store subkey per purpose (
blob,meta,audit,index,kek,check), each under a fixed, versioneddioptron/v1/...label and a per-store salt.keksubkey with XChaCha20-Poly1305, and the wrap binds the tenant id and both key ids.unlockchecks the store's key-check value in constant time. A mismatch fails closed with a distinctStoreLockederror.Sealed values. A sealed value is
rec_ver ‖ key_id ‖ 24-byte nonce ‖ ct‖tag, sealed with a random nonce per value. The AAD binds the label, schema version, record kind, key id, keyspace and record key. The two variable-length fields carry length prefixes, so different keyspace/record-key splits can never produce the same AAD.openselects the key by the id in the header, which lets reads span a rekey.Blob address. HMAC-SHA256 under the tenant's
blob_addrkey. It is scoped per tenant, so identical content in two tenants gets two addresses and a lookup cannot confirm what another tenant stored. The plain SHA-256 provenance digest is only ever stored inside sealed metadata.Why
Phase 01 S2 has to store capture bodies and tenant metadata durably. #35 and D17.15 require encryption at rest, with a fail-closed locked start, from the first durable byte. This slice builds the keys and sealing that the custody store will use.
Dependencies (one RustCrypto generation, no duplicate versions)
chacha20poly13050.11 (alloc,zeroize)hkdf0.13,hmac0.13,sha20.11getrandom0.4secrecy0.10,zeroize1rustix1.1openwithNOFOLLOW/NONBLOCKandgeteuid; std has neither. It was already in the lock file throughtempfile, so this adds no new crate versionsnafu0.9tempfile3 (dev)deny.tomlgains MIT, Apache-2.0 and Unicode-3.0 together with the first dependencies that need them.Accepted residuals (recorded in a WARNING comment in
crypto/mod.rs): hmac 0.13 and hkdf 0.13 leave derived key material on the stack, andchacha20poly1305/zeroizedoes not wipe the per-nonce Poly1305 key. Reading any of that requires access to the daemon's memory, and that memory already holds the live root key. The HKDF PRK is now zeroed explicitly.Stage and acceptance
Phase 01 S2, "Implement authorization and durable capture custody", covering the encryption part of custody. It implements D17.15 (closed #35): encryption and key recovery exercised, with tests that inspect the stored bytes to prove bodies and sensitive metadata are not plaintext.
Proof (toolchain 1.97.1)
cargo fmt --check,clippy -D warnings,cargo deny checkandcargo tree -dare clean, and the doc checks pass.