A wrap-provider: the tenant master key is wrapped and unwrapped by a GCP
Cloud KMS CryptoKey, and the vault is handed ordinary AES material.
ADR-0007 decision 1. This is a material source in ADR-0002 decision 5's
sense, exactly as that record classified GCP KMS: it introduces no
descriptor, no keyring and no engine change. Where ADR-0003 decision 2 wraps
the tenant master key with a root Encryptor vault into an engine message,
this provider wraps it with a GCP CryptoKey into a GCP KMS ciphertext.
| ADR-0003 root-vault envelope | this provider | |
|---|---|---|
| what holds the wrapping key | a root Encryptor vault | a GCP CryptoKey |
| the stored blob | an AWS ESDK message | a GCP KMS ciphertext |
| binding | encryption context (ADR-0003 decision 4) | GCP additional authenticated data, same fields |
| unwrap | Encryptor.Envelope.unwrap/2, local | Decrypt, one network round trip |
| application ciphertext | unchanged AWS ESDK message | unchanged AWS ESDK message |
The last row is the point: two hosts running the two shapes write byte-compatible application data and differ only in one small blob per tenant per version.
The wrapping root moves and nothing else does. The reference subkey of
ADR-0003 decision 6 stays local and stays configured on the tenant vault
(ADR-0004 decision 4 as amended), because tenant_ref travels in the clear
in every message header and must not depend on a remote service that could
be unreachable on the read path.
Configuration
config :my_app, MyApp.TenantVault,
provider:
{Encryptor.Provider.GcpKms,
project: "myapp-prod",
location: "us-east1",
key_ring: "encryptor-tenant-keys",
reference_subkey: {:system, "ENCRYPTOR_REFERENCE_SUBKEY"},
http_client: MyApp.KmsHttp,
goth: MyApp.Goth,
store: &MyApp.TenantKeys.live/1},
store: MyApp.TenantKeys,
max_age: :timer.minutes(5):project,:location,:key_ring- required. The ring exists already: this package never creates one (see "What it never does").:reference_subkey- required, 32 bytes. ADR-0003 decision 6's"encryptor/v1/tenant-ref"subkey, local and not replaced by GCP.:http_client- required. The host's module, described below.:goth- required. A runningGothserver's name, or{module, name}for any token server exportingfetch/1with Goth's return shape.:store- required. A one-argument function taking atenant_refand answering{:ok, rows}newest first, where a row is whatEncryptor.Provider.provision/2returned. This package owns no storage (ADR-0003 decision 9), so the read is the host's.:namespace- the key namespace carried in the binding and in every row. Defaults to"encryptor-tenant", ADR-0003 decision 5's default.:protection_level-:software(default) or:hsm. A configuration change, never a code change.:key_id_prefix- defaults to"t-".:key_id_fun- a one-argument escape hatch replacing the derivation below, with the same warningEncryptor.Provider.Functioncarries: everything the derivation guarantees becomes the host's obligation.:timeout- per call, milliseconds, default5_000. This is the bound ADR-0002's roadmap line asked for on a provider that does network I/O.
The HTTP client contract
The configured module must export request/5:
request(:post, url, headers, body, opts) ::
{:ok, %{status: non_neg_integer(), body: binary()}} | {:error, term()}where headers is a list of {name, value} string pairs and opts carries
:timeout. It is a five-line wrapper over whichever client the host already
runs, which is the point: ADR-0007 decision 9 refuses to pick between
finch, req and hackney on a host's behalf. Absence of either module at
start is {:missing_optional_dependency, module}, checked at start and not
at first use, so a misconfigured deploy fails to boot rather than failing on
a customer's first write.
The CryptoKey id
ADR-0007 decision 4. The id is an unkeyed, collision-free derivation of the selector and never the selector itself - a GCP resource name is visible in IAM policies, audit logs and every client error, and a raw tenant identifier there discloses the host's tenant list:
key_id = prefix <> Base.encode32(
:crypto.hash(:sha256, [namespace, 0, selector]),
case: :lower, padding: false
)The full digest, never truncated, because an undeletable resource that
collides is unrecoverable. Base32 lower-case because the GCP id charset
excludes = and base32 survives copy-paste and case-folding search. It is
deliberately not ADR-0003 decision 5's keyed tenant_ref: a keyed id
would rename every tenant's key on a root rotation, and GCP keys cannot be
renamed or deleted.
Provisioning
Encryptor.Provider.provision/2, reached through the vault as
MyApp.TenantVault.provision(tenant.id), creates the tenant's CryptoKey,
generates 32 bytes, wraps them, and returns everything a store needs -
keyed by tenant_ref, with the raw selector nowhere in it, and without the
plaintext.
It is not safe to call concurrently for one selector, and this package
does not make it so. Two concurrent calls both pass the create step and
both mint fresh material at the same version; whichever row loses the store
write leaves anything encrypted under the winner unreadable. This is the
race ADR-0003 open question 1 owns. Single-flight is the host's onboarding
transaction, or a unique index on (tenant_ref, version) in the store.
Resolution never provisions. Encryptor.Provider.encryption_key/2 and
Encryptor.Provider.decryption_keys/2 never call provision/2, never
call CreateCryptoKey, and answer a selector with no rows with
{:unknown_key, selector} (ADR-0003 decision 8). There is no path from a
read to a create, which matters more here than it did before: a typo'd
tenant identifier that reaches provision/2 mints a GCP CryptoKey that
will exist for the life of the project.
What it never does
- Never
CreateKeyRing. A key ring cannot be deleted. A package that created one would permanently enlarge a host's GCP project from inside a library call. The ring is the operator's Terraform, once per environment - and it is a destroy-time hazard there, not a create-time one:google_kms_key_ringaccepts a destroy and removes only the state entry, so a re-apply hitsALREADY_EXISTSon a resource no destroy can clear.prevent_destroy, or keeping the ring out of the application's state entirely, is the mitigation. - Never any IAM write. The service account needs
cloudkms.cryptoKeyVersions.useToEncryptanduseToDecrypton the ring, pluscloudkms.cryptoKeys.createif it mints. Granting itself those is a privilege-escalation surface with no upside; a deployment whose IAM is wrong fails loudly at the first call. - Never an automatic rotation schedule. GCP's automatic rotation moves the primary version and leaves existing ciphertexts decryptable under their original version, so it accumulates live versions nobody is tracking - which is not what ADR-0005's runbook means by a rotation with a verifiable end.
Two vocabularies of "version"
ADR-0007 decision 7. Conflating them is the failure this table exists to prevent:
| tenant master key version | GCP CryptoKeyVersion | |
|---|---|---|
| what it is | ADR-0003's version, one per minting of 32 fresh bytes | GCP's version of the wrapping key |
| where it lives | the host's store, and the AAD | GCP |
| rotating it | ADR-0005 R2, level 2: re-encrypt every ciphertext for the tenant | ADR-0005 R1, level 1: re-encrypt one blob per tenant per live version |
| cost | a walk over user tables | a walk over the key store |
| who walks | encryptor_ecto / the host | the key store's package |
| destroying it | deletes one wrapping (ADR-0005 P4) | DestroyCryptoKeyVersion |
They rotate on their own schedules and neither implies the other. An
operator who reads "rotate the key" and rotates the GCP CryptoKeyVersion
has done a level-1 rotation that touches no application data; one who mints
a new tenant master key version has committed to a level-2 re-encrypt.
The shred, and why it is not a function here
ADR-0007 decision 8. Destroying every CryptoKeyVersion of a tenant's
CryptoKey renders every wrapping of that tenant's master key
undecryptable including every backup copy of the store, because the
wrapping key is not in the backup. That is ADR-0005 P3 step 2a, and it runs
as the operator's own call against projects/<project>/locations/<location>/ keyRings/<ring>/cryptoKeys/t-<digest>/cryptoKeyVersions/<n>, once per live
version:
gcloud kms keys versions destroy <n> \
--location <location> --keyring <ring> --key t-<digest>This package ships no verb for it, for the reason ADR-0005 decision 10
declined to ship shred/2: the store delete is still the host's and the
destroy is still a call the host's runbook makes. ADR-0007 open question 5
leaves whether it should ever offer one open.
Two things the destroy does not do. It is not full erasure: the tenant's
permanent pseudonym, the tenant_ref, sits in every message header and
every retained backup, so P3 step 4's row deletion stays as
compliance-mandatory as ADR-0005 made it. And the scheduled destruction
window is a delay, not a reprieve to design around: a version is
DESTROY_SCHEDULED for the key's configured duration, 24 hours by default,
and restoring it works during that window. The window exists; do not rely
on it.
Records: ADR-0007 decisions 1 through 10; ADR-0002 decisions 1, 5 and 6; ADR-0003 decisions 1, 4, 5, 6 and 8; ADR-0004 decisions 3, 4 and 7; ADR-0005 decision 10 and procedure P3.
Summary
Functions
The GCP resource name of a tenant's CryptoKey, for an operator's runbook.
Every live master key for the tenant, newest first.
The tenant's current master key, unwrapped through GCP Decrypt.
Resolves and checks every option, once, at vault start.
Creates this tenant's CryptoKey, then mints and wraps its master key.
Types
@type state() :: %{ project: String.t(), location: String.t(), key_ring: String.t(), reference_subkey: binary(), http_client: module(), goth: term(), store: (String.t() -> {:ok, [Encryptor.Provider.provisioned()]} | {:error, term()}), namespace: String.t(), protection_level: :software | :hsm, key_id_prefix: String.t(), key_id_fun: (Encryptor.Provider.selector() -> String.t()) | nil, timeout: pos_integer() }
The frozen state: every option resolved and checked at start.
Functions
@spec crypto_key_name(state(), Encryptor.Provider.selector()) :: String.t()
The GCP resource name of a tenant's CryptoKey, for an operator's runbook.
Pure, and it calls nothing: ADR-0007 decision 8 leaves
DestroyCryptoKeyVersion to the host's runbook, so what this package owes
an operator running ADR-0005 P3 step 2a is the name to run it against:
projects/<project>/locations/<location>/keyRings/<ring>/cryptoKeys/t-<digest>
@spec decryption_keys(state(), Encryptor.Provider.selector()) :: {:ok, [Encryptor.Key.Aes.t(), ...]} | {:error, Encryptor.Provider.reason()}
Every live master key for the tenant, newest first.
One Decrypt per row on a cache miss; the vault's materials cache collapses
repeated resolutions to one round trip per partition per max_age
(ADR-0002 decision 2).
@spec encryption_key(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Key.Aes.t()} | {:error, Encryptor.Provider.reason()}
The tenant's current master key, unwrapped through GCP Decrypt.
The store's newest row, decrypted under the binding rebuilt from that row's
own tenant_ref, version and namespace. A row moved between tenants or
versions fails closed here rather than unwrapping silently, which is the
property ADR-0003 decision 4 bought with the encryption context and
ADR-0007 decision 5 carries into GCP's byte-string AAD.
@spec init(keyword()) :: {:ok, state()} | {:error, Encryptor.Error.reason()}
Resolves and checks every option, once, at vault start.
The transport modules are checked for presence here rather than at first use, which is ADR-0002 decision 5's at-start principle: a misconfigured deploy fails to boot rather than failing on a customer's first write.
@spec provision(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Provider.provisioned()} | {:error, Encryptor.Provider.reason()}
Creates this tenant's CryptoKey, then mints and wraps its master key.
ADR-0007 decisions 3 and 6, in order: CreateCryptoKey with the derived id,
ENCRYPT_DECRYPT and no rotation schedule; 32 bytes from the CSPRNG;
Encrypt under the four-field AAD; the row. ALREADY_EXISTS on the create
is success for that step - the id is a pure function of the selector, so an
existing key is always this tenant's - which is what makes a provision
that half-succeeded retryable.
The plaintext's whole lifetime is one function body. It is never returned, never logged and never put in the row.
Not to be confused with Encryptor.Envelope.provision/3, which wraps under
a root vault and takes a vault module as its first argument.