Skip to content

Slice 025: encryption at rest, and the key-custody seam - #81

Merged
HackTuah merged 6 commits into
mainfrom
slice/025-encryption-at-rest
Sep 23, 2026
Merged

HackTuah merged 6 commits into
mainfrom
slice/025-encryption-at-rest

Conversation

@HackTuah

Copy link
Copy Markdown
Member

Key material now enters this tree through one seam. Trinity.Keys is a behaviour with four operations and a local adapter offering three sources; the receipt signer's private key is sealed by it before it reaches disk; and Trinity.Vault seals the blob classes a deployment names, each under a fresh data key wrapped by the same seam.

What the measurement changed (AC5)

Three runs on each volume, same machine, same NVMe device, minutes apart:

encrypted (LUKS2) plain
single row, synchronous=FULL 647.7 µs/row 327.5 µs/row
batched, synchronous=NORMAL 1.8 µs/row 1.6 µs/row

The cost does not spread across writes; it lands almost entirely on the fsync path — which is exactly where the receipt chain lives, by design. So the honest claim is narrower and more useful than a percentage: encryption at rest doubles the cost of the one write this project most cares about and costs nothing anywhere else. At 648 µs that is ~1,500 receipts a second. Measured and acceptable, not measured and free.

Stated as an upper bound: the volume was a LUKS2 container in a loopback file, so every write crossed one more filesystem than a dedicated partition would. The ratio transfers; the microseconds do not. Re-runnable: elixir scripts/insert_bench.exs <dir>.

Why the split is where it is

Slices 031 and 032 search by indexing plaintext tokens and raw vectors. Field-level ciphertext there would not degrade search, it would remove it. So Trinity encrypts the blobs no index touches and leaves everything under the database file to the volume — and measures what that costs rather than assuming.

The seam

Three sources tried in a fixed order, availability reported separately from success, so an absent one refuses by name rather than falling through. This machine has a TPM device and no tpm2-tools, which is a real half-present case a mock would not produce — and why :device_absent and :tooling_absent are different answers.

Trinity.Keys declares top_level?: true, deps: [], the lesson slice 062 paid for. It bit immediately: the adapter reached for KeyCustody.keys_dir/0 and the compiler refused. The directory is configuration now, which is the better design.

Sealing is off by default, per class

A decision, not an omission. A sealed export restores only where the key is — what a regulated deployment wants and what someone moving to a new laptop does not. open/1 passes unsealed blobs through, so the choice can change later without a migration.

A defect I introduced, and the 19 tests it broke

KeyCustody.boot!/1 records its selection in :persistent_term — global state with no owner, which outlives the test process and the sandbox. A test booted custody against a temp directory, restored config on exit, deleted the directory, and left the selection pointing at it. Nineteen tests then failed in the membrane, MCP bridge, provenance and memory — none of which touch keys — all reporting signer_unavailable.

async: false does not help, because the leak outlives the test rather than racing it. What found it: running the suite on main with a clean database, which turned "something is broken" into "something I added is broken" in two minutes. Two false trails are recorded in NOTES rather than dropped.

Evidence

mix gate exit 0; 610 passed, 18 excluded; coverage 80.31%. Re-run on seeds 0, 1 and 424242 after the persistent_term fix, because one green run over a global-state bug proves only that one ordering was lucky.

Full record in the slice's PROOF; every criterion is [auto] and the manual queue is empty.

… split

AC5 of slice 025, and the first thing the slice needed before any of its code,
because the number decides whether the design is acceptable rather than the
other way round.

scripts/insert_bench.exs had to be written: slice 010 left no bench script and
its figure lives in slice 024's NOTES as prose, 274 us/row for the receipts
pattern. The script reproduces that pattern rather than inventing one, and reads
327 us/row on a plain volume today, which is the same measurement on a busier
machine rather than a disagreement.

Three runs on each volume, same NVMe device, minutes apart: encrypted 647.7
us/row against plain 327.5 us/row for a single row committed with
synchronous=FULL, and 1.8 against 1.6 us/row for batched writes.

