285 lines
19 KiB
Markdown
285 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 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.
|