# 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.