Repository navigation
Slice 025: encryption at rest, and the key-custody seam - #81
Merged
Merged
Conversation
… 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>
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.
Key material now enters this tree through one seam.
Trinity.Keysis 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; andTrinity.Vaultseals 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:
synchronous=FULLsynchronous=NORMALThe cost does not spread across writes; it lands almost entirely on the
fsyncpath — 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_absentand:tooling_absentare different answers.Trinity.Keysdeclarestop_level?: true, deps: [], the lesson slice 062 paid for. It bit immediately: the adapter reached forKeyCustody.keys_dir/0and 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/1passes unsealed blobs through, so the choice can change later without a migration.A defect I introduced, and the 19 tests it broke
KeyCustody.boot!/1records 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 reportingsigner_unavailable.async: falsedoes not help, because the leak outlives the test rather than racing it. What found it: running the suite onmainwith 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 gateexit 0; 610 passed, 18 excluded; coverage 80.31%. Re-run on seeds 0, 1 and 424242 after thepersistent_termfix, 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.