aere-quantum/pq-kms/README.md

19 KiB

Aere PQ KMS

A small "transit"-style key management service (in the spirit of Vault's transit engine) where every key is hybrid classical + post-quantum. It encrypts, decrypts, rewraps, issues data keys, signs and verifies. Callers never see private keys unless a key was explicitly created as exportable.

It runs on Node.js 24 only and uses nothing but node:crypto (OpenSSL 3.5), node:http, node:fs. No dependencies.

key type algorithms what the hybrid means
encrypt X25519 + ML-KEM-768, AES-256-GCM the data key is derived from both shared secrets; recovering it requires both
sign Ed25519 + ML-DSA-65 a signature carries both halves; verification fails if either half fails

What it is NOT

  • Not an HSM itself. Private keys live on disk sealed under a root key, and in process memory while in use. Anyone who can read process memory, or who has both the data directory and the root key, has the keys.

  • The root key comes from the environment (AERE_KMS_ROOT_KEY) or, since 2026-09-28, sealed by an HSM (AERE_KMS_ROOT_HSM, see below). With the environment there is no physical separation, no key ceremony, no secret sharing, no unseal quorum; protect the environment accordingly. Only the derived key buffer is zeroed after derivation; the hex string itself stays readable in the process (configuration) for its lifetime.

  • Not audited. No third party has reviewed this code or the hybrid construction below.

  • Single process. There is no file locking; do not point two processes at the same data directory. If two processes do write the same audit log, the chain breaks and the next start refuses with AUDIT_CHAIN_INVALID until the log is moved aside: it is a stop, not a degradation.

  • Audit verification is linear. Startup and GET /v1/audit/verify re-read and re-authenticate the whole log; on a very large log a caller holding the token can make that endpoint expensive. Rotate the log by moving it aside (keep it as evidence) and starting a new chain.

  • No TLS. The server speaks plain HTTP and listens on 127.0.0.1 by default. Put a TLS-terminating proxy in front of it before exposing it to anything else.

  • One static bearer token. There are no per-client identities, roles or per-key ACLs, and the audit log does not record who made a request.

  • No key deletion, no root-key rotation, no rate limiting, no replication or backup.

Root key sealed by an HSM (PKCS#11)

hsm-radacina.mjs seals the 32-byte root under an AES-256 key generated inside a PKCS#11 token (CKA_SENSITIVE, never extractable) and stores only the sealed form on disk. At start the server asks the HSM to open it with the token PIN (AERE_HSM_PIN, read by pkcs11-tool from the environment, never on a command line). Someone with the disk and the environment but without the HSM (and its PIN) does not have the root. Set exactly one of AERE_KMS_ROOT_KEY / AERE_KMS_ROOT_HSM; both is refused (ROOT_KEY_AMBIGUOUS).

AERE_HSM_PIN=... node hsm-radacina.mjs sigileaza --modul /usr/lib/softhsm/libsofthsm2.so --token aere-kms --id 0a --iesire root-hsm.json --nou
AERE_KMS_ROOT_HSM=root-hsm.json AERE_HSM_MODULE=/usr/lib/softhsm/libsofthsm2.so AERE_HSM_PIN=... AERE_KMS_TOKEN=... node server.mjs

The tool's command words are Romanian: sigileaza seals (--modul the PKCS#11 library, --iesire the output file, never overwritten, --nou a new random root instead of AERE_KMS_ROOT_KEY), and verifica <file> checks that a sealed root opens with this HSM and PIN (it also needs AERE_HSM_MODULE). It prints ok: or refused: <CODE>: <reason>.

The sealed file does not choose what the process loads or sends: the PKCS#11 library comes from the process configuration (AERE_HSM_MODULE, an absolute path) and must be exactly the one recorded in the file, checked before any HSM tool runs (HSM_MODULE_NOT_ALLOWED otherwise); the PIN is always read from AERE_HSM_PIN, and a file naming another variable is refused. So write access to the sealed file alone cannot make the KMS load another library or hand it the PIN or another secret. A modified seal is refused with one code and one message whichever check catches it (padding or the root's digest), so startup errors are not a padding oracle.

Measured on SoftHSM2 2.6.1 + OpenSC 0.25 (test/proba-hsm.mjs, 20/20 on 2026-09-29, needs Node 24 and a PKCS#11 module; the library and PIN rules above in test/proba-hsm-incredere.mjs, 7/7 without an HSM, with test/control-negativ-hsm-incredere.mjs): key generated in the token and not readable out of it, seal/open round trip, the KMS started from the HSM-opened root and a hybrid round trip through it, and named refusals for a wrong PIN, a missing PIN, another token, another key and a modified seal. What it does not change: after opening, the root is in process memory as before; the hybrid ML-KEM/ML-DSA operations run in the KMS, not in the HSM (standard PKCS#11 does not expose them); pkcs11-tool works on files, so the root passes for milliseconds through a file under /dev/shm, zero-filled and removed. The mechanism is AES-CBC-PAD (OpenSC 0.25 does not expose AES-GCM for encryption), so the seal carries 16 bytes of the root's own digest and a modified seal is a named refusal, never a wrong root. Not yet exercised against a physical HSM.

Threat model (short)

Defended, and exercised by the test suite (test/proba.mjs):

  • A future quantum adversary against stored ciphertexts (harvest now, decrypt later): the data key depends on the ML-KEM-768 secret, so breaking X25519 alone does not open an envelope. The reverse also holds: a flaw in ML-KEM alone does not open an envelope while X25519 stands.
  • Signature forgery with one broken scheme: a forger must produce valid Ed25519 and ML-DSA-65 signatures; the halves cannot be separated and reused as plain signatures over the raw message.
  • Tampering with ciphertexts: any modified byte, a different aad, a different key, a different version or a retired version is refused with a named reason.
  • Theft of the data directory without the root key: private keys are sealed with AES-256-GCM; key files carry an HMAC so that swapped public keys or a lowered min_decryption_version are refused.
  • Editing the audit log without the root key: rows are HMAC-chained; a deleted, changed or reordered row is detected.

Not defended:

  • Compromise of the running process or of the host (memory, environment, debugger).
  • An attacker with the root key.
  • Rollback of the whole data directory (or of one key file) to an older, validly-MACed state, e.g. to undo a raise of min_decryption_version. There is no monotonic counter.
  • Truncation of the audit log's tail is only detected against an externally recorded head (verifyAudit({ expectedHead })); on its own a hash chain cannot see missing trailing rows.
  • Side channels (timing, cache, power) of the underlying OpenSSL implementations were not evaluated.

Running

export AERE_KMS_ROOT_KEY=<64 hex characters>          # required; the service refuses to start without it
export AERE_KMS_TOKEN=<at least 32 printable chars>   # required; clients send "Authorization: Bearer <token>"
node server.mjs
variable default meaning
AERE_KMS_ROOT_KEY none (required, or AERE_KMS_ROOT_HSM) 32 bytes as hex. Missing, malformed or all-zero: refuses to start. Never generated automatically.
AERE_KMS_ROOT_HSM none path of a root sealed by an HSM (see above); exactly one of this and AERE_KMS_ROOT_KEY
AERE_HSM_MODULE none (required with AERE_KMS_ROOT_HSM) absolute path of the PKCS#11 library; must equal the one recorded in the sealed file
AERE_HSM_PIN none (required with AERE_KMS_ROOT_HSM) the token PIN, read by pkcs11-tool from the environment, never on a command line
AERE_KMS_TOKEN none (required) bearer token, 32+ printable ASCII characters, must differ from the root key
AERE_KMS_HOST 127.0.0.1 listen address
AERE_KMS_PORT 8420 listen port (0 picks a free one)
AERE_KMS_MAX_BODY 65536 request body limit in bytes, 8192 to 16777216 (a hybrid signature alone is 4531 base64 characters)
AERE_KMS_DATA_DIR ./data next to server.mjs key files, root check, audit log

A data directory is bound to the root key it was created with: opening it with another root key fails with ROOT_KEY_MISMATCH. No error message ever contains key material, the root key or the token.

Generate a root key with, for example: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".

HTTP API

All bodies are JSON. Binary values (plaintext, aad, message) are standard base64; ciphertexts and signatures are the self-describing strings below. Errors are { "error": CODE, "message": text }. Every route except GET /v1/health requires Authorization: Bearer <token>.

method and path body response
GET /v1/health { ok: true } (no auth)
GET /v1/keys { keys: [names] }
POST /v1/keys/:name { type: "encrypt"|"sign", exportable?: false } key description (public keys only)
GET /v1/keys/:name key description
POST /v1/keys/:name/rotate key description with a new latest version
POST /v1/keys/:name/config { min_decryption_version: n } key description
GET /v1/keys/:name/export[/:version] private keys (PKCS#8 DER, base64); 403 KEY_NOT_EXPORTABLE unless exportable
POST /v1/encrypt/:name { plaintext, aad? } { ciphertext, version }
POST /v1/decrypt/:name { ciphertext, aad? } { plaintext, version }
POST /v1/rewrap/:name { ciphertext, aad? } { ciphertext, version } (the plaintext never leaves the process)
POST /v1/datakey/:name { aad?, bits?: 128|256|512, include_plaintext?: true } { ciphertext, version, plaintext? }
POST /v1/sign/:name { message } { signature, version }
POST /v1/verify/:name { message, signature } { valid, reason, version, classical, post_quantum }
GET /v1/audit/verify { ok, rows, head } or { ok: false, reason, line }

Key names match ^[a-z0-9][a-z0-9_-]{0,63}$.

Versions, rotation and retirement

  • rotate adds a version; encrypt, datakey, rewrap and sign always use the latest one.
  • Older versions keep decrypting and verifying until min_decryption_version is raised past them. After that, decrypt and rewrap fail with VERSION_BELOW_MINIMUM, and verify returns valid: false, reason: "VERSION_BELOW_MINIMUM".
  • min_decryption_version can only be raised (MIN_VERSION_NOT_MONOTONIC), and never above the latest version (VERSION_OUT_OF_RANGE). Retired private keys stay on disk; there is no trim.
  • exportable is set at creation and cannot be changed. It defaults to false.

Error codes

code when
MALFORMED_CIPHERTEXT, MALFORMED_SIGNATURE not aerekms:v<n>:<base64>, non-canonical base64, wrong header, wrong length
VERSION_MISMATCH the prefix version and the embedded version differ, or the envelope belongs to another version of this key
KEY_MISMATCH the envelope was produced by a different key
UNKNOWN_VERSION the key has no such version
VERSION_BELOW_MINIMUM the version is retired by min_decryption_version
HEADER_AUTH_FAILED the key encapsulation, header or aad tag was modified, or the envelope was not produced for this key version (key commitment failed)
AAD_MISMATCH the aad differs from the one used at encryption
PAYLOAD_AUTH_FAILED the encrypted payload or its GCM tag was modified or truncated
CLASSICAL_SIGNATURE_INVALID, PQ_SIGNATURE_INVALID, BOTH_SIGNATURES_INVALID which half of a hybrid signature failed
KEY_NOT_FOUND, KEY_EXISTS, WRONG_KEY_TYPE, INVALID_KEY_NAME, INVALID_KEY_TYPE, INVALID_POLICY, INVALID_BITS, KEY_NOT_EXPORTABLE request errors
ROOT_KEY_MISSING, ROOT_KEY_INVALID, ROOT_KEY_WEAK, ROOT_KEY_MISMATCH, ROOT_CHECK_MISSING refuses to start
KEY_FILE_TAMPERED, SEAL_AUTH_FAILED a key file on disk fails its integrity check
AUDIT_CHAIN_INVALID refuses to start on an audit log that does not verify (move it aside as evidence to begin a new chain)
AUDIT_WRITE_FAILED the audit row could not be written; the result is withheld, and any change the operation made to the key file (a new key, a new version, a raised minimum) is rolled back, so a change exists only if its audit row exists
UNAUTHENTICATED, INVALID_TOKEN 401
BODY_TOO_LARGE 413

Library API

import { openKms } from './kms.mjs';
const kms = openKms({ dataDir: './data', rootKey: process.env.AERE_KMS_ROOT_KEY });
kms.createKey('orders', { type: 'encrypt' });            // exportable: false by default
const { ciphertext } = kms.encrypt('orders', 'secret', 'tenant-42');
const { plaintext } = kms.decrypt('orders', ciphertext, 'tenant-42');
kms.rotate('orders'); kms.rewrap('orders', ciphertext, 'tenant-42');
kms.setMinDecryptionVersion('orders', 2);
kms.datakey('orders', { aad: 'file-7', bits: 256, includePlaintext: true });
kms.createKey('releases', { type: 'sign' });
const { signature } = kms.sign('releases', 'artifact bytes');
kms.verify('releases', 'artifact bytes', signature);      // { valid, reason, classical, post_quantum, version }
kms.verifyAudit({ expectedHead });                         // expectedHead = an earlier kms.auditHead()

Refusals throw KmsError with a code from the table above (verify returns valid: false instead).

Wire format

Everything below is fixed by this implementation so that third parties can interoperate using only standard primitives; test/proba.mjs contains a second, independent implementation of it (including a hand-written HKDF) that decapsulates the service's envelopes and builds envelopes the service accepts.

Notation: u32 is big-endian, lp(x) is u32(len(x)) || x, strings are UTF-8, \0 is a zero byte.

Public keys and fingerprint

  • encrypt version: x25519 = raw 32-byte public key; ml_kem_768 = SPKI DER (1206 bytes; the raw 1184-byte encapsulation key follows a fixed 22-byte header).
  • sign version: ed25519 = raw 32 bytes; ml_dsa_65 = SPKI DER (1974 bytes; raw key 1952 bytes).
  • fingerprint = SHA-256("aerekms/v1/fingerprint\0" || kind || lp(name) || u32(version) || pubA_raw || pubB_raw)[0..8], with kind = 0x45 ('E') or 0x53 ('S').

Ciphertext: aerekms:v<version>:<base64(body)>

body = "AKM1" | 0x45 | u32 version | fp(8) | ePub(32) | ctK(1088) | aadTag(16) | commit(16) | payload | gcmTag(16)
  1. ePub is a fresh X25519 public key; ssX = X25519(eph, recipient_x25519).
  2. (ssK, ctK) = ML-KEM-768.Encaps(recipient_ml_kem_768).
  3. transcript = "aerekms/v1/hybrid-kem\0" || lp(name) || u32(version) || fp || recipient_x25519_raw || SHA-256(recipient_ek_raw) || ePub || ctK
  4. IKM = ssK || ssX, salt = SHA-256(transcript), and with HKDF-SHA-256:
    • commitKey = HKDF(IKM, salt, "aerekms/v1/commit", 32)
    • aadKey = HKDF(IKM, salt, "aerekms/v1/aad", 32), aadTag = HMAC-SHA-256(aadKey, aad)[0..16]
    • key || iv = HKDF(IKM, salt, "aerekms/v1/dek\0" || SHA-256(aad), 44)
  5. commit = HMAC-SHA-256(commitKey, body[0 .. commit offset))[0..16] (a key commitment over the whole header, including aadTag).
  6. payload || gcmTag = AES-256-GCM(key, iv, plaintext, AAD = body[0 .. payload offset)).

Decryption checks, in order: format, prefix version = embedded version, version exists and is not retired, fingerprint, decapsulation, commit, aadTag, GCM. Each step has its own error code. Absent aad and empty aad are the same thing.

The combiner is the common "concatenate both secrets, bind both encapsulations, then KDF" construction. No formal security proof is provided here.

Signature: aerekms:v<version>:<base64(body)>

body = "AKS1" | 0x53 | u32 version | fp(8) | ed25519_sig(64) | ml_dsa_65_sig(3309)
M'   = "aerekms/v1/hybrid-sig\0ed25519+ml-dsa-65\0" || fp || u32(version) || message

Ed25519 signs M'; ML-DSA-65 (pure, FIPS 204) signs M' with context string "aerekms/v1". Because both sign M' and not the raw message, neither half is a valid signature over the message on its own.

Storage

  • data/root-check.json: an HMAC under a key derived from the root key; detects a wrong root key.
  • data/keys/<name>.json: key metadata, public keys, and per version private_sealed = base64(iv(12) || AES-256-GCM ciphertext || tag(16)) of the PKCS#8 private keys, with AAD "aerekms/v1/seal\0" name "\0" type "\0" version "\0" fingerprint_hex. The whole file carries mac = HMAC-SHA-256 over its canonical JSON. Files are written atomically (temp file, fsync, rename).
  • Sub-keys are derived from the root key with HKDF-SHA-256 (salt "aerekms/v1/root", one info label each: seal, key-file MAC, audit chain, root check). The derived root key buffer is zeroed after derivation; the environment variable it came from is not (see the limits above).

Audit log: data/audit.log

One JSON object per line: { seq, ts, op, key, version, ok, reason, prev, mac }, where prev is the previous row's mac (64 zeros for the first row) and mac = HMAC-SHA-256(auditKey, canonical JSON of the row without mac). Rows never contain plaintext, aad, messages, ciphertexts or key material. Every operation writes a row, including refused ones (with ok: false and the reason code); the row is fsynced before the result is returned. verifyAudit() reports AUDIT_ROW_MODIFIED, AUDIT_CHAIN_BROKEN (deleted or reordered rows), AUDIT_ROW_UNPARSABLE, and, given an external head, AUDIT_TRUNCATED / AUDIT_HEAD_MISMATCH. Record auditHead() somewhere else if tail truncation matters.

Tests

node test/proba.mjs                        # the test suite
node test/control-negativ.mjs              # plants defects in a copy; each must turn named tests red
node test/control-negativ.mjs --autoproba  # decoy plants; the control itself must report each as a failure

test/proba.mjs pairs every positive assertion with a negative one and checks the refusal reason, not just that something failed. test/control-negativ.mjs copies the code to a temp directory, plants one defect at a time (KDF without the ML-KEM secret, KDF without the X25519 secret, verification that accepts one signature, ignored aad, ignored minimum version, unchained audit writer, verifier that skips the chain, verifier that skips row MACs, private key written in clear, key file MAC not checked, root key generated silently, fingerprint not checked, rewrap returning plaintext, missing auth accepted, body limit ignored), and requires the named tests to fail with the named message. It reads the suite's JSON output, not its exit code, and checks that the original files are byte-identical afterwards.

Measured on 2026-09-25, Windows 11, Node 24.14.1, OpenSSL 3.5.5: 61/61 tests pass; the negative control catches 15/15 plantings; the control's self-test gives 5/5 decoys their failure verdict. Not measured: Linux or macOS (including whether the 0o600/0o700 file modes are applied; Windows ignores them), throughput under load, behaviour with concurrent processes, side channels.