Skip to content

HSM-backed secret encryption (envelope encryption)

secsy-pki can encrypt passwords and other small secrets under a key that lives in a hardware security module (HSM). The private key material never leaves the HSM; decrypting a secret requires access to the device.

How it works — envelope encryption

Each secret is protected with the standard envelope pattern used by cloud KMS systems:

  1. A fresh 256-bit data-encryption key (DEK) is generated per message.
  2. The plaintext is sealed with AES-256-GCM under the DEK.
  3. The DEK is wrapped (encrypted) with a long-lived key-encryption key (KEK) — an RSA key whose private half is held by the HSM (PKCS#11) or the software keystore. Wrapping uses RSA-OAEP against the KEK public key and needs no HSM. Unwrapping asks the token to RSA-OAEP-decrypt the wrapped DEK via C_Decrypt, so the KEK private key never leaves the device.

The output is a versioned, self-describing JSON envelope carrying everything needed to decrypt except the KEK private key and any caller-supplied encryption context.

plaintext ──AES-256-GCM(DEK)──► ciphertext
   DEK    ──RSA-OAEP(KEK_pub)──► wrapped_dek     (unwrap runs on the HSM)

Ciphertext format (version 1)

{
  "version": 1,
  "provider": "pkcs11",
  "kek_label": "secsy-kek",
  "kek_uri": "pkcs11:token=...;object=secsy-kek;type=private",
  "wrap_alg": "RSA-OAEP-SHA256",
  "data_alg": "AES-256-GCM",
  "wrapped_dek": "<base64>",
  "nonce": "<base64>",
  "ciphertext": "<base64 ciphertext||GCM-tag>",
  "context_bound": false
}

The version field is checked on decrypt; unknown versions are rejected rather than mis-parsed, so the format can evolve safely.

Security properties

  • Confidentiality + integrity of the plaintext come from AES-256-GCM.
  • Algorithm & metadata binding: the header (version, both algorithm names, KEK label, and any caller context) is bound into the GCM additional authenticated data (AAD). An attacker cannot swap algorithms, repoint the record at a different KEK, or replay it under a different context without GCM detecting the change.
  • Encryption context (optional, KMS-style): a caller-supplied byte string mixed into the AAD. It is not stored in the envelope and must be supplied verbatim to decrypt — useful for binding a ciphertext to, e.g., a tenant or field name.
  • OAEP only: PKCS#1 v1.5 decryption is never used, avoiding the Bleichenbacher padding-oracle class of attacks. Decrypt failures return a single generic error so the API does not behave as an oracle.
  • Least-privilege KEK: on a PKCS#11 token the KEK private key is generated with CKA_DECRYPT/CKA_UNWRAP only (not CKA_SIGN), marked sensitive and non-extractable.
  • Fresh DEK + nonce per message: no key or nonce reuse across messages.

A note on SoftHSM and SHA-1

SoftHSM 2.6.x supports RSA-OAEP only with SHA-1, while production HSMs (YubiHSM) and the software backend support SHA-256. The service therefore negotiates the strongest OAEP hash the KEK can actually unwrap with (SHA-256 first, SHA-1 fallback) and records the chosen algorithm (wrap_alg) in every envelope, so old ciphertext stays decryptable. OAEP's security does not rely on the collision resistance of its hash, so the SHA-1 fallback does not expose the scheme to SHA-1 collision attacks — but prefer a SHA-256-capable HSM in production.

Post-quantum hybrid mode (ML-KEM-1024, harvest-now-decrypt-later resistance)

A future adversary with a large quantum computer can record ciphertext today and decrypt it later once RSA is broken ("harvest now, decrypt later"). To resist that for data at rest, an optional hybrid mode protects each envelope's data key with both the classical HSM KEK wrap and an ML-KEM-1024 (FIPS 203, crypto/mlkem) encapsulation, combined through a KDF so an attacker must defeat both primitives:

ssC              = a fresh 256-bit classical shared secret
WrappedDEK (env) = RSA-OAEP(KEK_pub, ssC)            # HSM unwraps ssC
(ssPQ, kem_ct)   = ML-KEM-1024.Encapsulate()          # kem_ct stored in the PQC block
wk               = HKDF-SHA256(ssC ‖ ssPQ, info)      # 256-bit wrapping key
PQC.wrapped_dek  = AES-256-GCM(wk, DEK)               # the real DEK, sealed under wk

Recovering the DEK needs ssC (only the HSM can unwrap it) and ssPQ (only the ML-KEM decapsulation key produces it). Breaking RSA alone yields ssC but not ssPQ; breaking ML-KEM alone yields ssPQ but not ssC. A quantum adversary who harvests an envelope and later breaks its RSA still faces ML-KEM-1024 to obtain ssPQ.

  • HSM stays in the trust path. The ML-KEM decapsulation key is stored only as a 64-byte seed sealed under the classical HSM KEK (RSA-OAEP), so decapsulating any envelope requires an on-token unwrap. The sealed key lives once in the key store, never copied into each ciphertext, so it is not part of a harvested envelope.
  • Threat-model boundary (honest). Because that sealed decapsulation key is itself protected only by the classical KEK, the harvest-now-decrypt-later guarantee holds for a harvest of the ciphertext (envelopes). An adversary who also exfiltrates the single sealed decapsulation-key blob from the key store and later breaks RSA degrades back to classical security. This is the necessary consequence of keeping a classical HSM in the trust path (no shipping HSM performs ML-KEM); the win is real for the common case where ciphertext is stored/distributed far more widely than the central key store. Restrict and separately protect the pqc_hybrid_keys material for the strongest posture.
  • Software ML-KEM, HSM RSA. SoftHSM/PKCS#11 tokens have no ML-KEM mechanism, so (following the Task 29 ML-DSA precedent) the ML-KEM operations run in software while the classical KEK may still live in the HSM.
  • Versioned & backward compatible. Hybrid envelopes are format version 3 and carry a pqc block; classical version 1/2 envelopes open unchanged. Disabling the flag never strands hybrid ciphertext — the material stays available for decryption.
  • Downgrade-resistant. The pqc block is bound into the GCM AAD and the DEK is committed, so the post-quantum layer cannot be stripped, tampered with, or swapped to force a weaker classical-only decryption.
  • FIPS. ML-KEM-1024 is a FIPS 203 algorithm inside the Go Cryptographic Module, so hybrid mode is FIPS-approvable (unlike CIRCL ML-DSA). Under security.fips the only added constraint is that the classical RSA-OAEP wrap use SHA-256 (no SoftHSM SHA-1 fallback).

Enable it with secret.pqc_hybrid: true and provision ML-KEM material per KEK family (init-kek does this automatically when the flag is on; use pqc-enable for an existing family). Rotating the classical KEK re-wraps only the classical shared secret; run pqc-reseal to move the sealed ML-KEM key onto a newer KEK version before retiring the version that seals it.

# Enable on a fresh KEK (init-kek provisions the ML-KEM material too):
$ secsy-secret init-kek                       # with secret.pqc_hybrid: true

# Or enable on an existing KEK family:
$ secsy-secret pqc-enable

# Inspect the material (metadata only; works with the HSM absent):
$ secsy-secret pqc-info

# After a classical rotate-kek, re-seal the ML-KEM decap key under the new version:
$ secsy-secret pqc-reseal

Scope: the hybrid layer covers the secret envelope (encrypt/decrypt, stored secrets, KEK rotation/rewrap, and PKCS#12 escrow envelopes). Scheduled encrypted backups (Task 89/94) keep using the classical KEK wrap and are unaffected.

Configuration

secret:
  kek_label: "secsy-kek"      # RSA KEK in the configured key_provider
  pqc_hybrid: false           # true → also protect data keys with ML-KEM-1024 (Task 137)
  version_history_depth: 0    # retained value versions per named secret; 0 = unlimited
  transforms: []              # format-preserving encryption / tokenization templates (see below)

SECSY_SECRET_KEK_LABEL overrides the KEK label and SECSY_SECRET_PQC_HYBRID=1 the hybrid flag via the environment. When kek_label is empty, the /api/secret/* endpoints are disabled.

CLI — secsy-secret

# One-time: create the KEK on the configured provider (HSM or software)
$ secsy-secret -config config.yaml init-kek            # defaults to rsa-4096
$ secsy-secret -config config.yaml init-kek -key-type rsa-2048

# Encrypt (stdin → envelope on stdout)
$ printf 'my-db-password' | secsy-secret encrypt -out secret.json

# Decrypt (envelope → plaintext)
$ secsy-secret decrypt -in secret.json

# With an encryption context (must match on decrypt)
$ printf 'pw' | secsy-secret encrypt -context 'tenant=acme' -out s.json
$ secsy-secret decrypt -in s.json -context 'tenant=acme'

# Inspect the configured KEK
$ secsy-secret kek-info

# Named secrets with a value history (see "Named secrets" below)
$ printf 'pw' | secsy-secret put -name db-password -ttl-days 90 -rotate-every-days 30
$ secsy-secret list-secrets                      # registry, with labels
$ secsy-secret versions -name db-password        # value history, newest first
$ secsy-secret get -name db-password -version 1  # read a retained older value
$ secsy-secret rollback -name db-password -version 1
$ secsy-secret lifecycle                         # what is due for TTL/rotation
$ secsy-secret exec -secret db-password=DB_PASS -- ./myapp

# KEK rotation lifecycle (see "KEK rotation and DEK re-wrap" below)
$ secsy-secret kek-versions                      # lineage + per-version secret counts
$ secsy-secret rotate-kek                        # generate the next version, make it active
$ secsy-secret rewrap -all                       # migrate stored secrets onto it
$ secsy-secret retire-kek -version 1             # withdraw the drained version

# Named HSM-backed signing keys (sign / verify over arbitrary data)
$ secsy-secret signing-key create -name app-signer -algorithm ecdsa-p256
$ secsy-secret signing-key list
$ printf 'data' | secsy-secret sign   -key app-signer -out sig.bin
$ printf 'data' | secsy-secret verify -key app-signer -sig-in sig.bin

# Format-preserving encryption / tokenization (FF1)
$ secsy-secret transform encode -template pan -value 4111-1111-1111-1111
$ secsy-secret transform decode -template pan -value 4923-8471-2210-6634

-kek <label> overrides the configured KEK on any subcommand.

HTTP API

All endpoints require authentication and are only registered when a KEK is configured.

Method & path Purpose
GET /api/secret/info KEK metadata (label, bits, algorithms)
POST /api/secret/encrypt Encrypt a secret into an envelope
POST /api/secret/decrypt Decrypt an envelope back to plaintext
POST /api/secret/datakey Mint a data key (plaintext + KEK-wrapped)
POST /api/secret/hmac Compute a keyed HMAC over caller data
POST /api/secret/hmac/verify Verify a keyed HMAC (constant time)
POST /api/secret/random CSPRNG bytes (HSM RNG when available)
POST /api/secret/signing-keys Create a named HSM-backed signing key
GET /api/secret/signing-keys List named signing keys
GET /api/secret/signing-keys/{name} Get a key's public view (SPKI PEM/DER)
POST /api/secret/signing-keys/{name}/sign Sign data (raw digital signature)
POST /api/secret/signing-keys/{name}/verify Verify a signature
POST /api/secret/transform/encode Format-preserving encrypt / tokenize
POST /api/secret/transform/decode Invert a transform (detokenize)
POST /api/secret/store Encrypt and store a named secret (version 1)
GET /api/secret/store List stored secrets (?name= resolves one)
GET /api/secret/store/{id} Get a stored secret including its envelope
PUT /api/secret/store/{id} Write a new value version
DELETE /api/secret/store/{id} Delete a secret and its whole history
GET /api/secret/store/{id}/versions List the retained value history
GET /api/secret/store/{id}/versions/{version} Read one historical version
POST /api/secret/store/{id}/rollback Make an older value current again
GET /api/secret/lifecycle Secrets due for TTL or rotation attention
GET /api/secret/kek/status KEK rotation posture (lineage + secrets per version)
POST /api/secret/kek/rotate Generate the next KEK version and make it active
POST /api/secret/rewrap Re-wrap stored secrets onto the active version
POST /api/secret/kek/retire Withdraw a superseded KEK version

encrypt request / response:

// POST /api/secret/encrypt
{ "plaintext": "<base64>", "context": "<base64, optional>" }
// 200 OK
{ "envelope": { /* version-1 envelope object */ } }

