Encryptor.Provider.GcpKms (Encryptor v0.3.0)

Copy Markdown View Source

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 envelopethis provider
what holds the wrapping keya root Encryptor vaulta GCP CryptoKey
the stored bloban AWS ESDK messagea GCP KMS ciphertext
bindingencryption context (ADR-0003 decision 4)GCP additional authenticated data, same fields
unwrapEncryptor.Envelope.unwrap/2, localDecrypt, one network round trip
application ciphertextunchanged AWS ESDK messageunchanged 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 running Goth server's name, or {module, name} for any token server exporting fetch/1 with Goth's return shape.
  • :store - required. A one-argument function taking a tenant_ref and answering {:ok, rows} newest first, where a row is what Encryptor.Provider.provision/2 returned. 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 warning Encryptor.Provider.Function carries: everything the derivation guarantees becomes the host's obligation.
  • :timeout - per call, milliseconds, default 5_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_ring accepts a destroy and removes only the state entry, so a re-apply hits ALREADY_EXISTS on 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.useToEncrypt and useToDecrypt on the ring, plus cloudkms.cryptoKeys.create if 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 versionGCP CryptoKeyVersion
what it isADR-0003's version, one per minting of 32 fresh bytesGCP's version of the wrapping key
where it livesthe host's store, and the AADGCP
rotating itADR-0005 R2, level 2: re-encrypt every ciphertext for the tenantADR-0005 R1, level 1: re-encrypt one blob per tenant per live version
costa walk over user tablesa walk over the key store
who walksencryptor_ecto / the hostthe key store's package
destroying itdeletes 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

Types

The frozen state: every option resolved and checked at start.

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

state()

@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

crypto_key_name(state, selector)

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

decryption_keys(state, selector)

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

encryption_key(state, selector)

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

init(opts)

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

provision(state, selector)

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.