The level 1 to level 2 relationship: how a tenant master key comes into existence, what protects it at rest, and how it gets back into memory.
ADR-0003 names three levels and this module is entirely about the middle one's relationship to the top:
- Level 1, the root key. One per deployment, supplied from the environment today and from a key manager later. It never encrypts application data; it exists to protect level 2.
- Level 2, the tenant master key. One per tenant per version. It never
encrypts application data either. It is the wrapping key the tenant
vault's
RawAeskeyring is built from, and it is what makes one tenant's data cryptographically separable from another's. - Level 3, the data key. One per message, generated by the engine, wrapped by the level 2 key, and discarded. This package does not implement level 3 at all.
The five things this module decides
A tenant master key is 32 random bytes, generated once, never derived
(decision 1). provision/3 calls :crypto.strong_rand_bytes/1. The key is
not a function of the tenant id, the version, or the root key, and the
reason is crypto-shredding: destroying every copy of the wrapping destroys
the key, so a delete is honest. A derived tenant key cannot be destroyed -
anyone holding the root recomputes it from the tenant id forever - and
deleting its row deletes a memo rather than a secret.
The wrapping is an ordinary Encryptor message (decision 2). The
wrapped-key blob is a complete engine message produced by a root vault, so
this package defines no wire format of its own, and every property
ADR-0001 bought - commitment policy, EDK limits, context binding, the error
vocabulary, rekey/2 - applies to it for free. Three consequences follow:
the root can move to a key manager without a format change, root rotation is
rewrap/2 and nothing else, and the blob is key-committed because the root
vault's configuration says so.
The plaintext never appears in a return value (decision 3). It exists
inside provision/3's body and is not returned, logged, or put in a struct.
unwrap/2 hands back an %Encryptor.Key.Aes{} descriptor, whose material
is redacted from inspect/2. There is no function in this package that
returns a bare tenant master key as a binary.
The wrapping's encryption context is package-owned (decision 4). See below.
tenant_ref is a keyed derivation, not a hash (decision 5). An unkeyed
hash of a low-entropy identifier is reversible by anyone who can guess the
identifier space, and tenant identifiers are frequently small integers or
short slugs.
The binding, verbatim
provision/3 sets, and unwrap/2 reproduces and requires, exactly the
four pairs ADR-0003 decision 4 spells:
%{
"encryptor-purpose" => "tenant-key-wrap",
"encryptor-tenant-ref" => tenant_ref,
"encryptor-key-version" => Integer.to_string(version),
"encryptor-key-namespace" => namespace
}The encryptor- prefix is this package's, chosen to sit clear of the
engine's reserved aws-crypto-, and Encryptor.Context refuses it from a
host on every path. It is not a host option here either: passing
:encryption_context to provision/3 is {:reserved_context_key, key}.
What it buys is the confused-deputy defence. A blob copied from one tenant's
row into another's does not unwrap; a blob copied from version 2 to
version 3 does not unwrap; a blob from some other part of the application
that happens to be an Encryptor message does not unwrap as a tenant key.
Without the binding all three are silent successes that yield the wrong key,
and the wrong key surfaces later as :decrypt_failed on application data -
the worst possible place to first learn about it.
The requiring is this module's, not the vault's. The vault-side
comparison of ADR-0004 decision 6 compares only keys present in both the
stored and the reproduced context, by design, so a message carrying none of
the four pairs would pass it. unwrap/2 and rewrap/2 therefore read the
header themselves and refuse a blob that does not carry every pair at the
value the row claims, before any key material is touched.
The two subkeys of the root, and their different lifetimes
ADR-0003 decision 6, unchanged:
| 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 |
Separating them means a routine root rotation can replace the wrapping
subkey while leaving every stored tenant_ref valid. The label namespace is
explicitly reserved beyond these two: "any future purpose-separated key
derived from the root or from a tenant master key takes a new
"encryptor/v<n>/<purpose>" label and never reuses an existing one".
ADR-0005 decision 5 then splits the inputs: after the first root rotation
a deployment supplies a pinned reference root and a rotating wrapping
root, and root_subkey/2 takes the material as an explicit argument
precisely so a host can pass different binaries for the two labels.
Acyclicity, and the configuration that would break it
The root vault used to wrap tenant keys is an Encryptor.Vault, so this
module sits above the vault rather than beside it. The arrangement looks
circular and is not: the root vault's provider is Static and resolves
nothing from a store. A root vault configured with a store-backed provider
would be a genuine cycle, and it would recurse or deadlock rather than
fail cleanly. ADR-0003's consequences call that out as worth enforcing in
documentation; this paragraph is that enforcement.
The root vault is also configured cache: false (decision 2) and
context_profile: :single. Provisioning is rare and unwraps are already
collapsed by the tenant vault's materials cache, so a second cache here
would hold the root's own data keys with no measurable benefit and a real
cost in what sits in memory.
Resolution never provisions
A tenant key comes into existence exactly when a caller runs provision/3
(decision 8). encryption_key/2 and decryption_keys/2 never provision. A
provider that lazily provisioned on first use would mean a typo in a tenant
identifier silently mints a key, that a race between two requests can mint
two keys for one tenant, and that the first encrypt after a deploy does a
write on the read path. An unknown selector is {:unknown_key, selector},
full stop.
What this module never sees
Storage. It produces and consumes Encryptor.Envelope.WrappedKey structs
and defines no table, migration, repo, primary key, index, or transaction -
encryptor_ecto owns all of that (decision 9). No function here takes a
repo, a query, a batch size, or a table, and there is deliberately no
shred/2, retire/2, or rotate/2: deleting a wrapping is a DELETE
against a store this package cannot see the copies of (ADR-0005 decision
10).
Worked example
# Onboarding a tenant, once.
{:ok, wrapped} =
Encryptor.Envelope.provision(MyApp.RootVault, tenant.id,
reference_subkey: reference_subkey,
namespace: "acme-tenant"
)
{:ok, _row} = MyApp.TenantKeys.insert(wrapped)
# Resolving it, on a cold cache, inside a store-backed provider.
{:ok, %Encryptor.Key.Aes{} = descriptor} =
Encryptor.Envelope.unwrap(MyApp.RootVault, row)
# Rotating the root, touching no application data.
for row <- MyApp.TenantKeys.all_live() do
{:ok, rewrapped} = Encryptor.Envelope.rewrap(MyApp.RootVault, row)
MyApp.TenantKeys.update_wrapping(row, rewrapped)
endRecords: ADR-0003 decisions 1 through 9, as amended at acceptance; ADR-0005 decisions 5, 7 and 10.
Summary
Types
provision/3's options.
A vault module configured to wrap tenant keys. Static provider, cache: false.
A tenant identifier, as ADR-0004 decision 3 types a selector.
Functions
Mints a tenant master key and wraps it under the root vault.
Re-wraps one wrapping under the root vault's current materials.
Expands one of ADR-0003 decision 6's labelled subkeys from the supplied root key material.
Derives a purpose-separated subkey from a tenant master key.
The keyed, stable, public reference for a tenant identifier.
Unwraps a stored wrapping into the descriptor a provider returns.
Types
@type opts() :: [ reference_subkey: binary(), namespace: String.t(), version: pos_integer() ]
provision/3's options.
:reference_subkey has no default and is required; see provision/3 for
why ADR-0003's original two-key opts() had to grow it.
@type root_vault() :: module()
A vault module configured to wrap tenant keys. Static provider, cache: false.
@type selector() :: Encryptor.Error.selector()
A tenant identifier, as ADR-0004 decision 3 types a selector.
Functions
@spec provision(root_vault(), selector(), opts()) :: {:ok, Encryptor.Envelope.WrappedKey.t()} | {:error, Encryptor.Error.t()}
Mints a tenant master key and wraps it under the root vault.
32 bytes from the CSPRNG, wrapped into an ordinary Encryptor message with
ADR-0003 decision 4's binding as its package-reserved context, returned as
an Encryptor.Envelope.WrappedKey. The plaintext does not leave this
function.
This is P2 step 1 of ADR-0005's runbook: it mints a version, it never
removes one, and it never changes what decryption_keys/2 already returns
for versions that exist. Calling it twice concurrently for one tenant can
produce two rows claiming the same version; the transaction that closes that
race is encryptor_ecto's, and ADR-0003 open question 1 owns the split.
Options
:reference_subkey- required, 32 bytes. The pinned reference root expanded under"encryptor/v1/tenant-ref", which is whattenant_refderives from.:namespace- the key provider id written into every message header. Defaults to"encryptor-tenant". It may not begin with"aws-kms".:version- defaults to1. ADR-0003's own worked example provisions without naming a version, so first provisioning needs no ceremony; a rotation namesn + 1explicitly.
Why :reference_subkey is an option ADR-0003's opts() did not have
ADR-0003's typespec reads provision(root_vault(), selector(), opts()) with
opts() holding only :namespace and :version, and its flow diagram
derives the reference inside this function from the root. That was written
before ADR-0005 decision 5 split the roots. After the split "a root vault
holds only the wrapping subkey as its Static provider material", so a root
vault is no longer an input the reference can be derived from - which is
exactly why tenant_ref/2's signature was amended at acceptance to take the
subkey. provision/3 needs the same value for the same reason, and takes it
the same way. This is an extension of the accepted opts() forced by the
accepted amendment, and it is flagged rather than assumed.
What it refuses
:encryption_contextinopts-{:reserved_context_key, key}. The binding is not a host option on this path. The host's own static context from the root vault's configuration still merges underneath, because that is ADR-0001 decision 4's behaviour and there is no reason to special-case it.- a missing
:reference_subkey-{:missing_config, [:reference_subkey]}, the same termEncryptor.Vault.Configuses for the same value. - a
:reference_subkeythat is not 32 bytes -{:invalid_config, :reference_subkey, :invalid_length}, likewise. - a
:versionthat is not a positive integer -{:invalid_config, :version, :not_a_positive_integer}. - a selector that is not a non-empty string -
{:invalid_selector, term}. A tenant reference has no meaning for:default. - a
:namespacethe engine or this package will not carry in a header -{:invalid_key_descriptor, detail}, from the same checks the vault's keyring builder (lib/encryptor/vault/keyring.ex) runs, so a row is refused at minting rather than at the first encrypt that tries to use it.
@spec rewrap(root_vault(), Encryptor.Envelope.WrappedKey.t()) :: {:ok, Encryptor.Envelope.WrappedKey.t()} | {:error, Encryptor.Error.t()}
Re-wraps one wrapping under the root vault's current materials.
This is root rotation, P1 step 3 of ADR-0005's runbook, and it touches no application data: it rewrites one small blob per tenant per live version and leaves every identity column alone, which is why decision 6's split of the two root labels was worth making.
It is built on Encryptor.Vault.rekey/2 and adds nothing cryptographic of
its own - ADR-0005 decision 7 settles that rekey/2 stays on the vault
precisely because this is its canonical caller. The context is carried
across byte for byte, so the returned wrapping carries the same binding, and
every identity field of the struct is unchanged. Only :wrapped moves.
Idempotent in effect, not in bytes. A wrapping already under the current root rewraps to an equivalent wrapping: different bytes, because a fresh data key and a fresh IV go into every message, and an identical descriptor on unwrap. A rewrap pass is therefore safe to re-run and safe to resume after a partial failure, which is what makes P1 step 3 recoverable.
The binding is required here for the same reason it is required in
unwrap/2: a rekey of a blob that is not a tenant-key wrapping would write
a foreign message into the wrapped-key population under a row that claims it
is a tenant key.
@spec root_subkey(binary(), Encryptor.Kdf.purpose()) :: binary()
Expands one of ADR-0003 decision 6's labelled subkeys from the supplied root key material.
One line onto Encryptor.Kdf.derive_subkey/3, which is the package's only
HKDF implementation. The two purposes decision 6 fixes:
iex> root = :binary.copy(<<0x0B>>, 32)
iex> byte_size(Encryptor.Envelope.root_subkey(root, "root-wrap"))
32
iex> root = :binary.copy(<<0x0B>>, 32)
iex> Encryptor.Envelope.root_subkey(root, "root-wrap") == Encryptor.Envelope.root_subkey(root, "tenant-ref")
falseThe parameter is a purpose, and the record can be read two ways
ADR-0003's typespec names the second parameter label and decision 6's
table gives the labels in full ("encryptor/v1/root-wrap"), while the same
record's worked example calls root_subkey(root, "root-wrap") - a purpose,
not a full label. The only reading under which both are true is that the
callee composes the "encryptor/v1/" prefix, which is what
Encryptor.Kdf.label/1 does and is the reading enc-j4h landed. This
function follows it. Flagged rather than treated as settled.
A consequence of the composition is that a purpose containing "/" is
refused, so a caller cannot spell an existing label from a different
starting point and defeat decision 6's one-way reservation.
Two inputs, after ADR-0005 decision 5
The material is an explicit argument rather than something read from a vault, and ADR-0005 decision 5 leans on exactly that: after the first root rotation a deployment supplies a pinned reference root and a rotating wrapping root, and "a host simply passes different binaries for the two labels". Before the first rotation the two hold the same bytes.
Raises rather than returning {:error, _}, because ADR-0003 specifies a
bare binary() return and every way to fail is a caller-supplied constant
that is wrong in the source. Every message names the constraint and never
the value.
@spec subkey(Encryptor.Key.Aes.t(), Encryptor.Kdf.purpose()) :: binary()
Derives a purpose-separated subkey from a tenant master key.
ADR-0003 decision 7. The tenant master key is a wrapping key first and a derivation root only under an explicit label:
The tenant master key is used directly as
RawAesmaterial for the encryption path, and that use is unlabelled because it predates and defines the key. Changing it would invalidate stored ciphertext.Any other use of a tenant master key derives a subkey by
HKDF-Expand(tenant_master_key, info: "encryptor/v1/<purpose>", 32)with a purpose label that is not"root-wrap"or"tenant-ref".
Both root purposes are therefore refused here, and the refusal is the reservation being mechanical rather than remembered:
iex> key = %Encryptor.Key.Aes{namespace: "acme-tenant", name: "t/ref/v1", material: :binary.copy(<<7>>, 32), bits: 256}
iex> byte_size(Encryptor.Envelope.subkey(key, "blind-index"))
32
iex> key = %Encryptor.Key.Aes{namespace: "acme-tenant", name: "t/ref/v1", material: :binary.copy(<<7>>, 32), bits: 256}
iex> Encryptor.Envelope.subkey(key, "root-wrap")
** (ArgumentError) "root-wrap" and "tenant-ref" are the root's purposes and may not be derived from a tenant master keyA derived subkey is never stored. It is recomputed from the tenant master key on demand, so it inherits the master key's shred semantics exactly: destroying the wrapping destroys the index keys too.
What this does not provide is capability separation. A component that can derive index keys under this scheme necessarily holds the tenant master key and can therefore also decrypt. A genuine search-only capability requires an independently random index key with its own wrapping, stored in its own column - a second wrapped key per tenant, not a derivation. ADR-0003 open question 4 was resolved at acceptance in favour of derived subkeys for now, with independently wrapped index keys held open as the recorded upgrade path.
Takes the descriptor rather than the bytes so that a caller reaches for
material it already holds legitimately, and so the argument is a type whose
inspect/2 redacts it.
@spec tenant_ref(binary(), selector()) :: {:ok, String.t()} | {:error, Encryptor.Error.t()}
The keyed, stable, public reference for a tenant identifier.
ADR-0003 decision 5's derivation, amended at acceptance to take the reference subkey rather than a root vault:
tenant_ref =
Base.url_encode64(
binary_part(HMAC-SHA256(reference_subkey, selector), 0, 16),
padding: false
)Two properties are what it is bought for. It is stable, so the same tenant always resolves to the same reference and the row can be found. And it is unguessable without the subkey, so a header discloses that two ciphertexts belong to the same tenant without disclosing which tenant that is - which an unkeyed hash of a short slug would not.
The output is not secret: it travels in the clear in every message header,
both as the tenant_ref context pair and inside the encrypted data key's
name. The input is, and it never reaches a message, a log line, or a
failure report.
The derivation lives in one place for all three callers - the vault's start-time known-answer check, the encrypt path's context injection, and this function - because a derivation spelled three times can drift in two of them, and a drifted reference is not a failed check but a message no correct reader can open, against a subkey ADR-0005 consequence four calls effectively permanent.
iex> subkey = :binary.copy(<<0x2A>>, 32)
iex> {:ok, ref} = Encryptor.Envelope.tenant_ref(subkey, "merchant-42")
iex> byte_size(ref)
22
iex> Encryptor.Envelope.tenant_ref(subkey, "merchant-42") == {:ok, ref}
true:default is refused: a :single vault has no tenant to name.
iex> subkey = :binary.copy(<<0x2A>>, 32)
iex> {:error, error} = Encryptor.Envelope.tenant_ref(subkey, :default)
iex> error.reason
{:invalid_selector, :default}
@spec unwrap(root_vault(), Encryptor.Envelope.WrappedKey.t()) :: {:ok, Encryptor.Key.Aes.t()} | {:error, Encryptor.Error.t()}
Unwraps a stored wrapping into the descriptor a provider returns.
The blob is decrypted by the root vault under the reproduced binding, and
the result is the %Encryptor.Key.Aes{} of ADR-0002 decision 3 - never a
bare binary.
Three failures are worth naming:
- a row whose fields cannot form a descriptor, or a
namespace,nameorbitsthe vault would refuse, is{:invalid_key_descriptor, detail}. The detail names the constraint and never the value. - a blob that does not carry every pair of the binding, at the value this
row claims, is
:decrypt_failed- checked from the header before any key material is touched. This is what makes a blob copied between tenants, between versions, or in from elsewhere in the application fail here rather than as a wrong key on application data later. - a blob that does not open under the root vault's current materials is
:decrypt_failedtoo, with the engine's own term in:engine. During a root rotation that is the expected answer for a wrapping the rewrap pass has not reached yet, which is why ADR-0005's P1 keeps the outgoing subkey in the root vault's candidate list until the pass finishes.
unwrap/2 deliberately does not compare the row's name column against
the blob. ADR-0003 open question 6 records that cross-check as unsettled:
the binding already guarantees the blob belongs to the claimed tenant and
version, and making the denormalized columns authoritative is a thing
decision 4 explicitly declined to do.