The finding was not the expected one. Encryption does not spread its cost across
writes; it lands almost entirely on the fsync path. Batched writes are
unaffected. A single row committed for durability costs twice as much, which is
exactly the pattern the receipt chain uses and uses deliberately. So the honest
statement is that encryption at rest doubles the cost of the one write this
project most cares about and costs nothing anywhere else, and at 648 us that is
still about 1,500 receipts a second.

docs/encryption-at-rest.md carries the numbers, the setup a deployment performs,
and what the measurement is not: an upper bound, because the volume is a LUKS2
container in a loopback file rather than a dedicated partition, on one machine
with AES-NI, so the ratio transfers and the microseconds do not.

It also states why the split is where it is, which is the part that is easy to
mistake for laziness: slices 031 and 032 index plaintext tokens and raw vectors,
so field-level ciphertext in those columns would not degrade search, it would
remove it. The line is drawn where the data structure changes.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
Trinity.Keys is a behaviour with four operations: fetch a named key, wrap data
key material so it can be written beside the blob it opens, unwrap it again, and
rotate the root. describe/0 names the adapter and source for the boot receipt and
returns no key material, because a receipt carrying a key would defeat the chain
it is part of.

A behaviour before there is a second adapter, deliberately. A deployment that
keeps keys in a KMS should say so in configuration rather than in a patch, and
shaping that seam after two consumers have grown their own key handling is a
rewrite rather than four callbacks. It is shaped around a local round trip on
purpose, so a network adapter later fits callbacks proven without one.

The package declares top_level?: true with deps: [], which is the lesson slice
062 paid for: a nested sub-boundary inherits its ancestors' dependencies, so a
nested deps: [] is a claim the compiler never checks. This one is checked, and it
bit immediately: the adapter wanted Trinity.Receipts.KeyCustody.keys_dir/0 and
the compiler refused it. The directory is configuration now, which is the correct
answer rather than a workaround.

Trinity.Keys.Local tries three sources in a fixed order, availability ascending:
a systemd credential, the TPM, then a passphrase through PBKDF2-HMAC-SHA512 at
600,000 iterations. Named keys are derived from one root rather than stored
separately, so there is one secret whose loss matters. Wrapping is AES-256-GCM
with the root's key id as additional authenticated data, so a wrapper names the
key that sealed it and rotation can find a retired root to open it.

Availability is reported separately from success, and a refusal names which half
is missing. That is the whole of AC1 and it is not decoration: this machine has a
TPM device and no tpm2-tools, so tooling_absent and device_absent are different
answers sending an operator to different places. The test asserts against whatever
the machine actually has rather than a mock.

The module documents what the seam is not worth as carefully as what it is: none
of the three sources protects against an attacker already running as this user,
and a passphrase in a unit file's environment is worth very little.

11 tests. mix gate exit 0; 591 passed; coverage 80.31%.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…e custody census

Trinity.Vault seals a blob under a fresh data key, AES-256-GCM, with that key
wrapped by the custody seam and carried in the blob. A fresh key per blob because
sharing one means a single compromise opens every blob, and 32 bytes of
randomness is a cheap way not to have that conversation. The wrapped key is bound
into the additional authenticated data, so a blob cannot be opened with a data
key lifted from another one.

An unsealed blob passes through open/1 unchanged. That is deliberate rather than
lax: it makes the encrypted and unencrypted paths the same code, so a tree
written before this slice keeps working and a deployment turning encryption on
does not have to migrate everything in one step. sealed?/1 is there for a caller
that needs to know which it got.

The vault does no file input or output. A read(path) and write(path, data) pair
lived here briefly, and sobelow was right to flag them: they accept any path, and
the honest justification would have been "the caller validated it", which is a
claim nothing checks. Path validation already exists where paths are built, so
the bytes come to this module and the paths stay there. That removed the finding
rather than suppressing it.

