Skip to main content
Version: 0.8.1

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:

KEKThe root
Key Vault RSAa deterministic signature (RS256) of a fixed label, made in the vault
Managed HSM AESa deterministic wrap (A256KW) of a fixed label
localHMAC-SHA256 of a fixed label under the key
OpenBao / Vault Transita Transit HMAC of a fixed label, at the KEK's version
AWS KMSa KMS MAC (GenerateMac) of a fixed label under the HMAC key, mac_key (AWS)
GCP Cloud KMSa 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; rewrap tags 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's sign, then tresor-server rewrap -tag-untagged once - 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 rewrap keeps 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-256 for an RSA key (either service); A256KW for 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 (or chacha20-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, not team/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 hmac on the key can compute the root, so:

    • give hmac on it to the service alone;
    • never make it exportable or allow_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 with min_decryption_version.
  • 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 raise min_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=batch suits).

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.
  • -retire deletes 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 than keys.cache_ttl plus 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 keys and in keys.previous is refused at start.
  • Readiness checks each previous KEK too, and the console's Service screen shows them.
  • Until keys.previous is removed the old KEK is trusted as the current one is: rewrap carries over every data key authentic under it, its tag made under the new KEK's root.
  • On the chart, localKEK.previousSecretName mounts the old local KEK and lists it in keys.previous.