Hardware key vault
Every secret SuperDRM holds — content keys, tenant token secrets, Widevine signing keys, FairPlay material — is stored sealed (AES-256-GCM, bound to the row it belongs to) under a Key-Encryption Key (KEK). The KEK never touches the database. Where the KEK lives is the only thing that changes between tiers, and it is chosen with one variable: SUPERDRM_VAULT.
#What "hardware-backed" means at each tier — honestly
| Tier | SUPERDRM_VAULT | Where the KEK is | Who can read a content key in plaintext | Use for |
|---|---|---|---|---|
| Software | local | A 32-byte file on disk (chmod 600) | Anyone who can read the file and the database | Dev, single-box staging. Not a security boundary. |
| HSM | pkcs11 or aws-kms | Inside the HSM, CKA_EXTRACTABLE=false (or a KMS CMK). The api only asks the HSM to encrypt or decrypt. | The api process, for the milliseconds a licence needs it; a database dump alone is useless | Production licence servers. Stealing the database does not steal the keys. |
| Enclave | pkcs11 / aws-kms inside an attested enclave (AWS Nitro Enclave, GCP Confidential VM, IBM Hyper Protect) | As above, and the HSM/KMS policy only releases to a measured enclave image | Only code whose attestation matches | Contracts that require "keys never in plaintext on a general-purpose host" — the FairPlay KSM and the Widevine signing key live here. |
The api code is identical across tiers. What you gain by going up a tier is a smaller set of parties who could ever see plaintext, and an audit trail that says so.
#Supported PKCS#11 tokens
| Token | Module | Notes |
|---|---|---|
| YubiHSM 2 (FIPS 140-2 L3 variant available) | /usr/lib/x86_64-linux-gnu/pkcs11/yubihsm_pkcs11.so (aarch64: /usr/lib/aarch64-linux-gnu/pkcs11/yubihsm_pkcs11.so), with yubihsm-connector running | PIN is <auth key id><password>, e.g. 0001password. Set YUBIHSM_PKCS11_CONF to point at the connector. One on the licence node, one in a safe. |
| Thales Luna (Network / PCIe / Cloud HSM) | /usr/safenet/lunaclient/lib/libCryptoki2_64.so | Partition label → SUPERDRM_PKCS11_TOKEN_LABEL; crypto-officer password → PIN. |
| AWS CloudHSM | /opt/cloudhsm/lib/libcloudhsm_pkcs11.so | PIN is CU_user:password. Alternatively use aws-kms (cheaper, same FIPS 140-3 L3 boundary, no cluster to run). |
| SoftHSM2 | /usr/lib/softhsm/libsofthsm2.so | Software emulation of a PKCS#11 token. CI and tests only — npm run test:pkcs11 proves the code path here. |
aws-kms uses the KMS API directly (no PKCS#11) with EncryptionContext = {aad}, which gives the same binding as GCM's AAD.
#Environment
SUPERDRM_VAULT=pkcs11
SUPERDRM_PKCS11_MODULE=/usr/lib/aarch64-linux-gnu/pkcs11/yubihsm_pkcs11.so
SUPERDRM_PKCS11_PIN=0001<password>
SUPERDRM_PKCS11_KEY_LABEL=superdrm-kek-1 # becomes kek_id "pkcs11:superdrm-kek-1"
SUPERDRM_PKCS11_TOKEN_LABEL= # optional; else slot index
SUPERDRM_PKCS11_SLOT=0 # optional
SUPERDRM_PKCS11_GCM_PARAMS=v240 # v230 only for a legacy token without ulIvBits
SUPERDRM_KEK_ID= # optional override of the recorded kek_id
SUPERDRM_VAULT=aws-kms
SUPERDRM_KMS_KEY_ID=arn:aws:kms:us-west-2:…:key/…
AWS_REGION=us-west-2On first start the vault generates the KEK inside the token if no object with that label exists (AES-256, CKA_TOKEN, CKA_SENSITIVE, CKA_EXTRACTABLE=false, encrypt/decrypt only). Every later start finds it by label. GET /health reports vault.provider and vault.detail.
#Key ceremony
- Two custodians, one session, written minutes. Nobody holds the whole token PIN alone: custodian A types the first half, custodian B the second (YubiHSM: two auth keys with split capabilities is the cleaner version).
- Generate in-token. Start the api once with the new label; confirm with
pkcs11-tool --module … --login --list-objectsthat the object showsnever extractableandsensitive. Never import a KEK you generated elsewhere. - Back up using the token's own mechanism, never by exporting: YubiHSM
wrapthe KEK under a wrap key whose halves the custodians hold; Luna partition backup to a Backup HSM; CloudHSM cluster backup to S3. Store the wrapped blob in a sealed envelope in a safe, signed by both custodians, with thekek_idand date on the outside. - Record
kek_id, module, token serial, custodians and the backup location in the change log (audit_logrowvault.ceremonyviasuperdrm audit, plus the paper minutes). - Prove restore once a year: restore the backup to a spare token in a staging box and open one sealed row with it.
#Rotation
Rotation is a new KEK, not a re-keyed one. Sealed rows carry kek_id, so old and new can coexist.
Generate the new KEK (ceremony above) with a new label, e.g.
superdrm-kek-2.Point the api at the new label (
SUPERDRM_PKCS11_KEY_LABEL=superdrm-kek-2) and restart: new rows seal under the new KEK immediately.Re-seal the old rows lazily or in one pass:
Shell SUPERDRM_OLD_VAULT=pkcs11 SUPERDRM_OLD_PKCS11_MODULE=… SUPERDRM_OLD_PKCS11_PIN=… \ SUPERDRM_OLD_PKCS11_KEY_LABEL=superdrm-kek-1 \ npm run reseal -- --from pkcs11:superdrm-kek-1 --dry-run npm run reseal -- --from pkcs11:superdrm-kek-1The re-seal walks
content_keys,tenants,tenant_drmandpartner_creds; opens each blob with the old vault, seals with the new, verifies by opening it again, and updates in batches of one transaction each. It is idempotent — rows already under the activekek_idare skipped — so it can be re-run after an interruption. The same command moves a deployment fromlocalto an HSM (SUPERDRM_OLD_VAULT=local SUPERDRM_OLD_LOCAL_KEK_FILE=…).When
select count(*) from content_keys where kek_id = '<old>'is zero, destroy the old KEK object in the token and shred its backup envelope (both custodians, minuted).
#Proving it
npm run test:pkcs11 builds a clean node:24-bookworm container, installs SoftHSM2 and pkcs11js, initialises a token, then runs the vault tests (KEK attributes, seal/open, AAD and tamper rejection, re-open finds the key, foreign-label refusal) and the entire api test suite on the PKCS#11 vault. It ends with pkcs11-tool --list-objects, an independent tool, showing the KEK as never extractable. Swap the module path and PIN and the same test proves a real YubiHSM.
Updated September 2026