aere-quantum/pq-pki
2026-09-29 17:42:36 +03:00
..
test Add crypto-inventory: a cryptographic inventory of source code as a CycloneDX 1.6 CBOM, with linear cost on hostile input; PKI DER messages in English 2026-09-29 17:42:36 +03:00
cli.mjs Aere Quantum: a post-quantum TLS 1.3 gateway (X25519MLKEM768, optional ML-DSA client authentication), a hybrid KMS (X25519 + ML-KEM-768, Ed25519 + ML-DSA-65; root key from the environment or sealed by an HSM through PKCS#11) and an ML-DSA private CA (X.509 v3, RFC 9881). Zero dependencies, Node.js 24 with OpenSSL 3.5. Each with its test suite and a negative control. 2026-09-29 16:24:04 +03:00
der.mjs Add crypto-inventory: a cryptographic inventory of source code as a CycloneDX 1.6 CBOM, with linear cost on hostile input; PKI DER messages in English 2026-09-29 17:42:36 +03:00
pki.mjs Add crypto-inventory: a cryptographic inventory of source code as a CycloneDX 1.6 CBOM, with linear cost on hostile input; PKI DER messages in English 2026-09-29 17:42:36 +03:00
README.md Aere Quantum: a post-quantum TLS 1.3 gateway (X25519MLKEM768, optional ML-DSA client authentication), a hybrid KMS (X25519 + ML-KEM-768, Ed25519 + ML-DSA-65; root key from the environment or sealed by an HSM through PKCS#11) and an ML-DSA private CA (X.509 v3, RFC 9881). Zero dependencies, Node.js 24 with OpenSSL 3.5. Each with its test suite and a negative control. 2026-09-29 16:24:04 +03:00

Aere PQ PKI

A private certificate authority that issues post-quantum X.509 v3 certificates signed with ML-DSA (FIPS 204, the pure form with an empty context, as RFC 9881 specifies for certificates), revocation lists signed the same way, and a strict chain verifier. Node 24 with its bundled OpenSSL 3.5, no dependencies.

With a chain from this CA, a TLS 1.3 server is post-quantum end to end: the key exchange is X25519MLKEM768 and the server authenticates with an mldsa65 signature. Measured in the probes: a Node server with our chain, and both openssl s_client (3.5) and a Node client accept it (Verify return code: 0, Peer signature type: mldsa65, Negotiated TLS1.3 group: X25519MLKEM768).

export AERE_PKI_PASSPHRASE='...12+ characters with 3 character classes, or 20+ characters...'
node cli.mjs init         --dir ca --name root --org "Example"             # ML-DSA-87 root, pathLen 1, 10 years
node cli.mjs intermediate --dir ca --ca root --name issuing                 # ML-DSA-65, pathLen 0, 5 years
node cli.mjs issue        --dir ca --ca issuing --cn api.internal --dns api.internal --out api   # 90 days, serverAuth
node cli.mjs revoke       --dir ca --ca issuing --cert api.crt
node cli.mjs crl          --dir ca --ca issuing --out issuing.crl
node cli.mjs verify       --roots ca/root.crt --chain api.chain.pem --host api.internal --crl issuing.crl
node cli.mjs unseal       --key api.key --out api.p8.pem                    # unencrypted PKCS#8 for a TLS server, only on request

Nothing is ever overwritten, and without AERE_PKI_PASSPHRASE nothing is issued.

Private keys on disk

Private keys are written sealed in a format of our own, never as PKCS#8:

-----BEGIN AERE PQ PKI PRIVATE KEY-----
  SEQUENCE {
    header SEQUENCE { version 1, "scrypt", salt (16 bytes), N=131072, r=8, p=1, "aes-256-gcm", iv (12 bytes) },
    tag (16 bytes),
    ciphertext          -- the PKCS#8 DER of the key, AES-256-GCM under scrypt(passphrase, salt), with the header as AAD
  }
-----END AERE PQ PKI PRIVATE KEY-----

Why: the PKCS#8 encryption that node:crypto produces (key.export({cipher})) derives its key with PBKDF2 at 2048 iterations, which the adversarial review measured at 3 to 5 ms per passphrase guess. With scrypt at N=2^17, r=8, p=1 (128 MiB of memory per guess) one guess costs about 0.7 to 1.0 s on the development laptop (Intel i7-10870H, Node 24 scryptSync; the probe measures it on every run and prints the number, and fails under 25 ms). The header is bound as AAD, so a changed parameter, salt, IV or ciphertext byte fails authentication exactly like a wrong passphrase (KEY_LOCKED); the reader accepts N only between 2^14 and 2^20 (KEY_FORMAT otherwise), so a crafted file cannot make it allocate gigabytes. PKCS#8 files, encrypted or not, are refused by importPrivateKey with KEY_FORMAT; a key made before this format has to be re-sealed.

The passphrase policy (checkPassphrase, applied when a key is written): at least 12 characters and at least 3 of the 4 classes (lower, upper, digit, other), or at least 20 characters; one repeated character is refused; the obvious ones (password, qwerty, 123456, letmein, ... with or without digits and punctuation appended) are refused. Refusals carry PASSPHRASE.

A Node TLS server loads the key in process, without ever writing it in clear: tls.createServer({ key: importPrivateKey(fs.readFileSync('api.key', 'utf8'), passphrase).export({ type: 'pkcs8', format: 'pem' }), cert }). For servers that only read files, node cli.mjs unseal writes an unencrypted PKCS#8 file with mode 0600 and never overwrites; delete it when it is no longer needed. readSealedKeyHeader(pem) returns the KDF parameters without opening the key.

What the verifier checks

verifyChain always returns a verdict { ok, code, reason, chain }; it never throws, whatever bytes it is given (the probe feeds it hundreds of random mutations, truncations and garbage).

  • Path building: issuer/subject matched byte-exactly, key identifiers matched when present, and every candidate path is tried, trust anchors first: with a renewed root (two anchors with the same name and key, one expired) or a cross-signed intermediate listed before the right one, the valid path is found. If no path validates, the failure of the last path tried is returned (UNTRUSTED when no path reaches an anchor).
  • Every link: a valid ML-DSA signature, a CA issuer (basicConstraints) with keyCertSign, the issuer's pathLen, validity at the time of use, no unknown critical extension; the trusted root must be a valid self-signed certificate.
  • Public keys: the subjectPublicKeyInfo of every certificate must carry a key of exactly the FIPS 204 length for its algorithm (1312 / 1952 / 2592 bytes for ML-DSA-44 / 65 / 87) with no unused bits; anything else is the verdict SPKI, on the leaf too (its key is never used by the verifier, so without this check a 10-byte "key" passed).
  • Extended key usage on authorities: when an issuer in the chain, root included, carries an EKU, the purpose asked for must be in it (verdict EKU, naming the authority), as OpenSSL, Chrome and Mozilla do. anyExtendedKeyUsage does not satisfy a purpose, as in OpenSSL.
  • Revocation, for each issuer in the chain: among that issuer's lists that verify under its key and are not dated in the future, the one with the newest thisUpdate is used (ties broken by the larger crlNumber); the order of the input does not matter. That list must be current (CRL_STALE after nextUpdate), and a listed serial is REVOKED. requireCrl makes a missing list CRL_MISSING. A list whose signature does not verify is CRL_SIGNATURE.
  • CRL extensions: authorityKeyIdentifier and crlNumber are understood; issuingDistributionPoint and deltaCRLIndicator are refused however they are marked, and so is any other critical extension of the list or of an entry (certificateIssuer of an indirect list, for instance): verdict CRL_EXT, naming the extension. Non-critical unknown extensions (a reasonCode on an entry, for instance) are ignored.
  • End entity: not a CA, digitalSignature, the extended key usage of the purpose, and the host name or IPv4 address in the subject alternative name.

Every refusal carries a code: SIGNATURE, UNTRUSTED, ROOT, EXPIRED, NOT_YET_VALID, NOT_CA, KEY_USAGE, PATH_LEN, EKU, SPKI, REVOKED, CRL_MISSING, CRL_SIGNATURE, CRL_STALE, CRL_EXT, LEAF_IS_CA, HOSTNAME, CRITICAL_EXT, ALG, ALG_MISMATCH, ALG_PARAMS, VERSION, EXT_DUP, DER. The DER parser is strict: no indefinite lengths, no non-minimal lengths, no trailing bytes, no non-canonical integers or object identifiers; object identifiers with a multi-byte first subidentifier (2.999 is 06 02 88 37) encode and decode as OpenSSL does.

How it is proven

node test/proba.mjs (27 probes, about 25 s) compares every verdict with a foreign implementation, the OpenSSL 3.5 command line in -x509_strict mode: the valid chain, revocation, the newest of several lists (both orders), lists made by openssl ca -gencrl (plain, with a revoked entry and a reason code, with a critical issuing distribution point, with an unknown critical extension), a delta list (OpenSSL refuses it only with -extended_crl), a changed byte, another root, expiry and not-yet-valid, a path-length violation, a non-CA issuer (a chain made by OpenSSL), host and IP names, purpose on the leaf and on authorities (unsuitable certificate purpose at depth 1), a renewed root (both anchors in one CAfile), malformed public keys (decode error), an unknown critical extension (made by OpenSSL), object identifier bytes (asn1parse -genstr). It also reads and verifies certificates made by OpenSSL, runs the real TLS handshake, measures the cost of a passphrase guess, exercises the passphrase policy, and drives the command line end to end. node test/control-negativ.mjs (about 3 minutes, four plantings in parallel) breaks each check in a copy of the code (22 plantings, at least one per finding of the review) and requires the named probe to turn red, the original to stay byte-identical, and the untouched suite to stay green; the number of probes is taken from the untouched run, not written down.

What it does not do, and where it differs from OpenSSL

  • Composite (hybrid classical + ML-DSA) certificates: still IETF drafts; this CA issues pure ML-DSA certificates. A client that only knows classical algorithms cannot verify them.
  • OCSP, name constraints, certificate policies, IPv6 names, delta and partial (IDP) revocation lists: not implemented; delta and IDP lists are refused (CRL_EXT) rather than misread. OpenSSL without -extended_crl silently treats a delta list as a complete one; we do not.
  • A list without nextUpdate is accepted for as long as it is the newest one, as OpenSSL does; an operator who wants freshness enforced must publish lists with nextUpdate.
  • A leaf without keyUsage is refused here (KEY_USAGE); OpenSSL -x509_strict accepts it.
  • Backtracking between intermediates: we find the valid path when a cross-signed intermediate is listed first; OpenSSL does not and refuses that input.
  • Name comparison is byte-exact on the DER encoding, not the full RFC 5280 normalization; chains issued here always encode names the same way.
  • The CA key is protected by a passphrase on disk (scrypt + AES-256-GCM, above), not by an HSM. Keep the root offline. PKCS#8 is no longer the on-disk format; unseal produces it only on request.
  • The second implementation used in the probes (OpenSSL) shares the ML-DSA code with Node, since Node bundles OpenSSL; the X.509 encoding, path building and revocation logic are independent.