decrypt request / response:

// POST /api/secret/decrypt
{ "envelope": { /* envelope object */ }, "context": "<base64, optional>" }
// 200 OK
{ "plaintext": "<base64>" }

Plaintext is capped at 64 KiB — this feature is for passwords and small secrets, not bulk data.

Stateless crypto service (data key, keyed HMAC, random)

Alongside encrypt/decrypt, the secret layer offers a non-storing crypto service — an "encryption as a service" surface modelled on HashiCorp Vault Transit. Nothing the caller submits is persisted; the server holds only keys. All three operations are exposed over REST, gRPC (SecretService), the secsy-secret CLI, and the operator console — the Crypto service panel on the Secrets page mints data keys (with an optional context/AAD binding), generates and verifies keyed HMACs, and draws random bytes — and each authorizes its own tenant-scoped capability (secret:datakey, secret:hmac, secret:random), audits, and meters.

Data keys — high-volume client-side envelope encryption

datakey mints a fresh key and returns it both in the clear (for immediate client-side use) and wrapped under the family KEK. The wrapped form is an ordinary envelope: decrypt it later to recover the key. No plaintext key is ever stored, so a client can seal unlimited data locally and keep only the wrapped key next to its ciphertext.

// POST /api/secret/datakey   { "bits": 256, "context": "<b64?>", "wrapped_only": false }
// 200 OK
{ "plaintext": "<b64 key>", "wrapped": { /* envelope */ }, "bits": 256,
  "kek_label": "secsy-kek", "kek_version": 1 }
