Skip to content

feat(phylake): add root key file and at-rest sealing primitives - #81

Merged
forkwright merged 22 commits into
mainfrom
feat/phylake-crypto
Sep 25, 2026
Merged

forkwright merged 22 commits into
mainfrom
feat/phylake-crypto

Conversation

@forkwright

@forkwright forkwright commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What changed

The encryption-at-rest layer for D5 custody (crates/phylake: keyfile, crypto::{keys, seal, wrap}), as specified in docs/design/custody-store.md and 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:

    • it must be a regular file;
    • it must be owned by the effective uid;
    • it must have no group or other permission bits.

    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_NOFOLLOW at mode 0600, fsyncs the file and its directory, and never overwrites an existing file. Keys live in secrecy/zeroize types, and Debug prints them redacted.

  • Key hierarchy. HKDF-SHA256 derives one store subkey per purpose (blob, meta, audit, index, kek, check), each under a fixed, versioned dioptron/v1/... label and a per-store salt.

    • Each tenant has its own random data key. It is wrapped under the kek subkey with XChaCha20-Poly1305, and the wrap binds the tenant id and both key ids.
    • Five tenant subkeys derive from the data key.
    • unlock checks the store's key-check value in constant time. A mismatch fails closed with a distinct StoreLocked error.
  • 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. open selects the key by the id in the header, which lets reads span a rekey.

  • Blob address. HMAC-SHA256 under the tenant's blob_addr key. 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)

Crate Why
chacha20poly1305 0.11 (alloc, zeroize) XChaCha20-Poly1305 AEAD; a 192-bit random nonce is safe per value
hkdf 0.13, hmac 0.13, sha2 0.11 Subkey derivation, keyed addressing, key check, provenance digest
getrandom 0.4 The single entropy source. Called directly because it is fallible; the AEAD's own nonce helpers panic instead
secrecy 0.10, zeroize 1 Key custody and wiping
rustix 1.1 Safe open with NOFOLLOW/NONBLOCK and geteuid; std has neither. It was already in the lock file through tempfile, so this adds no new crate version
snafu 0.9 Fleet error convention
tempfile 3 (dev) Tempdir-only tests

deny.toml gains 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, and chacha20poly1305/zeroize does 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)

  • 69 tests pass. They include:
    • Known-answer vectors: RFC 5869 (HKDF), RFC 4231 (HMAC), FIPS 180-2 (SHA-256) and draft-irtf-cfrg-xchacha-03 A.3.1 (XChaCha20-Poly1305).
    • Byte-exact known answers for the sealed and wrapped formats, computed with an independent Python implementation.
    • Tampering with any single header, nonce, ciphertext or tag byte fails; so does an AAD mismatch on each field, swapping values between records, or using the wrong key.
    • The key-file cases: permissions, symlink, FIFO, socket, foreign owner, wrong length, missing file, and a dangling symlink during generation.
    • A raw on-disk scan finds no plaintext, URL or tenant markers, no plaintext SHA-256 and no data-key bytes.
    • Every error variant is triggered.
  • cargo fmt --check, clippy -D warnings, cargo deny check and cargo tree -d are clean, and the doc checks pass.
  • An independent adversarial review hardened the key-file loader and added the format known answers.

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
Comment thread crates/phylake/src/crypto/keys.rs Fixed
Comment thread crates/phylake/src/crypto/keys.rs Fixed
Comment thread crates/phylake/src/crypto/keys.rs Fixed
Comment thread crates/phylake/src/crypto/mod.rs Fixed
Comment thread crates/phylake/src/crypto/mod.rs Fixed
Comment thread crates/phylake/src/crypto/mod.rs Fixed
Comment thread crates/phylake/src/crypto/seal.rs Fixed
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.lock
#	Cargo.toml
@forkwright
forkwright merged commit a49f36f into main Sep 25, 2026
13 checks passed
@forkwright
forkwright deleted the feat/phylake-crypto branch September 25, 2026 20:53
@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

2 participants