A keyring-backed provider: the tenant's key is an AWS KMS key.
ADR-0008. This is the only adapter that answers Encryptor.Key.Kms
descriptors, and AWS KMS is the only key manager that ever will - every
other one is a material source that produces the bytes of an
Encryptor.Key.Aes (ADR-0002 decision 5).
What that buys, and what it costs, is one table:
| a material-source provider | this one | |
|---|---|---|
| version identity | :name, minted by the provider | the KMS key ARN, assigned by AWS |
| who holds the wrapping key | the host's key store, as a wrapped blob | AWS KMS; nothing is stored |
| the data key is generated | by the engine, locally | inside KMS, by GenerateDataKey |
| the two-level envelope | yes | no - there is no tenant master key to wrap |
Encryptor.Vault.derive/3 | available | refused, {:invalid_key_descriptor, :not_derivable} |
| dropping a version from the candidate list | is the crypto-shred | is not the shred |
| the shred | DELETE the wrapping from the key store | ScheduleKeyDeletion on the tenant's KMS key |
The row that destroys data if it is skimmed
Removing a key from what this provider answers is not a crypto-shred. It
hides the tenant's data from this vault; KMS can still decrypt it for anyone
holding kms:Decrypt on the key, including from a backup of the ciphertext.
The shred on this path is ScheduleKeyDeletion on the key itself, it is not
complete until the pending-deletion window elapses, and CancelKeyDeletion
works inside that window. ADR-0005 P3 step 2 reads differently per shape and
ADR-0008 decision 4's table is where the two are reconciled.
What the shred gains in exchange: it survives backups, because the wrapping key was never in one.
Configuration
config :my_app, MyApp.TenantVault,
provider:
{Encryptor.Provider.Kms,
region: "us-east-1",
keys: %{
"acme" => "arn:aws:kms:us-east-1:111122223333:key/abcd1234",
"globex" => [
"arn:aws:kms:us-east-1:111122223333:key/ef567890",
"arn:aws:kms:us-east-1:111122223333:key/0b1c2d3e"
]
}}:keys- a map from selector to the key ids that selector's stored messages may have been written under, newest first, or a one-argument function taking the selector and answering{:ok, entries}/{:error, reason}. A bare string is the one-entry shape. A selector the map does not hold is{:unknown_key, selector}.:key_id- the selector-ignoring shape, for a:singlevault or a host that puts every tenant on one key. Mutually exclusive with:keys.:client- the engine's KMS client struct, built by the host. Mutually exclusive with:region.:region- builds the engine's shippedExAwsclient, which is what requires the optional dependencies below.:config- passed to the shipped client alongside:region. It is whereex_awsconventionally takes a static access key id and secret, so prefer an instance role and leave it out.:mrk- whether the keys are multi-region keys. Defaults tofalse, and an entry may override it:[key_id: "mrk-abcd1234", mrk: true].
At engine v1.0.0 the multi-region keyring is the same code path as the
single-region one. :mrk selects the engine struct and this package asserts
nothing else about it (ADR-0008 decision 8).
The AWS dependencies are the host's
This package declares none of them. The engine's AWS client stack is
its optional dependency, and a dependency's optional dependencies do not
flow into a dependent's build - adding them here would make every consumer
of this package carry an AWS HTTP stack it did not ask for. A host that
wants the shipped client adds :ex_aws and :ex_aws_kms itself; without
them Encryptor.Provider.init/1 refuses :region at vault start with
{:missing_optional_dependency, :ex_aws_kms} - at start, so a misconfigured
deploy fails to boot rather than failing on a customer's first write
(ADR-0002 decision 5, ADR-0008 decision 9).
A host supplying its own AwsEncryptionSdk.Keyring.KmsClient implementation
passes it as :client and needs none of them.
What it never does
- It creates no keys. The KMS key, its key policy, its alias and the
application role's
kms:GenerateDataKeyandkms:Decryptgrants are the operator's infrastructure.Encryptor.Provider.provision/2is not implemented, and a caller reaching for it gets{:not_provisionable, Encryptor.Provider.Kms}-provisioned()is shaped around a wrapped master key and none of its fields has a value here (ADR-0008 decision 7). - It stores nothing. There is no row, so there is nothing to read back and nothing to migrate.
- It never asks KMS to export a key, which is why
Encryptor.Vault.derive/3and every blind index built on it need the material-source shape instead.
Migrating a tenant from raw keys to KMS
An ordinary rotation window (ADR-0005 decision 2), because a candidate list
may hold both shapes at once: answer the Encryptor.Key.Kms descriptor
first and the live Encryptor.Key.Aes versions after it, flip
Encryptor.Provider.encryption_key/2 to the KMS one, re-encrypt at the
host's pace, then retire the AES versions - and that retire is a shred,
because the retired shape is the material-source one.
Nothing can be confused for anything else while it runs: a RawAes child
accepts an encrypted data key only when the header's provider id equals its
namespace, an AwsKms child only when it is exactly "aws-kms", and this
package refuses that prefix as a namespace before it builds anything. A
provider mixing the two shapes for one selector is the host's to write -
this adapter answers KMS keys only - and Encryptor.Provider.Function is
the shape that composes them without one.
Records: ADR-0002 decisions 1, 3, 4, 5 and 6; ADR-0005 decisions 2 and 3; ADR-0008 decisions 1, 3, 4, 6, 7, 8 and 9.
Summary
Types
One candidate: a key id, or a key id with its own :mrk.
Exactly one of :keys or :key_id, and at most one of :client or :region.
The frozen state: the client, and either the resolved descriptors or the host's closure.
Functions
Every key the selector's stored messages may have been written under, newest first.
The head of the selector's candidate list: the key new writes go under.
Builds the client once, resolves the configured keys, and freezes both.
Types
One candidate: a key id, or a key id with its own :mrk.
@type opts() :: keyword()
Exactly one of :keys or :key_id, and at most one of :client or :region.
@type state() :: %{ client: struct(), mrk: boolean(), keys: %{required(Encryptor.Provider.selector()) => [Encryptor.Key.Kms.t(), ...]} | [Encryptor.Key.Kms.t(), ...] | (term() -> term()) }
The frozen state: the client, and either the resolved descriptors or the host's closure.
Functions
@spec decryption_keys(state(), Encryptor.Provider.selector()) :: {:ok, [Encryptor.Key.Kms.t(), ...]} | {:error, Encryptor.Provider.reason()}
Every key the selector's stored messages may have been written under, newest first.
A walk down this list on the decrypt path costs one KMS Decrypt per wrong
candidate, so the head is where the write key belongs.
@spec encryption_key(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Key.Kms.t()} | {:error, Encryptor.Provider.reason()}
The head of the selector's candidate list: the key new writes go under.
@spec init(opts()) :: {:ok, state()} | {:error, Encryptor.Error.reason()}
Builds the client once, resolves the configured keys, and freezes both.
The optional-dependency check runs here rather than at first use, and the descriptors are built here so that resolution is a lookup afterwards.