Encryption
A secret's material is sealed before it reaches the state store: envelope encryption.
- A secret's params are one JSON document, sealed with AES-256-GCM under a data key.
- The AAD names the row, the secret's name and its version. A sealed value copied to another secret, another version, or kept from a dropped secret of the same name does not open.
- A data key is random, wrapped by the KEK, and stored wrapped. Unwrapped ones stay in memory for
keys.cache_ttl(5 minutes), never written or logged. - A delegation grant's tokens (the user's token, the tokens minted for them) are sealed the same way, bound to the grant.
- A data key is authenticated: a tag under the KEK's root (below). One planted by whoever could write the store is refused.
- What does not open is an error, never an empty value: a tampered value, a data key another KEK
wrapped or one that is not authentic (
500 service_error); a KEK that does not answer (503).
The root
An RSA KEK wraps with its public key: anyone who has it can make a data key that unwraps, and seal material of their choice under it. So a data key that unwraps proves nothing. The service derives a root that only the KEK's holder can compute, and tags every data key with it:
| KEK | The root |
|---|---|
| Key Vault RSA | a deterministic signature (RS256) of a fixed label, made in the vault |
| Managed HSM AES | a deterministic wrap (A256KW) of a fixed label |
| local | HMAC-SHA256 of a fixed label under the key |
| OpenBao / Vault Transit | a Transit HMAC of a fixed label, at the KEK's version |
| AWS KMS | a KMS MAC (GenerateMac) of a fixed label under the HMAC key, mac_key (AWS) |
| GCP Cloud KMS | a Cloud KMS MAC (MacSign) of a fixed label under the MAC key's version, mac_key (GCP) |
- The root is never stored; it stays in memory for
keys.cache_ttl, as the data keys do. - A data key's tag is an HMAC under the root over its id, its KEK id, its creation time and its wrapped bytes: no one who writes the store keeps a data key active past its age.
- A data key tagged by the first version of this (no creation time in the tag) still opens, but a new one
is made for new values;
rewraptags it anew. - A data key whose tag does not match is refused, on every store.
- Upgrading from a version before this: its data keys have no tag, and every value sealed under them is
refused (
500) until they are tagged: the KEK'ssign, thentresor-server rewrap -tag-untaggedonce - see Upgrading. - On the Kubernetes store, what is not sealed (a secret's grants, a delegation grant's user) carries a
MAC under a key derived from a data key: only the KEK's holder can make one that verifies. A
rewrapkeeps the data keys, so it keeps every MAC. - A routine
rewrap(after a rotation) never tags a data key with no tag: it skips and names it, for a key planted since would carry none either. It never tags a data key whose tag does not match.
The KEK
local
keys: {kind: local, key_env: TRESOR_KEK} # or key_file: /run/secrets/kek
32 bytes, base64 (openssl rand -base64 32). Data keys are wrapped with AES key wrap (RFC 3394). For
development, tests and small installs: the key is a static secret.
azurekeyvault
keys: {kind: azurekeyvault, key: https://corp-kv.vault.azure.net/keys/tresor-kek}
azure: {identity: managed}
- A key in Azure Key Vault or Managed HSM. The KEK never leaves it: the service asks it to wrap and unwrap.
RSA-OAEP-256for an RSA key (either service);A256KWfor an AES key on Managed HSM.- The service's identity needs get, wrap, unwrap and sign on the key - sign makes the root. No built-in role gives exactly these four: the recipes make a custom role, on the one key. On Managed HSM (an AES key), Managed HSM Crypto Service Encryption User is enough: the root is a wrap.
- The vault on the RBAC permission model, with purge protection: the material depends on the key.
- The key is named without a version. New data keys are wrapped under its current version; each data key records the version it was wrapped under.
- The current version is read at most once a minute. While the vault does not answer, the last one read serves for up to an hour: a seal needs no vault when its data key is in memory.
vault: OpenBao or HashiCorp Vault Transit
keys: {kind: vault, mount: transit, key: tresor-kek}
vault:
address: https://bao.example.eu:8200
auth: {method: kubernetes, role: tresor-server} # kubernetes | jwt | token_file
-
The key: an
aes256-gcm96(orchacha20-poly1305) Transit key, not derived. The KEK never leaves Vault; the service asks it to encrypt and decrypt data keys. Mounts are one path segment (transit, notteam/transit). -
The KEK id is
vault:<mount>/<key>:v<N>, the version the ciphertext names. -
The root is Transit's HMAC of a fixed label at that version. Every Transit key has an HMAC key per version. Whoever holds
hmacon the key can compute the root, so:- give
hmacon it to the service alone; - never make it
exportableorallow_plaintext_backup(the service refuses such a key); - never set
min_encryption_version: it refuses the HMAC at older versions (the service refuses that too). Retire versions withmin_decryption_version.
- give
-
The service's policy, on that one key:
path "transit/encrypt/tresor-kek" { capabilities = ["update"] }path "transit/decrypt/tresor-kek" { capabilities = ["update"] }path "transit/hmac/tresor-kek/sha2-256" { capabilities = ["update"] }path "transit/keys/tresor-kek" { capabilities = ["read"] } -
Rotation: rotate the key,
tresor-server rewrap, then raisemin_decryption_version- see The KEK. -
The login has no static secret:
kubernetes: the pod's ServiceAccount token, against Vault's Kubernetes auth method;jwt: a projected token (jwt_file), against its JWT auth method;token_file: a token a Vault Agent writes and renews.
-
The token: a login's is replaced at two thirds of its lease; when that login fails, the old token serves until its own end. A 403 with a live token (the policy's refusal) costs no login. A redirect from Vault is never followed: it would carry the token and a data key elsewhere. Give the role short-lived tokens (
token_type=batchsuits).
Rotation
A new version of the KEK (in the vault, by hand or a rotation policy) is seen within a minute; new data keys are
wrapped under it, and old values still open: their data keys name the old version. tresor-server rewrap wraps
every data key under the current version; the old versions can then be retired. No sealed value is touched. The
runbook, per kind: The KEK.
Data keys also rotate by age: one older than keys.data_key_max_age (30 days) is replaced for new values.
Resealing, and retiring data keys
A data key's rotation moves new writes only: a value written once stays under the data key of its day, and
every data key stays stored. tresor-server reseal (spec 018) moves the rows to the active data key, -rotate
makes a new one first (a data key leaked), and -retire deletes the data keys nothing uses any more.
- Not a write: each value is opened and sealed again under the same binding, its MAC made anew; its version, its times and its ETag stay.
- Only what verifies moves: a row whose MAC does not verify, or (a SQL store) has none yet, is named and left.
- Minted tokens on a SQL store stay under their data key until their delegation grant goes; the Kubernetes store moves them too.
-retiredeletes a data key that is not the active one, that no row uses, and that has settled: an older key once the active one is older thankeys.cache_ttlplus a minute (Data keys). A row under a deleted data key is refused, never read as empty.- Backups keep their own data keys.
The runbooks, a leaked data key among them: Data keys.
Moving to another KEK
From a local key to Key Vault, Vault or a KMS, from one key to another: the old KEK is listed under
keys.previous (read only: data keys under it still unwrap, nothing new is wrapped with it), tresor-server rewrap moves the data keys under the new one, and keys.previous is removed (spec 011). The values stay as they
are, and the service serves throughout. Moving between two Vault servers is not supported: one vault: client
serves every vault KEK. The runbook: The KEK.
keys:
kind: azurekeyvault
key: https://corp-kv.vault.azure.net/keys/tresor-kek
previous:
- {kind: local, key_file: /var/run/tresor/kek-old/kek}
- A data key opens only with the KEK that owns its id - never by trying each. A data key left under a KEK that
is no longer configured is refused (
500), never read as empty. - The same KEK as
keysand inkeys.previousis refused at start. - Readiness checks each previous KEK too, and the console's Service screen shows them.
- Until
keys.previousis removed the old KEK is trusted as the current one is:rewrapcarries over every data key authentic under it, its tag made under the new KEK's root. - On the chart,
localKEK.previousSecretNamemounts the old local KEK and lists it inkeys.previous.