Adds a derived-subkey surface for consumers - #30
Merged
Conversation
`Encryptor.Vault.derive/3` lets a consumer obtain derived key bytes
for a scope without the input key material ever being exported to
it, which is what encryptor_ecto's blind index needs. The scope is
{ikm_selector, salt, info, length}: a label purpose plus the vault's
own selector, the vault's per-deployment salt, and the caller's own
info string and length.
The salt required an extract step, so `Encryptor.Kdf` gains
HKDF-Extract (RFC 5869 section 2.2) alongside its expand, and
`salted_subkey/5` composes the two. Only the new exported tree is
salted; root-wrap and tenant-ref keep the expand-only construction
and every key they derive is byte-identical to before.
Crypto decisions are ADR decisions here, so this lands with ADR-0003
amendment A at status Proposed. The acceptance reading is the
operator's, and the amendment records the salt's home in vault
configuration, which trees are salted, and the compatibility
consequence of the ones that are.
Refs: enc-ix8
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
encryptor_ecto's blind index needs a per-scope derived key and must neverreceive the key material it was derived from (its ADR-0003 assumptions A8 and
A11). This package exposed no way to reach that:
Encryptor.Kdfexpands frommaterial the caller already holds, and
Encryptor.Envelope.subkey/2takes anunwrapped
%Encryptor.Key.Aes{}downstream code has no path to.The amended
encryptor_ectoderivation also names a vault-configuredper-deployment salt (its A10), while
Encryptor.Kdfis deliberatelyHKDF-Expand-only with no salt parameter. That was the open cross-repo
question on the bead. The operator settled it on 2026-08-28:
The timing is the argument: nothing derived through this package is published
or persisted anywhere real, so the change is free exactly once, and this is
the once.
What
ruling verbatim. Seven decisions, four consequences, three new open
questions. It records the extract step, where the salt lives, which
derivation trees are salted, and the compatibility consequence.
Encryptor.Kdf.extract/2- HKDF-Extract, RFC 5869 section 2.2,SHA-256 - and
salted_subkey/5, which composes extract, an expandunder the package label, and an expand under the caller's info.
Encryptor.Vault.derive/3and a generatedderive/2. A consumer namesa scope and receives derived bytes; the input key material is never
returned, never carried in an error, and never rendered.
:derivation_saltvault configuration: optional at start, required atderivation, at least 32 bytes, refused in
useoptions.Error.operationgains:derive.For the crypto read
This merges on green but lands on the post-merge crypto-read queue, so every
constant and every judgment call is flagged here rather than left in the
diff. The downstream consumer will be rebased onto this surface.
The construction, in full:
Constants. SHA-256 throughout (
HashLen32,Lbounded at255 * 32,both pre-existing). Salt minimum 32 bytes. Intermediate
purpose_keyfixed at32 bytes. Default output length 32. Label grammar
"encryptor/v1/<purpose>",unchanged and still composed only by
Kdf.label/1.Judgment calls, each argued in the amendment:
root-wrapandtenant-refkeepthe expand-only construction and RFC 5869 section 3.3's argument for it.
Salting them would change the root vault's provider material and every
stored
tenant_ref, and buys nothing cryptographically for inputs that arealready uniform 256-bit keys. Machine-checked, not asserted: both
purposes'
root_subkeyoutputs were computed onmainand on this branchand are byte-identical.
extract/2is the unguarded RFC primitive; the 32-byte salt guard sitson
salted_subkey/5and on config. This was a correction during thework: RFC 5869's appendix A vectors use a 13-byte salt and an empty one, so
a guard on the primitive would have made the RFC's own numbers unrunnable
against it, leaving only a reimplementation to check against.
infoare neverconcatenated into one expansion: purpose
"a"/info"b"and purpose"ab"/info""would spell the same info string, which is the label reuseADR-0003 decision 6 forbids.
info: "". One extra HMAC,and the intermediate
purpose_keynever leaves the package.:saltoption is ignored, and there is a test asserting that.{:invalid_key_descriptor, :not_derivable}rather than exported. Existing reason atom; no vocabularyadded there.
encryption_key/2is consulted, neverdecryption_keys/2. Aderived subkey is recomputed rather than stored, so there is no historical
value to reproduce. The asymmetry with encrypt/decrypt is deliberate and
recorded (amendment A7).
:derivation_saltis refused inuseoptions with its own message,distinct from the key-material refusal: it is not secret, but a
per-deployment value compiled into a
.beamis shared by every deploymentbuilt from that artifact.
Inspectanyway, though it is not secret.derive!/3. The caller is a library with a tuple to thread.Error.operation4 -> 5.error_test's closed-vocabulary assertionmoved with it; that test firing is it working as designed, and the fifth
arrived through a record as that test requires.
Evidence.
SHA-256 cases), alongside the existing expand vectors.
implementation that is not this one - and those two constants are a
golden-vector test. The RFC vectors alone cover extract and expand
separately and would not catch a wrong composition; a mutation that
reorders the extract and the first expand is red only against this test.
mix compile --forcebetween them,each confirmed red.
Left to the operator: the acceptance reading of amendment A, and its three
open questions (A-1 whether the unsalted trees should be salted at a future
v2label space; A-2 per-tenant vs per-deployment salt; A-3 whetherderive/2belongs on the generated vault surface at all). ADR-0003's ownStatus line and the
docs/adr/README.mdstatus column are deliberatelyuntouched - status changes are yours.
Not in scope:
encryptor_ectois untouched. Consuming this surface is aseparate sequenced step.
Notes
mix qualitygreen: 419 tests, 98.2% coverage, dialyzer and credoclean. Doctor, Gettext and Sobelow are declared permanently inapplicable in
this project's manifest.
key material, so a component that can derive a tenant's index key can also
decrypt (ADR-0003 decision 7). This surface hides the material from the
caller; it does not create a search-only capability.
Closes enc-ix8