Encryptor.Provider.Kms (Encryptor v0.3.0)

Copy Markdown View Source

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 providerthis one
version identity:name, minted by the providerthe KMS key ARN, assigned by AWS
who holds the wrapping keythe host's key store, as a wrapped blobAWS KMS; nothing is stored
the data key is generatedby the engine, locallyinside KMS, by GenerateDataKey
the two-level envelopeyesno - there is no tenant master key to wrap
Encryptor.Vault.derive/3availablerefused, {:invalid_key_descriptor, :not_derivable}
dropping a version from the candidate listis the crypto-shredis not the shred
the shredDELETE the wrapping from the key storeScheduleKeyDeletion 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 :single vault 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 shipped ExAws client, which is what requires the optional dependencies below.
  • :config - passed to the shipped client alongside :region. It is where ex_aws conventionally 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 to false, 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:GenerateDataKey and kms:Decrypt grants are the operator's infrastructure. Encryptor.Provider.provision/2 is 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/3 and 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

entry()

@type entry() :: String.t() | [key_id: String.t(), mrk: boolean()]

One candidate: a key id, or a key id with its own :mrk.

opts()

@type opts() :: keyword()

Exactly one of :keys or :key_id, and at most one of :client or :region.

state()

@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

decryption_keys(state, selector)

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

encryption_key(state, selector)

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

init(opts)

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