ADR 0010: Authenticated key wraps, schema 5, and private key files
Every key_wraps row is appended to the authenticated settings record (integrity::SettingsFields), in id order, under the label key_wraps with their count: the…
Status: accepted · Date: 2026-10-25 · Changes: ADR 0009 (what
replacing a recovery key guarantees; the schema it settled), and, for one
crate, the workspace rule that only sourceorrery-ffi uses unsafe
Context
Section titled “Context”The security review of 2026-10-25 (SECURITY-REVIEW-2026-10-25.md) found
two problems this record settles before any code changes.
SR01. A vault’s key is wrapped once per key that opens it: this
machine’s key and one recovery key (ADR 0009). Replacing the recovery key
deletes the old recovery wrap and wraps the same data-encryption key
(DEK) under the new key. The key_wraps table is not part of the
authenticated settings record, and the event that replaces the key does
not bind the new set of wraps. Putting the old wrap row back into the
current file, with the old recovery key, opens the vault and returns
values written after the replacement, and the audit chain still verifies.
docs/cli.md and the threat model promise that the old key “stops
working”.
SR04. vault create --recovery-key-out FILE asks for mode 0600 on
Unix but creates the file with inherited permissions on Windows, while
saying it is “readable by you only”. Under a shared parent directory the
plaintext recovery key, which opens the whole vault and passes its view
check, is readable by other accounts.
Decision
Section titled “Decision”1. The key wraps are part of the settings record
Section titled “1. The key wraps are part of the settings record”Every key_wraps row is appended to the authenticated settings record
(integrity::SettingsFields), in id order, under the label key_wraps
with their count: the wrap’s id, its kind_json, the wrapped key’s nonce
and bytes, and its created_on, each length-prefixed by the same
canonical encoder as the rest of the record. Unlike the connections and
projects, the section is always present: every vault has exactly one
recovery wrap.
Because the settings record is checkpointed on the audit chain (its tag
is carried by the event that wrote it, and a record whose tag is not the
newest checkpoint is refused), the set of wraps is too. Every change to
key_wraps rewrites the record in the same transaction, and the change’s
audit event carries the new tag:
| Change | Event that checkpoints the new set |
|---|---|
| a vault is created (its recovery wrap, and this machine’s when the store is available) | vault_create |
the recovery key is replaced (setup screens through change_settings, or replace_recovery_key) |
protector_add (“recovery key replaced”) |
| this machine’s key is added | protector_add (“this machine’s key added”) |
| a copy is exported (the copy loses its this-machine wraps) | the copy’s own vault_export |
| a copy is imported (another machine’s wraps removed, this machine’s added) | vault_import |
Opening a vault already verifies the settings record against the newest
checkpoint after a candidate DEK unwraps and before anything is read. A
substituted, added or removed wrap row now changes the record and is
refused with crypto, and putting back the old record as well is refused
because the chain’s newest checkpoint is the new one. keyring_accounts
still reads the wraps of an unopened file (to find credential-store
entries for vault destroy); it is documented as unauthenticated and its
callers keep their existing checks.
2. What replacing a recovery key guarantees
Section titled “2. What replacing a recovery key guarantees”Replacing the recovery key makes the current file refuse the old key, including when an old wrap row (or the old settings record with it) is put back. It does not change the DEK. Whoever held the old recovery key and a copy of the file, or of its old wrap row, from before the replacement can derive the DEK and decrypt any copy of the vault, the current file included, with their own tools; no change to the file can revoke a key someone already knows. The documentation states exactly this and no more.
In-place DEK rotation (re-encrypting every value and re-authenticating every row under a new DEK) is a separate feature, not part of 0.3.0.
3. Compromise response: a new vault and new values
Section titled “3. Compromise response: a new vault and new values”When a recovery key, an exported copy or a machine may be compromised,
the secrets themselves must be assumed known, so re-storing them anywhere
does not help. The procedure (docs/threat-model.md, docs/cli.md):
- Rotate every affected credential at its issuer (the database, the API provider, the cloud account), so the exposed values stop working.
- Create a new vault (
sourceorrery setup): a fresh DEK and recovery key. - Store the new values in it (
secret set, orimportfrom a new.env), and point each project at it (init). - Destroy the old vault (
vault destroy) and the exported copies you control. Copies and backups elsewhere stay readable to whoever holds the old key; they can no longer be made unreadable.
vault export and vault import preserve the DEK and the recovery key by
design (a copy for another machine); they are never compromise recovery.
4. Schema 5
Section titled “4. Schema 5”Authenticating the wraps changes what a valid file is, and files with the
schema-4 settings record exist (development builds of main). Rather than
redefine an existing format, the format becomes schema 5. Schemas 1 to
4 are refused with migration_required, without opening or changing the
file. The record’s encoding domains do not change: the schema number is
itself in the record. ADR 0007 is unaffected beyond this: schema 4 was
never released, the C ABI and the contract do not change, and no error
code is added.
5. Private files: sourceorrery-os, the second crate with unsafe
Section titled “5. Private files: sourceorrery-os, the second crate with unsafe”Setting a file’s Windows permissions at creation needs Win32 calls
(CreateFileW with a security descriptor), which need unsafe. Setting
them after the file exists leaves a window in which another account can
open a handle and keep it; checking a dependency’s unsafe is no better
than reviewing our own. So a new crate, crates/sourceorrery-os, is the
second crate allowed unsafe (after sourceorrery-ffi), over Microsoft’s
maintained windows-sys. Its whole public surface is one safe function:
pub fn create_private_new(path: &Path) -> std::io::Result<std::fs::File>;It takes no key material and returns an empty file only once these hold:
- At creation.
CreateFileWwithCREATE_NEWand an explicit security descriptor: owner the current user (the process token’s user SID), a protected DACL granting full access to that user and toSYSTEMonly, so nothing is inherited from the parent; the handle is not inheritable. An existing file is never opened or changed. - Verified before returning. The owner and the DACL are read back
through the created handle and must be exactly that. A volume that does
not keep ACLs (
FILE_PERSISTENT_ACLSunset, as on FAT) is refused. On any failure the empty file is removed and an error returned; there is no fallback to ordinary creation. - Unix.
O_CREAT | O_EXCLwith mode0600through the standard library, then the mode is checked; nounsafethere. - Small and checked. Ordinary files only. Native handles, the token
and buffers the system allocates are owned by RAII types that close or
free them. Every
unsafeblock states the pointer, lifetime and ownership facts it relies on; the crate deniesunsafe_op_in_unsafe_fnand clippy’sundocumented_unsafe_blocks, assourceorrery-ffidoes. The workspace-wide prohibition stays for every other crate, and CLAUDE.md names both exceptions.
The CLI says what is enforced: on Windows the key file is “restricted to your account and Windows SYSTEM”, on Unix it is “readable by you only”. What happens around the file (writing, flushing, the order against creating the vault) is the CLI’s concern, not this crate’s.
Consequences
Section titled “Consequences”- A vault made by a development build of
main(schema 4) must be recreated, as earlier vaults must. - Every new operation that changes
key_wrapsmust rewrite the settings record and checkpoint it in its event; the storage tests put old wrap rows back, with and without the old record, to keep that true. - Users get an accurate replacement guarantee and a compromise procedure that does not pretend export, import or replacement can revoke a key.
- One more crate is reviewed as
unsafecode. It does one thing, and its tests check the resulting ACL rather than only that the call succeeded.
Alternatives considered
Section titled “Alternatives considered”- A per-row MAC on
key_wraps. Detects edits to a row but not an old authentic row put back; the settings checkpoint already solves replay for connections and projects. - Rotating the DEK whenever the recovery key is replaced. It would make the old key useless against values written afterwards, but needs a re-encryption of every value and row with crash recovery; deferred as its own feature. It still could not protect values the old key’s holder already read.
- Keeping schema 4. Cheaper, but a schema-4 file from
mainwould then fail with an integrity error instead ofmigration_required. windows-permissionsorwindows-acl. Safe APIs, but unmaintained since 2021 and built onwinapi0.3; they move theunsafeout of sight without reducing it.icaclsafter creation. A window in which another account can open a handle, and a dependency on parsing tool output.