Adds the envelope and its labelled subkeys - #28
Merged
Conversation
Encryptor.Envelope is the level 1 to level 2 relationship: a tenant master key is 32 bytes from the CSPRNG, generated once and never derived, wrapped by a root vault into an ordinary Encryptor message. Storing a random key rather than deriving one is what makes a delete an honest crypto-shred. provision/3 mints and wraps, and the plaintext exists inside one function body: it is never returned, logged, or put in a struct. unwrap/2 hands back a descriptor, rewrap/2 is root rotation built on rekey/2, and WrappedKey is the six fields a store has to give back. This package still defines no table, migration, repo, or transaction. The wrapping carries a package-owned context binding it to one purpose, tenant, version and namespace, required on the way back in. The requiring is the envelope's own: the vault-side comparison is present-in-both by design, which is right for a host's advisory context and wrong for a binding whose absence is the failure. The reserved context layer is a positional argument on the internal paths, never an option, so no host route reaches it. root_subkey/2 and subkey/2 are one line each onto Encryptor.Kdf. The root's two purposes have deliberately different lifetimes, and subkey/2 refuses them outright so a tenant-side key can never be derived under a label that already means something else. provision/3 takes a required :reference_subkey option, which the recorded options list did not have: the reference derives from the pinned reference root, which a root vault does not hold once the two roots diverge. Refs: enc-06v
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.
Why
Nothing so far says where a tenant's key material comes from, what protects
it at rest, or how it gets back into memory. ADR-0003 decides that, ADR-0005
amends two pieces of it at acceptance, and this is the implementation.
The load-bearing choice is decision 1: a tenant master key is 32 bytes from
the CSPRNG, generated once and never derived. A derived key cannot be
destroyed - anyone holding the root recomputes it from the tenant id forever -
so deleting its row deletes a memo rather than a secret. Storing an
independently random key wrapped under the root gives the opposite property,
and that property is the whole reason the record exists.
What
Encryptor.EnvelopeandEncryptor.Envelope.WrappedKey, plus a smallinternal seam for the package-reserved context layer.
provision/3mints and wraps. The plaintext exists inside one functionbody: never returned, never logged, never in a struct.
unwrap/2returns an%Encryptor.Key.Aes{}, validated through the samechecks
Encryptor.Vault.Keyringruns, so a row that cannot form adescriptor is refused at resolution rather than as an EDK mismatch later.
rewrap/2is root rotation, built onEncryptor.Vault.rekey/2, withevery identity column and the whole binding carried across unchanged.
tenant_ref/2delegates to the internalEncryptor.Vault.Reference,which is where the derivation already lives - a second spelling of it is a
drifted reference nobody notices until decrypt time.
root_subkey/2andsubkey/2are one line each ontoEncryptor.Kdf. No HKDF is re-implemented here.Encryptor.Vault.{Encrypt,Decrypt}.call/4andEncryptor.Vault.Resolve.context/5grew a positional reserved-contextargument. Positional and not an option on purpose: a reserved layer
reachable through the caller's keyword list would be a route for a host to
write under a prefix
Encryptor.Contextrefuses it, which is the wholecontent of
{:reserved_context_key, key}. A test asserts a host cannotreach it by any route.
The binding is required on the way back in, and that check is the
envelope's own rather than the vault's. The vault-side comparison of ADR-0004
decision 6 compares only keys present in both maps, by design, so a blob
carrying none of the four pairs would pass it.
unwrap/2andrewrap/2readthe header themselves and refuse before any key material is touched.
Constants, for the crypto read
Every literal below is quoted from accepted-ADR text and written out
independently in the test file, so a typo in the module cannot agree with a
typo in the test.
ADR-0003 decision 4's binding, set by
provision/3and required byunwrap/2:ADR-0003 decision 6's two root labels, composed by
Encryptor.Kdf.label/1:"encryptor/v1/root-wrap"Staticprovider material"encryptor/v1/tenant-ref"ADR-0003 decision 7's reservation, quoted: any other use of a tenant master
key derives by
HKDF-Expand(tenant_master_key, info: "encryptor/v1/<purpose>", 32)"with a purpose label that is not"root-wrap"or"tenant-ref"".subkey/2refuses both outright, and refuses a purpose containing/.Decision 5's derivation, and the name grammar of ADR-0002 decision 4:
Other fixed values: material 32 bytes (
bits: 256, decision 1), referencesubkey 32 bytes (matching
Encryptor.Vault.Config), reference width 16 bytesof the tag, default namespace
"encryptor-tenant", default version1.Flagged for the operator's read - none of these is treated as settled
provision/3takes a required:reference_subkeyoption thatADR-0003's
opts()does not list. The record's flow diagram derives thereference inside
provision/3from the root, which stopped being possiblewhen ADR-0005 decision 5 split the roots - the same reason
tenant_ref/2'ssignature was amended at acceptance.
provision/3needs the same value forthe same reason and takes it the same way. This is the largest single
deviation in the PR.
root_subkey/2's second parameter is a purpose, not a full label.ADR-0003's typespec names it
labeland decision 6's table gives labels infull, while the same record's worked example passes
"root-wrap". The onlyreading under which both are true is that the callee composes the prefix,
which is what
Encryptor.Kdf.label/1does and whatenc-j4hlanded. Sameflag that bead raised, restated here because this is the function the
record actually names.
provision/3refuses an empty:encryption_contextmap, whereEncryptor.Vault.Rekeytolerates one. ADR-0003 decision 4 readsliterally as "passing the option is the refusal". The divergence is
deliberate - on this path the option has no correct value at all, where a
rekey's has exactly one - but it is a divergence.
:reference_subkey,:versionor:namespaceargument. No new terms were added to
Encryptor.Error.reason/0, per therecord. A missing or wrong-length reference subkey reuses
{:missing_config, [:reference_subkey]}and{:invalid_config, :reference_subkey, :invalid_length}- the exact termsEncryptor.Vault.Configalready reports for the same value. A bad versionis
{:invalid_config, :version, :not_a_positive_integer}. A namespace theengine reserves is
{:invalid_key_descriptor, detail}. Reusing:invalid_configfor a call-site argument rather than a config key is thepart worth a second look.
{:invalid_key_descriptor, {:invalid_wrapped_key_field, field}}is a newdetail shape for a row whose columns cannot form a descriptor. Same
reason, new detail, and the detail carries the field name and never the
value.
unwrap/2does not compare therow's
namecolumn against the blob. What it does check isbits, whichthe binding does not cover, because that is the one descriptor field a
drifted column could otherwise carry into a keyring.
binding/3andkey_name/2are@doc false. The record's contractsection lists neither, and a store-backed provider gets the binding applied
by calling
unwrap/2. Ifenc-5h9orencryptor_ectowants either as apublic surface, that is a decision, not a refactor.
Notes
mix qualitygreen post-rebase - format, compile(warnings-as-errors), credo
--strict, dialyzer, deps, 361/361 tests, 98.2%coverage against a 90% floor.
lib/encryptor/envelope.exandlib/encryptor/envelope/wrapped_key.exare both at 100%.mix compile --forcebetween them, each confirmed red for the test that cites it and reverted.
The scan reports no missing and no unverifiable notes. The first pass caught
two weak tests - one asserted a collapse no mutation could break, one
compared hex against an
inspect/1output that never contains hex - andboth were rewritten against mutations that do go red.
size, or transaction, and no
shred/2,retire/2orrotate/2- ADR-0005decision 10 lists those as not-shipped-and-deliberately.
configured with a store-backed provider would be a genuine cycle and would
recurse rather than fail cleanly.
Refs: enc-06v