// Recover later:  POST /api/secret/decrypt { "envelope": <wrapped> }  → the same key

bits is 128, 256 (default), or 512; wrapped_only omits the plaintext.

$ secsy-secret datakey -bits 256 -json          # plaintext + wrapped
$ secsy-secret datakey -wrapped-only -out dk.json

Keyed HMAC — authenticate caller data

hmac computes an HMAC-SHA256 over caller data with a versioned MAC key derived (HKDF) from a random seed that is itself sealed under the KEK — so the MAC key never exists at rest and recovering it is an on-HSM operation. The seed is provisioned on first use and re-derived per request; the returned version identifies it so hmac/verify is unambiguous. Rotating the KEK does not invalidate existing tags (the sealed seed re-wraps like any DEK).

// POST /api/secret/hmac         { "data": "<b64>" }
// 200 OK                        { "hmac": "<b64>", "version": 1, "algorithm": "HMAC-SHA256" }
// POST /api/secret/hmac/verify  { "data": "<b64>", "hmac": "<b64>", "version": 1 }
// 200 OK                        { "valid": true, "version": 1 }

An unknown version or a mismatch is a valid:false result, not an error; the comparison is constant-time.

$ printf 'payload' | secsy-secret hmac                 # prints the base64 tag
$ printf 'payload' | secsy-secret hmac-verify -hmac <tag>   # exits non-zero on mismatch

