A wrap-provider: the scope 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 scope 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 scope 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 scoped vault
(ADR-0004 decision 4 as amended), because scope_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.ScopedVault,
provider:
{Encryptor.Provider.GcpKms,
project: "myapp-prod",
location: "us-east1",
key_ring: "encryptor-scope-keys",
reference_subkey: {:system, "ENCRYPTOR_REFERENCE_SUBKEY"},
http_client: MyApp.KmsHttp,
goth: MyApp.Goth,
store: &MyApp.ScopeKeys.live/1},
cache: [max_age: 300]: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/scope-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 ascope_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-scope", ADR-0003 decision 5's default as ADR-0009 Amendment A respells it (A1 row 5).:protection_level-:software(default) or:hsm. A configuration change, never a code change.:key_id_prefix- defaults to"s-"(ADR-0009 Amendment A, A1 row 8).: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 scope identifier there discloses the host's scope 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 scope_ref: a keyed id
would rename every scope'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.ScopedVault.provision(scope.id), creates the scope's CryptoKey,
generates 32 bytes, wraps them, and returns everything a store needs -
keyed by scope_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 (scope_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
scope 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.
What a failed Decrypt answers
ADR-0007 Amendment B. Encryptor.Provider.encryption_key/2 and
Encryptor.Provider.decryption_keys/2 unwrap through the same Decrypt
call, so they answer a failed one the same way:
the Decrypt call | reason |
|---|---|
| HTTP 400 or 404 | {:invalid_key_descriptor, {:kms_refused, status}} |
| HTTP 403, 429, 5xx or any other status | {:key_unavailable, selector} |
| the transport failed, or no token could be had | {:key_unavailable, selector} |
| a response that is not a usable answer | {:key_unavailable, selector} |
A 400 is what Cloud KMS answers when the additional authenticated data does
not match the wrapping, which is a row moved between scopes or versions; a
404 is a key or version that is not there. Retrying either changes nothing,
so neither is reported as the term a caller retries. One 400 is reversible
all the same: a CryptoKeyVersion that an operator has disabled answers
400 until it is enabled again.
A 403, an IAM denial, stays {:key_unavailable, selector}: a revoked IAM
binding is the provider-level suspension of ADR-0005 Amendment A, and a
suspension answers that term at either locus.
Encryptor.Error's message renders only the family, "the provider returned
a key descriptor this vault cannot use", because the detail of an
{:invalid_key_descriptor, detail} is never rendered. The status is in the
reason's detail and in the error's :reason field, where a case or a log
line that inspects the reason reads it.
Encryptor.Provider.provision/2 is not covered by this table: a failed
CreateCryptoKey or Encrypt there answers {:key_unavailable, selector}
whatever the status.
Two vocabularies of "version"
ADR-0007 decision 7. Conflating them is the failure this table exists to prevent:
| scope 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 scope | ADR-0005 R1, level 1: re-encrypt one blob per scope 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 scope 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 scope's
CryptoKey renders every wrapping of that scope'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/s-<digest>/cryptoKeyVersions/<n>, once per live
version:
gcloud kms keys versions destroy <n> \
--location <location> --keyring <ring> --key s-<digest>This package ships no verb for it, for the reason ADR-0005 decision 10
declined to ship shred/2: the key store is not this package's, so the
store delete is not a call it makes, 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.
The store delete, P3 steps 2 and 3, is a separate call. A host that keeps
its key rows in encryptor_ecto's Repo key store makes it through that
package's Encryptor.Ecto.KeyStore.shred/3 (version: :all), which deletes
the scope's rows from the store the vault reads and, by default, returns
only once the vault's cache can no longer serve them. It does not touch
GCP: step 2a's destroy stays the host's, beside it in the same runbook. A
host with any other store still deletes the rows itself.
Two things the destroy does not do. It is not full erasure: the scope's
permanent pseudonym, the scope_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 CryptoKey's destroyScheduledDuration, and
restoring it works during that window. The field is immutable and set only
when the key is created; provision/2 sets none, so a key it creates takes
the Cloud KMS default, 30 days. The window exists; do not rely on it.
Records: ADR-0007 decisions 1 through 10 and Amendment B; 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 scope's CryptoKey, for an operator's runbook.
Every live master key for the scope, newest first.
The scope's current master key, unwrapped through GCP Decrypt.
Resolves and checks every option, once, at vault start.
Creates this scope'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 scope'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/s-<digest>
@spec decryption_keys(state(), Encryptor.Provider.selector()) :: {:ok, [Encryptor.Key.Aes.t(), ...]} | {:error, Encryptor.Provider.reason()}
Every live master key for the scope, newest first.
One Decrypt per row, on every call. The vault's materials cache sits in
front of the CMM rather than in front of the provider, so it does not reduce
that count, whatever max_age is set to (ADR-0002 Amendment A's A1,
ADR-0007 Amendment A's A1).
@spec encryption_key(state(), Encryptor.Provider.selector()) :: {:ok, Encryptor.Key.Aes.t()} | {:error, Encryptor.Provider.reason()}
The scope's current master key, unwrapped through GCP Decrypt.
The store's newest row, decrypted under the binding rebuilt from that row's
own scope_ref, version and namespace. A row moved between scopes 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 scope'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 scope'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.