The census for AC3 takes its population from git ls-files and plants a violation
in a temporary file to prove it can fail, because a census that only ever passes
proves nothing about whether it works.

It found one offender on its first run and the offender was my pattern.
Trinity.Receipts.KeyRegistry reads registry.json, which holds public keys,
fingerprints and JWKs on purpose: it is what lets a stranger verify the chain
offline. A census that called that a leak would push this project toward hiding
the one file that has to be readable, so the pattern now matches private material
and says why in a comment.

Two more of the project's own rules caught me on the way: five @sobelow_skip
attributes without the reason line above them, which sobelow_skips_test refuses
because a named exception with a reason is an enforcer and a bare skip is a
weakening; and the formatter, twice.

7 vault tests, 4 census tests. mix gate exit 0; 602 passed; coverage 80.31%.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…eceipt names the custody

The receipt signing key is sealed by Trinity.Keys before it is written, so the
key file is ciphertext and what opens it is whatever the deployment's adapter
holds. The registry row records which custody actually held the key rather than
the constant "file" it carried before.

Two compatibility obligations, both tested. A key written before this slice has
no custody field and holds its private bytes directly, and still opens: a slice
that made every existing installation unbootable to gain a property would not be
an improvement. And when no key source is available the key is written unsealed
rather than not at all, because refusing to boot over unconfigured encryption at
rest trades a working signer for a stronger one nobody asked for; the registry
row says which of the two happened.

The boot receipt now names the custody adapter, its source and the key id, so a
reader of the chain can tell whether the signing key was held by a passphrase, a
systemd credential or a TPM without asking anyone. It carries no key material and
a test asserts that by searching the encoded subject for the key's bytes.

One defect found and fixed on the way, mine, and the interesting part of this
commit. KeyCustody.boot!/1 records its selection in :persistent_term, which
outlives the test process and the sandbox. The new test booted custody against a
temporary directory, restored the config on exit, deleted the directory, and left
the selection pointing at it. Nineteen tests then failed in the membrane, the MCP
bridge, provenance and memory, none of which touch keys, all with
signer_unavailable. Restoring config was not enough; the selection had to be
re-made from it. Confirmed across three seeds.

Two false trails are recorded in NOTES rather than dropped: a stale
trinity_test.db carrying another test's rows, which was a real and separate
problem, and the temp directory named trinity-* that slice 034 warns about, which
is a real warning and was not this. What found the actual cause was running the
suite on main with a clean database: 580 there against 588 of 607 on the branch
turned "something is broken" into "something I added is broken".

mix gate exit 0; 607 passed; coverage 80.31%.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…lice

Trinity.Vault gains per-class sealing, off by default. The skills staging seals
staged changes on write and opens either kind on read, so the only place that has
to know is the one that writes; the archive export seals its manifest the same
way.

Off by default is a decision rather than an omission, and the module says why. A
sealed export restores only where the key is, which is exactly what a regulated
deployment wants and exactly what someone moving their data to a new laptop does
not, and a sealed skill file cannot be edited in a text editor. The mechanism is
built and proven; which blobs it covers is the deployment's call, and open/1
passes unsealed blobs through so the choice can change later without a migration.

maybe_seal!/2 keeps the call site reading the same whether sealing is on or off,
because a path that branches on whether encryption is enabled is a path where one
branch is less tested than the other.

The boundary refused the first version of this, correctly: Trinity.Vault had to
be exported before the skills and archive sub-boundaries could call it.

mix gate exit 0; 610 passed; coverage 80.31%.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
The row went in with PENDING in the sha column because the sha it names is the
commit that carries it. Set to the measurement's actual commit; coverage.tsv is
the file the gate reads for its floor, and a row naming no commit is a number
nobody can go back to.

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit ecdb546 into main Sep 23, 2026
8 checks passed
@HackTuah
HackTuah deleted the slice/025-encryption-at-rest branch September 23, 2026 23:11
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.

1 participant