Encryptor.Envelope (Encryptor v0.2.0)

Copy Markdown View Source

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 RawAes keyring 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:

LabelUseRotates with
"encryptor/v1/root-wrap"the root vault's Static provider materiala rewrap pass over every wrapped key
"encryptor/v1/tenant-ref"the reference derivation in decision 5a 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)
end

Records: 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

opts()

@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.

root_vault()

@type root_vault() :: module()

A vault module configured to wrap tenant keys. Static provider, cache: false.

selector()

@type selector() :: Encryptor.Error.selector()

A tenant identifier, as ADR-0004 decision 3 types a selector.

Functions

provision(root_vault, selector, opts \\ [])

@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 what tenant_ref derives from.
  • :namespace - the key provider id written into every message header. Defaults to "encryptor-tenant". It may not begin with "aws-kms".
  • :version - defaults to 1. ADR-0003's own worked example provisions without naming a version, so first provisioning needs no ceremony; a rotation names n + 1 explicitly.

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_context in opts - {: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 term Encryptor.Vault.Config uses for the same value.
  • a :reference_subkey that is not 32 bytes - {:invalid_config, :reference_subkey, :invalid_length}, likewise.
  • a :version that 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 :namespace the 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.

rewrap(root_vault, wrapped)

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.

root_subkey(root_key, purpose)

@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")
false

The 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.

subkey(aes, purpose)

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 RawAes material 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 key

A 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.

tenant_ref(reference_subkey, selector)

@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}

unwrap(root_vault, wrapped)

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, name or bits the 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_failed too, 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.