Skip to content

Adds a derived-subkey surface for consumers - #30

Merged
johnnyt merged 1 commit into
mainfrom
enc-ix8-derived-subkey-surface
Aug 28, 2026
Merged

Adds a derived-subkey surface for consumers#30
johnnyt merged 1 commit into
mainfrom
enc-ix8-derived-subkey-surface

Conversation

@johnnyt

@johnnyt johnnyt commented Aug 28, 2026

Copy link
Copy Markdown
Member

Why

encryptor_ecto's blind index needs a per-scope derived key and must never
receive the key material it was derived from (its ADR-0003 assumptions A8 and
A11). This package exposed no way to reach that: Encryptor.Kdf expands from
material the caller already holds, and Encryptor.Envelope.subkey/2 takes an
unwrapped %Encryptor.Key.Aes{} downstream code has no path to.

The amended encryptor_ecto derivation also names a vault-configured
per-deployment salt (its A10), while Encryptor.Kdf is deliberately
HKDF-Expand-only with no salt parameter. That was the open cross-repo
question on the bead. The operator settled it on 2026-08-28:

I agree with the salt ruling - add the salt now via an upstream HKDF-Extract
amendment, shaped to A8's {ikm_selector, salt, info, length}

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

  • ADR-0003 amendment A, appended at status Proposed, quoting the
    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 expand
    under the package label, and an expand under the caller's info.
  • Encryptor.Vault.derive/3 and a generated derive/2. A consumer names
    a scope and receives derived bytes; the input key material is never
    returned, never carried in an error, and never rendered.
  • :derivation_salt vault configuration: optional at start, required at
    derivation, at least 32 bytes, refused in use options.
  • Error.operation gains :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:

PRK         = HKDF-Extract(salt: derivation_salt, ikm: key material)
purpose_key = HKDF-Expand(PRK, info: "encryptor/v1/<purpose>", 32)
derived     = HKDF-Expand(purpose_key, info: <caller info>, length)

Constants. SHA-256 throughout (HashLen 32, L bounded at 255 * 32,
both pre-existing). Salt minimum 32 bytes. Intermediate purpose_key fixed at
32 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:

  1. Only the new exported tree is salted. root-wrap and tenant-ref keep
    the 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 are
    already uniform 256-bit keys. Machine-checked, not asserted: both
    purposes' root_subkey outputs were computed on main and on this branch
    and are byte-identical.
  2. extract/2 is the unguarded RFC primitive; the 32-byte salt guard sits
    on salted_subkey/5 and on config.
    This was a correction during the
    work: 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.
  3. Three steps, never two. The label and the caller's info are never
    concatenated into one expansion: purpose "a"/info "b" and purpose
    "ab"/info "" would spell the same info string, which is the label reuse
    ADR-0003 decision 6 forbids.
  4. The final expand always runs, including for info: "". One extra HMAC,
    and the intermediate purpose_key never leaves the package.
  5. The salt comes from configuration and a caller cannot override it. A
    :salt option is ignored, and there is a test asserting that.
  6. A KMS descriptor is refused as {:invalid_key_descriptor, :not_derivable} rather than exported. Existing reason atom; no vocabulary
    added there.
  7. Only encryption_key/2 is consulted, never decryption_keys/2. A
    derived 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).
  8. :derivation_salt is refused in use options with its own message,
    distinct from the key-material refusal: it is not secret, but a
    per-deployment value compiled into a .beam is shared by every deployment
    built from that artifact.
  9. The salt is redacted in Inspect anyway, though it is not secret.
  10. No derive!/3. The caller is a library with a tuple to thread.
  11. Error.operation 4 -> 5. error_test's closed-vocabulary assertion
    moved with it; that test firing is it working as designed, and the fifth
    arrived through a record as that test requires.

Evidence.

  • RFC 5869 appendix A vectors now cover extract directly (all three
    SHA-256 cases), alongside the existing expand vectors.
  • The composed construction is checked against OpenSSL 3.6.3 - an
    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.
  • 23 sabotage mutations run, each with mix compile --force between 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
v2 label space; A-2 per-tenant vs per-deployment salt; A-3 whether
derive/2 belongs on the generated vault surface at all). ADR-0003's own
Status line and the docs/adr/README.md status column are deliberately
untouched - status changes are yours.

Not in scope: encryptor_ecto is untouched. Consuming this surface is a
separate sequenced step.

Notes

  • Full mix quality green: 419 tests, 98.2% coverage, dialyzer and credo
    clean. Doctor, Gettext and Sobelow are declared permanently inapplicable in
    this project's manifest.
  • Capability separation is unchanged and still absent: deriving requires the
    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

`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
@johnnyt
johnnyt merged commit da7a9f2 into main Aug 28, 2026
1 check passed
@johnnyt
johnnyt deleted the enc-ix8-derived-subkey-surface branch August 28, 2026 04:13
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