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_INVALIDuntil the log is moved aside: it is a stop, not a degradation. -
Audit verification is linear. Startup and
GET /v1/audit/verifyre-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.1by 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_versionare 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
rotateadds a version;encrypt,datakey,rewrapandsignalways use the latest one.- Older versions keep decrypting and verifying until
min_decryption_versionis raised past them. After that, decrypt and rewrap fail withVERSION_BELOW_MINIMUM, and verify returnsvalid: false, reason: "VERSION_BELOW_MINIMUM". min_decryption_versioncan 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.exportableis set at creation and cannot be changed. It defaults tofalse.
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
encryptversion: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).signversion: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], withkind=0x45('E') or0x53('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)
ePubis a fresh X25519 public key;ssX = X25519(eph, recipient_x25519).(ssK, ctK) = ML-KEM-768.Encaps(recipient_ml_kem_768).transcript = "aerekms/v1/hybrid-kem\0" || lp(name) || u32(version) || fp || recipient_x25519_raw || SHA-256(recipient_ek_raw) || ePub || ctKIKM = 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)
commit = HMAC-SHA-256(commitKey, body[0 .. commit offset))[0..16](a key commitment over the whole header, includingaadTag).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 versionprivate_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 carriesmac= 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.