aere-quantum/pq-kms/README.md

289 lines
19 KiB
Markdown

# 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
```sh
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
```js
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
```sh
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.