# 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. - **The declared algorithm is the issuer key's** (since 2026-10-19; AIP-25, finding C3): the signature algorithm a certificate declares must be the algorithm of its issuer's public key, and a trusted root's must be that of its own key (verdict `ALG_KEY`); a revocation list that verifies under its issuer's key but declares another algorithm is `CRL_ALG_KEY` (RFC 5280 Sections 4.1.2.3 and 5.1.2.2; RFC 9881). Until then a certificate declaring `id-ml-dsa-87`, signed with an ML-DSA-44 key, passed: the signature was verified with whatever key the issuer has, so a policy that reads the declared algorithm was misled. OpenSSL 3.5 refuses the same cases (`unable to get local issuer certificate`, `CRL signature failure`), measured in `test/proba.mjs`. 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`, `CRL_ALG_KEY`, `ALG_KEY`, `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` (28 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 (26 plantings, at least one per finding of the review and four for C3) 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.