KMS backends
forze[kms-aws], forze[kms-gcp], and forze[kms-yc] each supply a
KeyManagementPort backed by a managed key service, so the key-encryption key
never leaves the KMS — reach for one when you want
envelope encryption without running Vault.
For deployments with no cloud at all, forze_kms.local supplies the same port
over operator-provided master keys, with no extra to install — see
Self-hosted keys.
Install¶
uv add 'forze[kms-aws]'
uv add 'forze[kms-gcp]'
uv add 'forze[kms-yc]'
Settings¶
Each cloud KMS ships its own: AwsKmsSettings (region, optional endpoint override,
optional static credentials, refusing a half-set pair), GcpKmsSettings (emulator endpoint
and timeout — credentials are ambient) and YcKmsSettings (an IAM or OAuth token, never
both). See connection settings.
Wire it¶
Build the client and register its deps module — that publishes the client so the
lifecycle step can open it. CryptoDepsModule composes the keyring over the
adapter and registers KeyManagementDepKey itself:
from forze.application.contracts.crypto import KeyRef, StaticKeyDirectory
from forze.application.execution import CryptoDepsModule, DepsRegistry, LifecyclePlan
from forze_kms.aws import (
AwsKmsClient,
AwsKmsDepsModule,
AwsKmsKeyManagement,
awskms_lifecycle_step,
)
kms = AwsKmsClient()
deps = DepsRegistry.from_modules(
AwsKmsDepsModule(client=kms),
CryptoDepsModule(
kms=AwsKmsKeyManagement(client=kms),
directory=StaticKeyDirectory(KeyRef(key_id="alias/app-kek")),
),
)
lifecycle = LifecyclePlan.from_steps(awskms_lifecycle_step(region_name="eu-central-1"))
from forze.application.contracts.crypto import KeyRef, StaticKeyDirectory
from forze.application.execution import CryptoDepsModule, DepsRegistry, LifecyclePlan
from forze_kms.gcp import (
GcpKmsClient,
GcpKmsDepsModule,
GcpKmsKeyManagement,
gcpkms_lifecycle_step,
)
kms = GcpKmsClient()
key = "projects/acme/locations/europe-west1/keyRings/app/cryptoKeys/app-kek"
deps = DepsRegistry.from_modules(
GcpKmsDepsModule(client=kms),
CryptoDepsModule(
kms=GcpKmsKeyManagement(client=kms),
directory=StaticKeyDirectory(KeyRef(key_id=key)),
),
)
lifecycle = LifecyclePlan.from_steps(gcpkms_lifecycle_step())
from forze.application.contracts.crypto import KeyRef, StaticKeyDirectory
from forze.application.execution import CryptoDepsModule, DepsRegistry, LifecyclePlan
from forze_kms.yc import (
YcKmsClient,
YcKmsDepsModule,
YcKmsKeyManagement,
yckms_lifecycle_step,
)
kms = YcKmsClient()
deps = DepsRegistry.from_modules(
YcKmsDepsModule(client=kms),
CryptoDepsModule(
kms=YcKmsKeyManagement(client=kms),
directory=StaticKeyDirectory(KeyRef(key_id="abjq…")),
),
)
lifecycle = LifecyclePlan.from_steps(yckms_lifecycle_step())
Leave key_management unset on the KMS deps module: CryptoDepsModule already
registers the port, and registering it twice is a conflicting dependency.
Per-tenant keys¶
Give each tenant its own KEK, and create it when the tenant is onboarded. A provisioner resolves through the same directory the keyring encrypts through, so the provisioned key and the encrypt-path key can never drift:
from forze.application.contracts.crypto import TenantTemplateKeyDirectory
from forze_kms.aws import AwsKmsTenantProvisioner
# KMS mints the CMK id, so a tenant's key is addressed by a caller-chosen alias.
directory = TenantTemplateKeyDirectory(
template="alias/tenant-{tenant_id}",
default_key_id="alias/shared-kek",
)
provisioner = AwsKmsTenantProvisioner(client=kms, directory=directory)
from forze.application.contracts.crypto import TenantTemplateKeyDirectory
from forze_kms.gcp import GcpKmsTenantProvisioner
ring = "projects/acme/locations/europe-west1/keyRings/app"
directory = TenantTemplateKeyDirectory(
template=f"{ring}/cryptoKeys/tenant-{{tenant_id}}",
default_key_id=f"{ring}/cryptoKeys/shared-kek",
)
# The key ring is shared and long-lived; only the CryptoKey is per-tenant.
provisioner = GcpKmsTenantProvisioner(client=kms, directory=directory)
from forze_kms.yc import YcKmsKeyDirectory, YcKmsTenantProvisioner
# Yandex Cloud mints the key id, so a template cannot address a tenant's key —
# this directory looks it up by the name the provisioner creates.
directory = YcKmsKeyDirectory(client=kms, folder_id="b1g…", template="tenant-{tenant_id}")
provisioner = YcKmsTenantProvisioner(client=kms, directory=directory)
Pass the provisioner to TenancyDepsModule(tenant_provisioner=…) — alongside a
schema or bucket provisioner via CompositeTenantProvisioner — so onboarding a
tenant readies every backend at once. Provisioning is idempotent, so a retried
onboarding is safe.
Self-hosted keys (no cloud)¶
LocalKeyManagement implements the same port entirely in process: it wraps data
keys under raw 32-byte master keys you supply, with AES-256-GCM. It needs no
extra — the cryptography library is already a core dependency — and no client,
deps module, or lifecycle step:
from forze.application.contracts.crypto import KeyRef, StaticKeyDirectory
from forze.application.execution import CryptoDepsModule
from forze_kms.local import LocalKeyManagement
CryptoDepsModule(
kms=LocalKeyManagement({"k1": load_master_key()}), # key id → raw 32 bytes
directory=StaticKeyDirectory(KeyRef(key_id="k1")),
)
Know the trust model. The master keys live in process memory and in your
configuration — there is no HSM, no non-exportability, no backend audit log.
Compromise of the host or the config is compromise of the keys. That is the
honest boundary of a self-hosted deployment; if you need keys that never leave a
service, use one of the cloud backends or Vault above. How the raw bytes get to
the process (env var, file mount, secret manager) is your application's choice,
same as deterministic_root.
No wrap-count rotation cadence. AES-GCM with random nonces is only safe for about 2^32 encryptions under one key, and DEKs are minted per stream, TTL, and tenant — so a busy fleet could plausibly approach that ceiling under one long-lived master key. Each wrap therefore seals under a one-shot HKDF subkey (a fresh random salt stored in the envelope), so the ceiling never accrues against the master key: rotate on policy and on suspicion of compromise, not on volume. Envelopes sealed by earlier builds remain readable.
Replacing a key uses the standard previous-key overlap, with one extra rule: the outgoing key stays in the map until the sweep is done — the directory widens what reads accept; the map is what can unwrap:
CryptoDepsModule(
kms=LocalKeyManagement({"k2": new_key, "k1": old_key}), # old key still unwraps
directory=StaticKeyDirectory(
KeyRef(key_id="k2"), # new writes seal here
previous_key_ref=KeyRef(key_id="k1"), # old reads still accepted
),
)
Run the re-encryption sweeps
(or let natural rewrites drain the old key), then drop k1 from both places in
the same deploy. Dropping it from the map too early fails closed with an error
telling you to put it back.
Fleets work — same keys, every replica. "Local" means the wrap is computed
in process, not that the deployment is single-node: any replica holding the same
key map can open any envelope, so a fleet just injects the same secret into
every process (a mounted Secret, env injection — however you already ship
deterministic_root). Two rules follow. Rotate in two phases: first roll out
the new key to every replica's map (directory unchanged), then flip
key_ref in a second rollout — otherwise a mid-rollout replica that already
seals under the new key writes envelopes its not-yet-updated peers cannot open.
And every config holder is a key holder — there is no central place that
audits or revokes access.
Drift between replicas is fail-closed but worth catching early: each instance
logs its key ids and a one-way fingerprint of the key map at construction —
compare that value across the fleet (grep startup logs, or attach it to your
own metrics) and any divergence is a ten-second diagnosis. In particular,
fleet-wide core.crypto.aead_auth_failed right after a deploy usually means
two replicas hold different bytes under the same key id — differing
fingerprints confirm it; envelopes sealed by the odd node out are fine once its
config is corrected. If you need keys that never leave one service, that
property is exactly what requires a networked unwrap: use
Vault Transit (or OpenBao, which the suite is verified against) or
a cloud backend above.
Moving to a cloud backend later changes wiring only — envelopes and call sites are identical across backends — but envelopes wrapped under a local master key still have to be re-encrypted under the new backend's key: an overlap window plus a sweep, the same procedure as replacing any key.
What it provides¶
| Contract | Implementation | Dep key |
|---|---|---|
| Key management (envelope encryption) | AwsKmsKeyManagement · GcpKmsKeyManagement · YcKmsKeyManagement · LocalKeyManagement |
KeyManagementDepKey (registered by CryptoDepsModule) |
| Per-tenant KEK provisioning | AwsKmsTenantProvisioner · GcpKmsTenantProvisioner · YcKmsTenantProvisioner |
via TenantProvisionerPort |
| Key directory (Yandex Cloud only) | YcKmsKeyDirectory |
passed to CryptoDepsModule(directory=…) |
| Raw client | AwsKmsClient · GcpKmsClient · YcKmsClient |
AwsKmsClientDepKey · GcpKmsClientDepKey · YcKmsClientDepKey |
What a KeyRef.key_id names, per provider:
| Provider | Extra | key_id |
Credentials when unset |
|---|---|---|---|
| AWS | forze[kms-aws] |
a CMK id, ARN, or alias/<name> |
the botocore chain (env, profile, instance role) |
| Google Cloud | forze[kms-gcp] |
a CryptoKey resource name (projects/…/cryptoKeys/…) |
application-default credentials |
| Yandex Cloud | forze[kms-yc] |
a symmetric key id | the instance metadata service |
| Self-hosted | none needed | an operator-chosen name keying the master-key map | — (keys are supplied directly) |
Notes¶
- Credentials are implicit by default — each lifecycle step falls back to its
platform's ambient chain (the table above). Pass them explicitly when you must:
access_key_id/secret_access_keyonawskms_lifecycle_step,credentialsongcpkms_lifecycle_step, andiam_token/oauth_token/service_account_keyonyckms_lifecycle_step. - Rotation is transparent. A wrapped data key is decryptable by the KMS without
being told which version sealed it, so rotating the KEK never orphans data: new
writes wrap under the new version while old ciphertext still decrypts. Nothing to
migrate, no re-encrypt sweep — see Searchable fields and rotation
for the one case that does need a re-index. Pointing a tenant at a
different
key_idis a migration, not a rotation — it needs a previous-key read overlap and a re-encrypt sweep; see Replacing a key. The self-hosted backend has no key versions at all: rotating it is replacing the key (above). - Data-key length is
dek_byteson the adapter — 32 bytes (AES-256) by default, matching the keyring's AEAD; 16 selects AES-128. - Teardown is opt-in and never immediate.
deprovisiondoes nothing unless you setallow_deletion=True— destroying a KEK makes every value wrapped under it unrecoverable. Even then the platform protects you: AWS drops the alias and schedules the CMK for deletion afterpending_window_days(7–30, so you can cancel); Google Cloud cannot delete a CryptoKey at all, so the provisioner destroys its versions and the empty key resource remains; Yandex Cloud deletes the key outright. - Google Cloud KMS has no data-key API. The adapter mints the data key itself
from the framework's CSPRNG entropy seam and wraps it with
Encrypt; AWS and Yandex Cloud use their nativeGenerateDataKey. The envelope is identical either way. - The Yandex Cloud SDK is blocking. Calls are driven off the event loop, so a KMS round-trip never stalls the runtime.
- Each client needs its lifecycle step — the deps module only registers an already-constructed client; it doesn't open it.