Skip to content

Adds the envelope and its labelled subkeys - #28

Merged
johnnyt merged 1 commit into
mainfrom
enc-06v-envelope-subkeys
Aug 28, 2026
Merged

Adds the envelope and its labelled subkeys#28
johnnyt merged 1 commit into
mainfrom
enc-06v-envelope-subkeys

Conversation

@johnnyt

@johnnyt johnnyt commented Aug 28, 2026

Copy link
Copy Markdown
Member

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.Envelope and Encryptor.Envelope.WrappedKey, plus a small
internal seam for the package-reserved context layer.

  • provision/3 mints and wraps. The plaintext exists inside one function
    body: never returned, never logged, never in a struct.
  • unwrap/2 returns an %Encryptor.Key.Aes{}, validated through the same
    checks Encryptor.Vault.Keyring runs, so a row that cannot form a
    descriptor is refused at resolution rather than as an EDK mismatch later.
  • rewrap/2 is root rotation, built on Encryptor.Vault.rekey/2, with
    every identity column and the whole binding carried across unchanged.
  • tenant_ref/2 delegates to the internal Encryptor.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/2 and subkey/2 are one line each onto
    Encryptor.Kdf. No HKDF is re-implemented here.
  • Encryptor.Vault.{Encrypt,Decrypt}.call/4 and
    Encryptor.Vault.Resolve.context/5 grew a positional reserved-context
    argument. 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.Context refuses it, which is the whole
    content of {:reserved_context_key, key}. A test asserts a host cannot
    reach 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/2 and rewrap/2 read
the 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/3 and required by
unwrap/2:

%{
  "encryptor-purpose" => "tenant-key-wrap",
  "encryptor-tenant-ref" => tenant_ref,
  "encryptor-key-version" => Integer.to_string(version),
  "encryptor-key-namespace" => namespace
}

ADR-0003 decision 6's two root labels, composed by Encryptor.Kdf.label/1:

Label Use Rotates with
"encryptor/v1/root-wrap" the root vault's Static provider material a rewrap pass over every wrapped key
"encryptor/v1/tenant-ref" the reference derivation in decision 5 a re-index pass over every stored row

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/2 refuses both outright, and refuses a purpose containing /.

Decision 5's derivation, and the name grammar of ADR-0002 decision 4:

tenant_ref = Base.url_encode64(binary_part(HMAC-SHA256(ref_key, tenant_id), 0, 16), padding: false)
name       = "t/<tenant_ref>/v<n>"

Other fixed values: material 32 bytes (bits: 256, decision 1), reference
subkey 32 bytes (matching Encryptor.Vault.Config), reference width 16 bytes
of the tag, default namespace "encryptor-tenant", default version 1.

Flagged for the operator's read - none of these is treated as settled

  1. provision/3 takes a required :reference_subkey option that
    ADR-0003's opts() does not list.
    The record's flow diagram derives the
    reference inside provision/3 from the root, which stopped being possible
    when ADR-0005 decision 5 split the roots - the same reason tenant_ref/2's
    signature was amended at acceptance. provision/3 needs the same value for
    the same reason and takes it the same way. This is the largest single
    deviation in the PR.
  2. root_subkey/2's second parameter is a purpose, not a full label.
    ADR-0003's typespec names it label and decision 6's table gives labels in
    full, while the same record's worked example passes "root-wrap". The only
    reading under which both are true is that the callee composes the prefix,
    which is what Encryptor.Kdf.label/1 does and what enc-j4h landed. Same
    flag that bead raised, restated here because this is the function the
    record actually names.
  3. provision/3 refuses an empty :encryption_context map, where
    Encryptor.Vault.Rekey tolerates one.
    ADR-0003 decision 4 reads
    literally 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.
  4. The reason for a bad :reference_subkey, :version or :namespace
    argument.
    No new terms were added to Encryptor.Error.reason/0, per the
    record. A missing or wrong-length reference subkey reuses
    {:missing_config, [:reference_subkey]} and
    {:invalid_config, :reference_subkey, :invalid_length} - the exact terms
    Encryptor.Vault.Config already reports for the same value. A bad version
    is {:invalid_config, :version, :not_a_positive_integer}. A namespace the
    engine reserves is {:invalid_key_descriptor, detail}. Reusing
    :invalid_config for a call-site argument rather than a config key is the
    part worth a second look.
  5. {:invalid_key_descriptor, {:invalid_wrapped_key_field, field}} is a new
    detail 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.
  6. ADR-0003 open question 6 is left open. unwrap/2 does not compare the
    row's name column against the blob. What it does check is bits, which
    the binding does not cover, because that is the one descriptor field a
    drifted column could otherwise carry into a keyring.
  7. binding/3 and key_name/2 are @doc false. The record's contract
    section lists neither, and a store-backed provider gets the binding applied
    by calling unwrap/2. If enc-5h9 or encryptor_ecto wants either as a
    public surface, that is a decision, not a refactor.

Notes

  • Gate: full mix quality green post-rebase - format, compile
    (warnings-as-errors), credo --strict, dialyzer, deps, 361/361 tests, 98.2%
    coverage against a 90% floor. lib/encryptor/envelope.ex and
    lib/encryptor/envelope/wrapped_key.ex are both at 100%.
  • Sabotage: 41 mutations, applied one at a time with mix compile --force
    between 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/1 output that never contains hex - and
    both were rewritten against mutations that do go red.
  • Nothing here walks storage. No table, migration, repo, query, batch
    size, or transaction, and no shred/2, retire/2 or rotate/2 - ADR-0005
    decision 10 lists those as not-shipped-and-deliberately.
  • The envelope's own moduledoc records the acyclicity constraint: a root vault
    configured with a store-backed provider would be a genuine cycle and would
    recurse rather than fail cleanly.

Refs: enc-06v

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
@johnnyt
johnnyt merged commit 1b42c26 into main Aug 28, 2026
1 check passed
@johnnyt
johnnyt deleted the enc-06v-envelope-subkeys branch August 28, 2026 02:04
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