Random bytes — CSPRNG from the HSM

random returns cryptographically-strong bytes, drawn from the HSM RNG (C_GenerateRandom) when the backend supports it, otherwise the OS CSPRNG. The response reports which via source (hsm | software).

// POST /api/secret/random   { "bytes": 32, "format": "base64" }   // format: base64 | hex
// 200 OK                    { "random": "<encoded>", "format": "base64", "bytes": 32, "source": "hsm" }
$ secsy-secret random -bytes 32 -format hex

bytes is capped at 1024. Data-key mints and HMAC operations count against the tenant's daily secret-op quota; random draws do not (the byte cap bounds abuse).

Digital signatures (named signing keys, sign / verify)

Beyond the symmetric primitives above, the secret layer offers asymmetric digital signatures — the sign/verify counterpart to Vault Transit — over named, HSM-backed signing keys. A signing key is a real key pair whose private half is generated non-extractable inside the key provider (the HSM under PKCS#11); only the public half is exported and persisted. Signatures are raw, application-level signatures over arbitrary data — deliberately distinct from the CMS/X.509 artifact-signing service, which produces structured signature containers. Everything here is exposed over REST, gRPC (SecretService: CreateSigningKey, ListSigningKeys, GetSigningKey, Sign, Verify), and the secsy-secret CLI.

The algorithm is fixed when the key is created — the curve or RSA modulus and, for RSA, the PSS-vs-PKCS#1-v1.5 scheme:

Algorithm Key Scheme
ecdsa-p256 NIST P-256 ECDSA
ecdsa-p384 NIST P-384 ECDSA
ecdsa-p521 NIST P-521 ECDSA
ed25519 Edwards25519 Ed25519 (EdDSA)
rsa-pss-2048 RSA 2048 RSASSA-PSS
rsa-pss-3072 RSA 3072 RSASSA-PSS
rsa-pss-4096 RSA 4096 RSASSA-PSS
rsa-pkcs1v15-2048 RSA 2048 RSASSA-PKCS1-v1_5
rsa-pkcs1v15-3072 RSA 3072 RSASSA-PKCS1-v1_5
rsa-pkcs1v15-4096 RSA 4096 RSASSA-PKCS1-v1_5

For ECDSA and RSA the message hash (sha256 | sha384 | sha512) is chosen per signing request (empty selects the algorithm's default), and a caller may sign either a message (hashed server-side) or a pre-computed digest (signed verbatim, its length checked against the hash). RSASSA-PSS uses a salt length equal to the hash length — the interoperable choice, so signatures verify against openssl and JOSE libraries as well as this service. Ed25519 is pure EdDSA: it signs the message directly (no selectable hash, no pre-hashed digest), and a request that supplies either is rejected.

Two capabilities separate the privileged key-management from day-to-day use: creating and listing keys needs secret:signing-key (admins by default); sign, verify, and public-key export need secret:sign (travels with the crypto-service grant). Key creation and each signature are audited; signing meters the daily secret-op quota (verify and public-key export do not — they touch only public material).

// POST /api/secret/signing-keys          { "name": "app-signer", "algorithm": "ecdsa-p256" }
// 201 Created  { "id": "...", "name": "app-signer", "algorithm": "ecdsa-p256",
//                "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...", "public_key_der": "<b64>" , ... }

// POST /api/secret/signing-keys/app-signer/sign     { "message": "<b64>", "hash": "sha256" }
// 200 OK   { "signature": "<b64>", "algorithm": "ecdsa-p256", "hash": "sha256", "key": "app-signer" }

// POST /api/secret/signing-keys/app-signer/verify   { "message": "<b64>", "signature": "<b64>" }
// 200 OK   { "valid": true, "algorithm": "ecdsa-p256" }

A signature that does not match is a valid:false result, not an error. Verification uses only the stored public key, so it works even without the HSM — exactly what an external verifier does.

$ secsy-secret signing-key create -name app-signer -algorithm ecdsa-p256
$ secsy-secret signing-key public -name app-signer -out app-signer.pub.pem
$ printf 'release-v1.2.3' | secsy-secret sign   -key app-signer -out sig.bin
$ printf 'release-v1.2.3' | secsy-secret verify -key app-signer -sig-in sig.bin   # exit 0 on match

# The exported SPKI verifies with standard tooling — no secsy-pki needed:
$ printf 'release-v1.2.3' > msg.bin
$ openssl dgst -sha256 -verify app-signer.pub.pem -signature sig.bin msg.bin
Verified OK

For an RSASSA-PSS key, openssl needs the padding mode and salt length:

$ openssl dgst -sha256 -verify k.pub.pem \
    -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 \
    -signature sig.bin msg.bin

In the operator console, the Secrets page carries a Digital signatures panel: it lists the tenant's signing keys, creates a new key (algorithm picker), shows and downloads a key's SPKI public-key PEM, and signs / verifies a text message against a named key — the same authorized, audited, quota-metered operations as the CLI and API.

Adopting a key you already sign with

A key whose public half is compiled into clients that have already shipped cannot be rotated cheaply — but it can stop living in the application's config and move into the HSM. signing-key import adopts it into the registry, after which it is indistinguishable from a generated key except in provenance:

$ secsy-secret signing-key import -name release-signing -key app-signing.key -out pub.pem
$ diff app-signing.pub pub.pem     # identical: existing verifiers keep working

The algorithm is derived from the key for ECDSA and Ed25519; an RSA key needs an explicit -algorithm, because the same key can be used with PSS or PKCS#1 v1.5 and the choice has to match what the existing verifiers already do. Like the CA-side commands this is deliberately CLI-only, and it records a secret.signing_key_import audit event. See importing existing keys for the accepted file formats, the passphrase handling, and what import can and cannot launder.

Format-preserving encryption & tokenization (FF1)

Where envelope encryption turns a value into an opaque blob, a transform enciphers structured data — a card PAN, an SSN, an account number — into another value of the same length over the same alphabet, using NIST SP 800-38G FF1 (AES-based format-preserving encryption). Legacy systems that validate the format of a field keep working on the protected value. A deterministic template yields stable ciphertext for equal plaintext, so a protected column can still be searched for equality and de-duplicated.

The FF1 key never exists in the clear at rest. A random seed is sealed as an ordinary envelope under the family KEK (exactly like the keyed-HMAC seed), and a per-template FF1 key is HKDF-derived from it per request — so templates are cryptographically independent, and raw keys never leave the HSM trust path. The seed does not rotate (a format-preserving token carries no version to select an old key), but its KEK wrapping is re-sealed when the KEK rotates (secsy-secret rewrap -all), keeping the derived keys — and every issued token — stable. secsy-secret retire-kek refuses to withdraw a KEK version still sealing the FPE seed until it has been re-sealed (or -force).

Templates are declared in config:

secret:
  kek_label: "secsy-kek"
  transforms:
    - name: pan                 # referenced by callers; bound into the derived key
      alphabet: digits          # named set, or an inline "chars:<symbols>" literal
      min_length: 12            # >= the FF1 domain minimum for the radix (radix^len >= 1e6)
      max_length: 19
      deterministic: true       # convergent: equal PAN -> equal token (equality search)
      preserve_other: true      # copy separators (dashes/spaces) through verbatim
    - name: account
      alphabet: alphanumeric    # radix 62
      deterministic: false      # context mode
      tweak_source: request     # a per-request tweak (e.g. a record id); re-supply to decode
      roles: [issuer]           # optional per-template role allowlist

Named alphabets: digits, hex-lower, hex-upper, letters-lower, letters-upper, alphanumeric-lower/-upper (radix 36), alphanumeric (radix 62), base32, base32-hex. A custom set is chars:<symbols> (e.g. chars:ACGT). The name and alphabet of a template must never change once data has been tokenized under it — both are identity.

// POST /api/secret/transform/encode  { "template": "pan", "value": "4111-1111-1111-1111" }
// 200 OK                             { "template": "pan", "result": "4923-8471-2210-6634", "deterministic": true }

// POST /api/secret/transform/decode  { "template": "pan", "value": "4923-8471-2210-6634" }
// 200 OK                             { "template": "pan", "result": "4111-1111-1111-1111", "deterministic": true }

For a request-tweak template, pass a base64 tweak; present the same tweak to decode. Access requires the secret:transform capability plus any per-template roles allowlist. Only lengths (never the plaintext or token) are audited.

$ secsy-secret transform encode -template pan -value 4111-1111-1111-1111
$ secsy-secret transform decode -template pan -value 4923-8471-2210-6634
$ secsy-secret transform encode -template account -value ACCT12345 -tweak "$(printf ctx-7 | base64)"

Both directions count against the tenant's daily secret-op quota. gRPC exposes the same operations as SecretService.TransformEncode / TransformDecode.

Named secrets: version history, lifecycle reminders, and exec

encrypt / decrypt are stateless — you keep the envelope. The stored-secret registry instead holds the envelope server-side under a tenant-unique name, and gives it a value history, a TTL/rotation schedule, and a way to hand the plaintext to a child process without it ever touching disk.

Only the envelope is stored. The registry never holds plaintext, and reading a value still unwraps its data key on the HSM.

Every put appends a version

A secret's value history is append-only: put adds the next version, and rollback re-activates an older value by appending a copy of it rather than rewriting history. So "what was this password in August, and who changed it" stays answerable.

# Create (version 1) with metadata and a lifecycle schedule.
printf 'first-password' | secsy-secret put -name db-password \
    -ttl-days 90 -rotate-every-days 30 \
    -label owner=payments -label env=prod -comment "initial"

# Each later put appends a version.
printf 'second-password' | secsy-secret put -name db-password -comment "quarterly rotation"

# History, newest first (ciphertext is never printed).
secsy-secret versions -name db-password

# Read the current value, or any retained one.
secsy-secret get -name db-password
secsy-secret get -name db-password -version 1

# Make an older value current again (appends a new version).
secsy-secret rollback -name db-password -version 1

put -expect-version N makes the write conditional on the secret still being at version N — a compare-and-swap for orchestrators that must not clobber a concurrent rotation. A lost race is reported, not silently applied.

Retention depth

By default history is kept forever. Bound it with:

secret:
  version_history_depth: 10   # keep the newest 10 versions; 0 = unlimited

Pruning is opt-in on purpose. Deleting a history row destroys the only copy of that ciphertext, so a rollback to it — and any escrow recovery of it — becomes impossible; an upgrade must not silently discard recoverable material. The current version is always exempt, so any depth ≥ 1 keeps the live value readable. When a put prunes, it says so and records pruned_versions=N history_depth=D in the audit event.

History survives KEK rotation

Re-wrap migrates every retained version, not just the current one: the current envelope moves with its registry row, and historical envelopes move through their own work list. That is what keeps get -version N and rollback working after a rotation — and it is why retire-kek is refused while any historical envelope still depends on the version being withdrawn.

Because re-wrap only replaces the KEK wrap header and leaves the data key untouched, escrow shares over an old version stay valid across rotations.

secsy-secret rotate-kek            # new active version
secsy-secret rewrap -all           # migrates current + historical envelopes
secsy-secret versions -name db-password   # every row now shows the new KEK label
secsy-secret retire-kek -version 1 # safe once nothing references it

A re-wrap deliberately does not advance value_changed_at, so migrating a KEK never resets a rotation reminder.

TTL and rotation reminders

-ttl-days sets an expiry deadline and -rotate-every-days arms a rotation reminder measured from the last value change. Both are operational reminders: an expired secret still decrypts — expiry here is not a cryptographic revocation.

The expiry monitor classifies them against the same warning/critical windows it uses for certificates and pushes the due ones through the same notification sinks, as secret-lifecycle findings with state expiring, expired or rotation_due. Labels ride along so an alert can be routed without a second lookup. Repeats are storm-filtered: a still-overdue secret re-notifies on escalation, on a new value version, or after the re-notify interval — not on every tick.

secsy-secret lifecycle    # what is due right now, with labels

Metrics: secsy_secrets_lifecycle_due{state} tracks how many secrets sit in each state (refreshed every scan, and cleared when the backlog drains), and secsy_secret_lifecycle_notifications_total{state} counts reminders actually dispatched.

exec — inject secrets into a child process

exec decrypts and places values directly in a child's environment. Nothing is written to disk and no shell expansion sees the value.

# NAME=VAR, or NAME:VAR — both bind a secret to a variable.
secsy-secret exec -secret db-password=DB_PASS -- ./myapp

# Pin a specific version, and compose values into a connection string.
secsy-secret exec -secret db-password@3:DB_PASS \
    -env 'DATABASE_URL=postgres://app:{{secret:db-password}}@db/app' -- ./myapp

# Omit the variable name to derive it from the secret name (db-password → DB_PASSWORD).
secsy-secret exec -secret db-password -- env

# Start from an empty environment instead of inheriting this process's.
secsy-secret exec -no-inherit -secret db-password=DB_PASS -- ./myapp

The child's exit status is propagated, and signals are forwarded. Variable names must be portable ([A-Za-z_][A-Za-z0-9_]*); collisions and unresolved {{secret:…}} placeholders fail closed rather than launching the child with a half-built environment.

Environment variables are visible to anything that can read the child's /proc/<pid>/environ — i.e. the same user and root. That is a smaller exposure than a plaintext file, not zero.

RBAC, tenancy, and audit

Every endpoint is tenant-scoped through the usual X-Secsy-Tenant resolution, and a secret name is unique per tenant. Writes (store, put, rollback, delete) need the secret:encrypt capability; reads (get, versions, lifecycle) need secret:decrypt.

Action Event
create secret.store
new version secret.put (detail: new version, KEK label/version, any pruning)
read a historical version secret.version_read
read the current value secret.decrypt
rollback secret.rollback (detail: source and new version)
delete secret.store_delete
env injection secret.exec (detail: secret names and the command)

secret.version_read is recorded separately from secret.decrypt — including on a denial or a miss — because reading history is the one way to obtain a value the secret no longer holds. No audit detail ever contains plaintext. Review them with secsy-secret audit (and -verify to check the hash chain).

KEK rotation and DEK re-wrap

The KEK is the one key in this design that never changes by itself: every stored envelope names the key that can unwrap its data key, so replacing that key in place would strand all existing ciphertext. Rotation is therefore an explicit, three-step migration with an overlap window, mirroring the dual-chain overlap used for CA signing keys.

Versioned KEK families

A family is the lineage of HSM keys behind one logical wrapping key. The family name is the configured secret.kek_label (or a tenant's). Version 1 is the key under that bare label; every later version N lives under <family>-vN — its own CKA_LABEL, because a duplicate label makes PKCS#11 key lookup ambiguous. Each envelope header records both the label and the version that currently wraps its DEK:

{ "kek_label": "secsy-kek-v2", "kek_version": 2, "wrapped_dek": "…", … }

Each version is active, retiring, or retired:

Status Seals new envelopes Opens existing envelopes
active yes yes
retiring no yes (overlap window)
retired no no — fail-closed

The three steps

# 1. Rotate: generate the next version in the HSM and make it active.
#    Nothing is re-encrypted; the old version becomes "retiring".
$ secsy-secret rotate-kek -key-type rsa-4096

# 2. Re-wrap: unwrap each DEK under its old KEK and immediately re-wrap it
#    under the active one. Run it fleet-wide or for individual secrets.
$ secsy-secret rewrap -all
$ secsy-secret rewrap -id <secret-id>
$ secsy-secret rewrap -in envelope.json      # a standalone envelope file

# 3. Retire: withdraw the drained version. Refused while any stored secret
#    still depends on it, unless you pass -force.
$ secsy-secret retire-kek -version 1

# At any point: where does the migration stand?
$ secsy-secret kek-versions

Between steps 1 and 3 the family runs a dual-KEK decrypt window: reads work for both versions, so rotation needs no downtime and no flag day. Re-wrapping is resumable — re-running it retries only what is left, and concurrent writers are reported as conflicts rather than silently overwritten.

What re-wrap does and does not touch

Re-wrap rewrites only the wrapped data key:

  • The data ciphertext, nonce, and AES-GCM tag are byte-identical afterwards. The DEK itself is unchanged, so the bulk data is never re-encrypted — a re-wrap costs one HSM unwrap plus one public-key wrap per secret, regardless of plaintext size.
  • Because the DEK is unchanged, existing M-of-N escrow shares stay valid: an escrowed secret remains recoverable by its agent quorum across rotation, and escrow is still the break-glass path for ciphertext stranded on a retired version. See key escrow.
  • The DEK is never exposed outside the process: it is unwrapped on the token via C_Decrypt, re-wrapped immediately, and zeroized — exactly as on an ordinary decrypt. No plaintext DEK is written to disk, logged, or returned by any endpoint.

Using the post-quantum hybrid mode? Run secsy-secret pqc-reseal after a classical rotation and before retiring the old version, so the ML-KEM decapsulation key is re-sealed under the new KEK.

RBAC and audit

Rotation is key management, not day-to-day use: all four operations require the secret:rotate capability (admin-only by default) on the owning tenant, and kek/rotate + kek/retire additionally sit behind the WebAuthn step-up gate and can be put behind a four-eyes approval (secret.kek_rotate). Every attempt — success and failure — appends to the hash-chained audit log as secret.kek_rotate, secret.rewrap, or secret.kek_retire.

Monitoring the migration

Four metrics track rotation posture; the expiry monitor refreshes the gauges on every scan tick, so they stay current between explicit operations:

Metric Meaning
secsy_secret_on_old_kek{family} Secrets not yet on the active version — the number a re-wrap drains to zero
secsy_secret_kek_active_version{family} The family's current active version
secsy_secret_rewrap_pending{family} Remaining secrets in a running re-wrap batch
secsy_secret_rewrap_total{result} Re-wraps by result (ok/conflict/error)

A family with secrets still on an old version logs a re-wrap reminder each tick; secrets found on a retired version escalate to CRITICAL, since those are undecryptable until the version is reinstated or they are recovered from escrow.

Key escrow and recovery (M-of-N, dual control)

Optionally, each secret can be escrowed so its data key can be recovered under dual control if the original requester loses access — a break-glass path that does not weaken day-to-day security.

How escrow works

At encryption time, in addition to wrapping the DEK to the primary KEK, the DEK is split with Shamir's Secret Sharing over GF(2⁸) into N shares under a reconstruction threshold M. Each share is then RSA-OAEP wrapped to one recovery agent's public key. The wrapped shares travel with the envelope in an escrow block:

   DEK ──Shamir split (M-of-N)──► share₁ … shareₙ
 shareᵢ ──RSA-OAEP(agentᵢ_pub)──► wrapped_shareᵢ   (unwrap runs on the agent's HSM)
  • Any M recovery agents can reconstruct the DEK; any M-1 learn nothing about it (Shamir's information-theoretic guarantee). No sub-quorum can recover.
  • The whole escrow block is bound into the envelope's AES-GCM authenticated data, so an attacker cannot substitute their own recovery agents, tamper with a wrapped share, or strip the escrow block without invalidating the ciphertext.
  • Recovery-agent private keys stay in the HSM: each agent unwraps its share via C_Decrypt on the token, exactly like the primary KEK.

Escrow is optional per secret and does not change the primary decrypt path: the KEK owner can still decrypt an escrowed envelope normally.

Configuration

secret:
  kek_label: "secsy-kek"
  escrow:
    enabled: true
    threshold: 2          # M — quorum required to recover (>= 2, dual control)
    agents:               # N recovery agents
      - id: "alice"
        key_label: "agent-alice"     # RSA key held by the provider/HSM
      - id: "bob"
        key_label: "agent-bob"
      - id: "carol"
        public_key_file: "carol.pub" # or an externally-held public key (wrap-only)

threshold must be at least 2 (dual control) and at most the number of agents. Each agent needs a key_label to participate in recovery through this provider; an agent given only a public_key/public_key_file can be wrapped to but must recover on its own device.

CLI

# Provision a recovery-agent RSA key on the HSM (repeat per agent)
$ secsy-secret escrow-init-agent -label agent-alice

# Show / validate the escrow policy (resolves every agent key with -verify)
$ secsy-secret escrow-config -verify

# Encrypt WITH escrow (records a secret.escrow audit event)
$ printf 'master-password' | secsy-secret encrypt -escrow -out secret.json

# Recover under a quorum (records a secret.recover audit event)
$ secsy-secret recover -in secret.json -agent alice -agent bob -out plain.txt

# Review escrow / recovery events and verify the audit chain
$ secsy-secret audit
$ secsy-secret audit -verify

A recover invocation fails closed if fewer than threshold distinct agents are supplied, if a named agent is not a recovery agent of that envelope, or if the envelope carries no escrow — and each of those outcomes is logged.

Over the HTTP API, add "escrow": true to a POST /api/secret/encrypt request to escrow the data key using the configured policy (gated on the same secret:encrypt capability). Recovery is intentionally CLI-only: exposing a one-call recovery endpoint would let a single administrator obtain plaintext, defeating dual control. Recovery is meant to be run as a deliberate, audited ceremony (ideally on an isolated host with the required agents present).

The recovery ceremony (runbook)

  1. Authorize. Recovery is an admin-only, break-glass operation (secret:recover). Convene the required quorum of recovery agents per policy.
  2. Stage the envelope to a host with access to the recovery agents' HSM keys.
  3. Run recovery, naming each participating agent: secsy-secret recover -in secret.json -agent <id> -agent <id> …. The tool enforces the M-of-N quorum, unwraps each share on the token, and reconstructs the DEK only if the quorum is met.
  4. Record. Every attempt — success, sub-quorum denial, or error — appends a distinct secret.recover event (with the participating agent IDs) to the tamper-evident audit log. Verify the chain afterward with secsy-secret audit -verify.
  5. Rotate. Treat a recovered secret as exposed: rotate it and re-encrypt.

RBAC and audit

Capability Role Covers
secret:encrypt admin, issuer encrypt, including escrow-on-encrypt
secret:escrow admin administering the escrow configuration
secret:recover admin performing a recovery ceremony

Escrow and recovery are logged with their own event types — secret.escrow and secret.recover — separate from routine secret.encrypt / secret.decrypt, so break-glass activity stands out in the audit trail and in SIEM export.

Testing

Unit tests (internal/secret) run with no HSM using the software backend. HSM-gated tests (*_softhsm_test.go) run against SoftHSM when SECSY_PKCS11_MODULE / SECSY_TOKEN_LABEL are set. See ../TESTING.md and HSM configuration.