Runbook — HSM as the master KEK source
Audience: Platform / Integrations / ReleaseOrchestrator operators. Applies to: all three services that resolve the master KEK. Related runbooks: kek-rotation.md, kek-source-vault.md, credential-storage-algorithm-switch.md.
When to use this
Production Stella Ops deployments in regulated environments where the master KEK must be sealed behind a hardware security module (PKCS#11-compliant). The deployment already runs an HSM and has a wrapping key provisioned on it.
Hardware-validation caveat: the code path is production-ready but full functional validation requires actual HSM hardware. Operators using SoftHSM (CI-friendly substitute) get a working path; operators using Thales Luna / YubiHSM / equivalent get a code path that needs first-deployment validation against their hardware.
Service-wiring caveat (verified 2026-05-31): the HSM KEK source is library-complete and unit-tested, but no shipped service wires it yet — Platform, Integrations, and ReleaseOrchestrator each hardwire EnvKekSource and do not read Crypto:Kek:Source. Steps 3–4 below are the glue an operator (or a future sprint) must add before Crypto:Kek:Source=hsm takes effect. Treat this runbook as the wiring guide, not a description of out-of-the-box behaviour.
Prerequisites
- An HSM token reachable via PKCS#11 with a wrapping key provisioned. Recommended: AES-256 with
CKA_UNWRAP=true(AES-GCM mechanism). - The PKCS#11 library binary on the host (
.so/.dll). - HSM credentials (slot id + PIN + token label) configured for the existing
StellaOps.Cryptography.Plugin.Hsmplugin. - A wrapped KEK ciphertext + IV produced during commissioning.
- For the step 5 verification command
stella crypto kek status: an Authority token bearingcrypto:kek:read(orcrypto:kek:rotate/ops.admin). The Platform KEK admin endpoints gate read access behind theplatform.crypto.kek.readpolicy, which accepts any of those three scopes — verified againstsrc/Platform/StellaOps.Platform.WebService/Program.cs(AddStellaOpsAnyScopePolicy(PlatformPolicies.CryptoKekRead, …CryptoKekRead, …CryptoKekRotate, …OpsAdmin)) and the per-routeRequireAuthorization( PlatformPolicies.CryptoKekRead)on the/{kekId}/statusendpoint inEndpoints/CryptoKekAdminEndpoints.cs. Scope-string gotcha: the two KEK scopes are colon-form (crypto:kek:read,crypto:kek:rotate, from the canonical catalogStellaOpsScopes.cs), but the ops-admin escape hatch is the dot-formops.admin(PlatformScopes.OpsAdmin = "ops.admin") — notops:admin. The HSM unwrap path itself needs no scope — it runs at host startup before any request is served.
Commissioning steps
1. Generate the master KEK
openssl rand -out /tmp/kek-bytes 32
2. Wrap the KEK with the HSM wrapping key
The exact command depends on the PKCS#11 toolchain. With pkcs11-tool + an AES-256 wrapping key labelled stellaops-kek-unwrap:
IV=$(openssl rand -hex 12)
echo $IV | xxd -r -p > /tmp/iv.bin
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--label stellaops-kek-unwrap \
--encrypt --mechanism AES-GCM \
--iv-file /tmp/iv.bin \
--input-file /tmp/kek-bytes \
--output-file /tmp/kek-wrapped.bin
base64 < /tmp/kek-wrapped.bin # -> WrappedKekBase64
base64 < /tmp/iv.bin # -> IvBase64
Immediately delete the plaintext KEK: shred -u /tmp/kek-bytes. The HSM keeps the wrapping key; you only need the wrapped ciphertext + IV to boot.
3. Bridge StellaOps.Cryptography.Plugin.Hsm → IHsmKekUnwrapper
The IHsmKekUnwrapper interface is narrow on purpose so each operator can bridge to whatever HSM client they have.
Wiring status (verified against source 2026-05-31): the bridge below is a reference implementation you must add yourself — it is not checked into any service today. Program.cs in Platform, Integrations, and ReleaseOrchestrator all hardwire new EnvKekSource(...) (see src/Platform/StellaOps.Platform.WebService/Program.cs ~line 886 and the matching blocks in src/Integrations/StellaOps.Integrations.WebService/Program.cs and src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Program.cs). No shipped service calls ReadSelectedKekSourceKind, AddHsmKekSource, or registers an IHsmKekUnwrapper, and no HsmPluginKekUnwrapper type exists in the tree. The library half is complete and unit-tested (HsmKekSource, AddHsmKekSource, ReadSelectedKekSourceKind, IHsmKekUnwrapper); the service-side dispatch in step 4 is the remaining glue an operator (or a future sprint) supplies. Until that glue lands, setting Crypto:Kek:Source=hsm in config alone has no effect — the services ignore the key and continue resolving the KEK from the env fallback chain.
The reference bridge against the existing HSM plugin:
using StellaOps.Cryptography.CredentialStore.Hsm;
using StellaOps.Cryptography.Plugin.Hsm;
internal sealed class HsmPluginKekUnwrapper : IHsmKekUnwrapper
{
private readonly IHsmClient _hsmClient;
public HsmPluginKekUnwrapper(IHsmClient hsmClient)
{
_hsmClient = hsmClient;
}
public Task<byte[]> UnwrapAsync(
string keyLabel,
ReadOnlyMemory<byte> wrappedKek,
string mechanism,
ReadOnlyMemory<byte>? iv,
CancellationToken cancellationToken)
{
var hsmMechanism = mechanism.ToUpperInvariant() switch
{
"AES-256-GCM" => HsmMechanism.Aes256Gcm,
"AES-128-GCM" => HsmMechanism.Aes128Gcm,
_ => throw new NotSupportedException(
$"HSM unwrap mechanism '{mechanism}' is not supported by the bridge."),
};
return _hsmClient.DecryptAsync(
keyLabel, wrappedKek.ToArray(), hsmMechanism, iv?.ToArray(), cancellationToken);
}
}
Add this dispatch to each service’s Program.cs, replacing the current hardwired new EnvKekSource(...) registration (see the wiring-status note in step 3 — this block does not exist in the tree yet):
using StellaOps.Cryptography.CredentialStore;
using StellaOps.Cryptography.CredentialStore.Hsm;
var kind = KekSourceServiceCollectionExtensions.ReadSelectedKekSourceKind(builder.Configuration);
if (kind == "hsm")
{
// The HSM plugin must have been loaded + started before this point.
builder.Services.AddSingleton<IHsmKekUnwrapper, HsmPluginKekUnwrapper>();
builder.Services.AddHsmKekSource(builder.Configuration);
}
// else: keep the existing EnvKekSource / FileKekSource / VaultKekSource branch.
ReadSelectedKekSourceKind returns env when Crypto:Kek:Source is unset (so omitting this dispatch preserves today’s env behaviour) and throws at registration time on any value outside env | file | vault | hsm.
4. Configure the service
The Crypto:Kek block below is verified against source: Crypto:Kek:Source is the literal key KekSourceServiceCollectionExtensions.ReadSelectedKekSourceKind reads (allowed values env | file | vault | hsm; unknown values throw at registration time), and Crypto:Kek:Hsm binds onto HsmKekSourceOptions (KeyLabel, WrappedKekBase64, Mechanism default AES-256-GCM, optional IvBase64).
{
"Crypto": {
"Kek": {
"Source": "hsm",
"Hsm": {
"KeyLabel": "stellaops-kek-unwrap",
"WrappedKekBase64": "<base64 from step 2>",
"Mechanism": "AES-256-GCM",
"IvBase64": "<base64 IV from step 2>"
}
}
}
}
The HSM plugin itself (the thing your IHsmKekUnwrapper bridge talks to) is configured separately through the crypto plugin loader. The plugin binds an HsmOptions (LibraryPath, SlotId, TokenLabel, Pin, KeyIds, AllowSimulation, …; see src/Cryptography/StellaOps.Cryptography.Plugin.Hsm/ and plugin.yaml). The verified plugin-config root is StellaOps:Crypto:Plugins (AddStellaOpsCryptoFromConfiguration), with per-plugin keys driven by the loader convention — treat the section below as illustrative shape, not a verbatim key path, and confirm the exact section name against your deployment’s plugin loader before relying on it. KeyIds must include the wrapping key’s label so the plugin permits the unwrap operation.
// illustrative shape — confirm the plugin-loader section path for your deployment
{
"LibraryPath": "/usr/lib/softhsm/libsofthsm2.so",
"SlotId": 0,
"TokenLabel": "stellaops",
"Pin": "1234",
"KeyIds": "stellaops-kek-unwrap"
}
5. Restart and verify
docker logs platform | grep "HSM KEK source resolved"
# Expect ONE line per service:
# HSM KEK source resolved (kek_id=platform:connector-credentials:v1, key_label=stellaops-kek-unwrap, mechanism=AES-256-GCM).
# The plaintext KEK, the wrapped ciphertext, and the HSM PIN MUST NOT appear in any log line.
stella crypto kek status --kek-id platform:connector-credentials:v1
# Source should now report "hsm".
Hardware-validation matrix
| HSM | Code path validated | Notes |
|---|---|---|
HsmKekSource ↔ IHsmKekUnwrapper contract (CI) | YES (in-memory AES-GCM mock unwrapper) | HsmKekSourceTests exercises happy-path unwrap, the in-process cache, no-silent-fallback on unwrap failure, and the sentinel-leak assertions — using a hand-rolled in-memory AesGcm unwrapper, not a real HSM. |
| SoftHSM 2 | NO (not run in CI) | Documented here as the CI-friendly substitute, but HsmKekSourceTests does not drive a SoftHSM binary (CI hosts do not reliably ship one). Operators wiring SoftHSM get a working code path that still needs a one-time deployment check (see below). |
| YubiHSM 2 | NO (no hardware in CI) | Code path expected to work via the existing Pkcs11HsmClient (real CKM_AES_GCM decrypt through Pkcs11HsmClientImpl); first-deployment validation required. |
| Thales Luna | NO (no hardware in CI) | Code path expected to work via the existing Pkcs11HsmClient; first-deployment validation required. |
| AWS CloudHSM | NO (cloud KMS deliberately not shipped) | Operators install the AWS PKCS#11 library + treat as a generic PKCS#11 token; IHsmKekUnwrapper bridge unchanged. |
For first deployment against new HSM hardware, the recommended validation is:
- Wrap a throwaway 32-byte secret via step 2.
- Run the service with
Crypto:Kek:Source=hsmpointed at it. - Verify the startup log shows
HSM KEK source resolvedwith the correct key label. - Verify
stella crypto kek statusreportssource=hsm. - Round-trip a single connector credential write + read to verify the AEAD layer is decrypting against the HSM-unwrapped KEK.
Failure modes
| Symptom (startup log) | Cause | Operator action |
|---|---|---|
HSM KEK source: Crypto:Kek:Hsm:KeyLabel is required. | KeyLabel unset (constructor validation) | Set Crypto:Kek:Hsm:KeyLabel. |
HSM KEK source: Crypto:Kek:Hsm:WrappedKekBase64 is required. | WrappedKekBase64 unset (constructor validation) | Set Crypto:Kek:Hsm:WrappedKekBase64 to the step 2 output. |
HSM KEK source: Crypto:Kek:Hsm:Mechanism is required. | Mechanism blanked out (constructor validation; the option defaults to AES-256-GCM, so this only fires if explicitly emptied) | Restore a mechanism value. |
HSM KEK source: Crypto:Kek:Hsm:WrappedKekBase64 is not valid base64. | Wrapped-KEK config typo | Re-run step 2; verify base64 -d round-trips. |
HSM KEK source: Crypto:Kek:Hsm:IvBase64 is not valid base64. | IV config typo | Re-run step 2; verify the IV base64 -d round-trips. |
HSM KEK source: unwrap failed via key 'X' with mechanism 'Y'. CKR_KEY_HANDLE_INVALID | Wrong key label or wrapping key absent | Verify key exists on the HSM token; check pkcs11-tool --list-objects. |
HSM KEK source: unwrap failed via key 'X' with mechanism 'Y'. CKR_PIN_INCORRECT | HSM PIN wrong | Fix the HSM plugin’s Pin (see step 4). Service WILL NOT start until resolved. |
HSM KEK source: unwrap failed via key 'X' with mechanism 'Y'. CKR_MECHANISM_INVALID | HSM does not implement the configured mechanism | Switch to a mechanism the HSM supports (re-wrap KEK + update config). |
The unwrap failed via key '...' with mechanism '...' prefix is emitted verbatim by HsmKekSource; the trailing text (CKR_*) is the unwrapper’s own message and propagates through unchanged. The wrapped-KEK ciphertext is never included in the message.
The source fails-startup on any unwrap failure — no silent fallback to env-var.
Audit & sentinel guarantees
- The HSM PIN is held by the existing HSM plugin (not by this source). It is never logged or surfaced to admin endpoints.
- The wrapped-KEK ciphertext is held in config (operator-supplied)
- briefly in memory during unwrap. It is never logged.
- The plaintext KEK is held in process memory + HKDFed by
CredentialAead. It is never logged, persisted outside the process boundary, or emitted in any HTTP response. - The KEK fingerprint is the ONLY KEK-derived artefact surfaced. It is a double SHA-256 with kdf-label binding —
SHA-256(SHA-256("{kdfLabel}:{secret}"))— truncated to 16 hex chars (64 bits), perKekFingerprint.Compute. The double-hash + label binding ensure a fingerprint leak cannot regenerate the raw secret bytes, and two installations sharing a raw secret but different kdf labels produce different fingerprints. This is whatstella crypto kek statusprints on theFingerprint:line.
Related
- ADR-007 — KEK lifecycle and rotation:
../architecture/decisions/ADR-007-kek-lifecycle-and-rotation.md - ADR-008 — regional algorithm routing:
../architecture/decisions/ADR-008-credential-storage-regional-algorithm-routing.md - Vault source runbook:
kek-source-vault.md - KEK rotation runbook:
kek-rotation.md - Algorithm switch runbook:
credential-storage-algorithm-switch.md - Source library:
src/__Libraries/StellaOps.Cryptography.CredentialStore.Hsm/ - Tests:
src/__Libraries/__Tests/StellaOps.Cryptography.CredentialStore.Tests/HsmKekSourceTests.cs - HSM plugin:
src/Cryptography/StellaOps.Cryptography.Plugin.Hsm/ - Sprint dossier:
docs-archive/implplan/SPRINT_20260529_028_Key_management_operations.md
