# Conformance

> **pkinative is held to corpora it did not write and to parsers it does not share code with.** A blocking gate runs the built package over x509-limbo and Wycheproof, pinned by commit and SHA-256, and compares serial, validity, CA flag and fingerprint with OpenSSL.

## The corpora

| Corpus | What it holds | Pinned at |
|---|---|---|
| [x509-limbo](https://github.com/C2SP/x509-limbo) | 9 793 x509-limbo test cases of certificate validation, from RFC 5280, the CA/Browser Forum, webpki, BetterTLS and pathological inputs | commit `118721335e67` |
| [Project Wycheproof](https://github.com/C2SP/wycheproof) | 1 530 Wycheproof ECDSA vectors on P-256, P-384 and P-521, including deliberately mis-encoded signatures | commit `3fa63dd0344a` |
| [NIST PKITS](https://csrc.nist.gov/projects/pki-testing) | 223 path-validation tests of the US Federal PKI — 405 certificates and 173 revocation lists, with certificate policies, policy mapping, key rollover and indirect CRLs | SHA-256 `592f66030d2e` of `PKITS_data.zip` |
| [RFC 5280](https://www.rfc-editor.org/rfc/rfc5280) | Not a corpus of cases but the text L5 is held to: 182 requirement sentences of §4.1 and §4.2, each accounted for | SHA-256 `a2f2628c0a83` of `rfc5280.txt` |
| [RFC 5652](https://www.rfc-editor.org/rfc/rfc5652), [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161), [RFC 6960](https://www.rfc-editor.org/rfc/rfc6960), [RFC 7292](https://www.rfc-editor.org/rfc/rfc7292), [RFC 7468](https://www.rfc-editor.org/rfc/rfc7468) | The texts of CMS, timestamps, OCSP, PKCS #12 and PEM, read the same way: 132 requirement sentences, each held by a named test or excluded with its reason | SHA-256 `dbd209ae7844`, `39fd17644ff2`, `7e63ffa1ea2c`, `168ce6749ac3`, `0b2c3c2087cc` of each `rfc<NNNN>.txt` |

`npm run conformance:fetch` downloads them into `test-output/corpora/` and refuses any file whose SHA-256 differs from `.github/checksums/`; nothing is committed. PKITS is distributed as a ZIP and nothing else, so it is pinned by the archive's own digest **and** by a per-file list of what was extracted from it — one digest fixes what NIST published, the other fixes what this project's ZIP reader made of it. `scripts/lib/corpora.ts` holds the pins, and the verify-docs rule `corpus-pin-parity` keeps the checksums, `THIRD-PARTY-NOTICES.md` and `docs/assets/ecosystem.json` in step with them.

## The levels

`scripts/validate-certs.ts` runs the package from `dist/` — what users get, not the sources:

- **L0 — pins and canaries.** The files match their SHA-256, and the case counts match the values declared in `ecosystem.json`, so a silently changed corpus fails loudly.
- **L1 — parse or refuse by the rules.** Every one of the 30 361 unique x509-limbo certificates parses, or throws a `PkiError`. A refusal is allowed only when every limbo case using that certificate expects failure, and only as recorded in the reviewed baseline `scripts/data/limbo-refusals.json`: 565 certificates refused — unknown GeneralName tags, malformed name forms, iPAddress lengths, empty subtree lists, a name-constraints extension that constrains nothing, an empty extended key usage, two truncated extension values (subjectAltName, authorityInfoAccess), an issuer RSA key that is not an RSAPublicKey, a duplicated extension, an emoji in a DNS name, and two denial-of-service certificates stopped by `maxNameAttributes`. A baseline entry that starts to parse is an `UNEXPECTED-PASS`; any other exception fails the gate. L1 also reads the decision surface `docs/data/refusals.frozen.json` directly: every refusal it promises whose certificate the corpus holds must be refused with the promised code, so that regenerating the baseline cannot drop one ([ADR 0014](../adr/0014-the-decision-surface-contract.md)).
- **L2 — byte identity.** Every certificate re-encodes byte for byte from its decoded tree, and the boundaries of `tbsCertificate` and `signatureValue` agree with `scripts/lib/raw-der.ts`, a walker written separately that never imports `src/`.
- **L3 — OpenSSL.** Every parsed certificate agrees with `node:crypto.X509Certificate` on serial number, validity, CA flag and SHA-256 fingerprint, and a sample of 202 — every 148th parsed certificate — agrees on serial number and fingerprint with the `openssl` command-line tool — two builds, the one inside Node and the system one. What is cross-checked is the values the tool reads, never its acceptance policy: a disagreement on a serial or a fingerprint always fails, while a non-reference implementation refusing a certificate (LibreSSL on macOS) is reported as not applicable, and fails only if it reads too few to be a cross-check at all.
- **L4 — other implementations.** There is no veraPDF for the PKI: no single reference a parser can be held to. The honest equivalent is confrontation, so a sample of the corpus is read again by implementations written by other people, in other languages, and the readings must agree. What is compared is never rendered text — every implementation prints a distinguished name its own way — but SHA-256 of exact DER slices: `subjectFp256` and `issuerFp256` over the encoded subject and issuer, `spkiKeyFp256` over the public key octets, `tbsFp256` over the signed bytes, plus the exact identifiers `keyAlgOid` and `version`. Each validator declares in its output which of those it can supply, so a tool that cannot reach a field says so instead of being special-cased — and `VALIDATORS` in `scripts/lib/validators.ts` pins the exact mask each must declare, so one that stops supplying a field fails instead of agreeing about less.

  Three validators, three lineages that share no parsing code with OpenSSL or with each other. On Windows, `windows-cryptoapi` reads through CryptoAPI and supplies five fields — every one but `tbsFp256`, which `X509Certificate2` does not expose. On Linux, `go-x509` reads through Go's `crypto/x509` and supplies all six, `tbsFp256` included, from the slices Go itself read; `python-cryptography` reads through pyca/cryptography's Rust parser and supplies five — every one but `spkiKeyFp256`, because pyca exposes no slice of the key it read. On macOS none runs, and the report says so. Over the 202-certificate sample both Linux validators agree with pkinative on every certificate they read, with the positive canary agreeing and no negative canary accepted; a validator may decline a certificate, which is its acceptance policy and is reported as not applicable, but one that reads fewer than half the sample fails.

  Three guards make agreement mean something. A **positive canary** every implementation reads is submitted first, so one that rejects everything is unmasked (`VACUOUS`). **Negative canaries** — a truncated certificate, no bytes at all, octets that are not ASN.1, a length that runs past the input — are submitted last, so one that accepts everything is unmasked (`XPASS`). A **footer** states how many certificates were read, so one that stops halfway and exits 0 is caught by the contract rather than by a guess. Neither canary verdict can be waived.

  A real difference between two implementations is recorded in `scripts/data/validator-disagreements.json` with the reason it is accepted, the way refusals are recorded in `limbo-refusals.json` — never silenced, never a permanent red. Today that file is empty. Every toolchain the matrix uses is on the runner or installed by the conformance workflow at a pinned version — Go through a commit-pinned `actions/setup-go`, pyca/cryptography from `scripts/data/interop-python-requirements.txt`, pinned by version and by the SHA-256 of every file — so nothing reaches the gate that a reviewer did not pin, and nothing is vendored. A validator that does not exist on a platform is reported as not applicable, never as a pass; one that should exist there and is missing fails under `--require-all`.
- **L5 — RFC 5280, clause by clause.** L1 to L4 are all **differential**: they prove agreement, not correctness. If pkinative, OpenSSL and `node:crypto` were wrong in the same way, L3 would be green. And a red L1 says *"certificate `ab3f…` was refused and the baseline says otherwise"* — a regression detector, not an authority.

  L5 makes the claim that is missing, and it is a claim about **attribution**. `scripts/lib/clauses.ts` holds 61 clauses — 58 of RFC 5280 §4.1 and §4.2, and 3 of the X.690 encoding rules the profile inherits — each quoting the normative sentence it enforces and naming the `PkiDiagnosticCode` that must report it. `scripts/validators/rfc5280-clauses.ts` decides each one from the raw bytes through `raw-der.ts`, without importing `src/` — two readings that shared a decoder could not disagree. The runner then fails on a violation pkinative is **silent** about, and, for a clause that claims to decide every case its diagnostic covers, on a diagnostic with **no violation behind it**: the two readings disagree, and one of them is wrong.

  Today a red run says a certificate changed behaviour. L5 says: *"RFC 5280 §4.2.1.9 is violated by 14 certificates of the corpus; pkinative diagnoses 13 and is silent on one."*

  A clause nothing triggers proves nothing, so every clause must be exercised by at least one corpus certificate — or carry a reviewed waiver saying why the corpus cannot reach it and naming the suite that does. Thirteen do, each because x509-limbo was built to ask about path validation and the field bears on none of it: no certificate with a unique identifier, a multi-valued relative distinguished name, policyMappings, inhibitAnyPolicy, issuerAltName, freshestCRL or subjectInfoAccess, and no LDAP URI and no nameRelativeToCRLIssuer in a distribution point or an access location. `tests/conformance/clauses.test.ts` builds a violating and a conforming certificate for **every** clause, which is also the only thing that could catch an evaluator that always answers "pass". §6 is not in this table and will not be: it is judged over chains, by the scored corpora of L6, L7 and L8 ([ADR 0008](../adr/0008-section-6-judged-by-scored-corpora.md)).

  **Completeness against the RFC itself.** Everything above holds each clause to the corpus; nothing there could say the table is missing a sentence, or that a clause quotes one the RFC does not contain. So RFC 5280 is pinned like a corpus — the plain text from rfc-editor.org, SHA-256 `a2f2628c0a83` — and `scripts/lib/rfc-requirements.ts` extracts from it every sentence of §4.1 and §4.2 carrying an RFC 2119 requirement keyword: `MUST`, `MUST NOT`, `SHALL`, `SHALL NOT`, `REQUIRED`, `SHOULD`, `SHOULD NOT`, `RECOMMENDED`, `NOT RECOMMENDED`. `MAY` and `OPTIONAL` are left out because a certificate cannot violate a permission; `SHOULD` is kept because pkinative diagnoses several, and a profile concern is exactly what a diagnostic is for. The extraction is deterministic — page furniture removed, ASN.1 fragments skipped, sentences split on the RFC's own two-space typography, whitespace collapsed — and finds **182 sentences** (117 carry `MUST`, 21 `MUST NOT`, 2 `SHALL`, 44 `SHOULD`, 8 `SHOULD NOT`; 49 in §4.1, 133 in §4.2).

  `scripts/data/rfc5280-requirements.json` accounts for each one, by an id made of its section, its rank and a hash of its text, exactly once: **57 are held by a clause** whose quote is that sentence, and **125 are excluded** with a reason from a fixed vocabulary and a sentence written by reading it — 31 `path-validation` (decided by §6 over a chain, and scored at L6–L8), 30 `not-decidable-from-bytes`, 24 `relying-party`, 23 `covered-elsewhere` (a structural refusal, a diagnostic or another clause, named), 16 `issuer-policy` and 1 `nothing-to-violate`. None is a recorded gap: the 36 sentences decidable from one certificate that pkinative did not report until 1.0 — mostly criticality flags, URI and LDAP URL syntax, and user-notice text — are each a diagnostic now and a clause like the others, 34 codes for 36 sentences because two pairs state one fact each. The L5 run is blocking on all of it: a clause quote not found verbatim in the section it cites is refused as invented, a sentence the inventory does not account for fails, an entry no sentence matches any more fails as stale, the counts are canaries in `ecosystem.json`, and an entry left `todo` fails under `--require-all` — which is what the conformance workflow and the release gate run. `tests/conformance/rfc-requirements.test.ts` holds the inventory's shape and vocabulary in the fast gate, where the RFC is not needed.

  Building it found four clauses whose quote the RFC does not contain — one of them a paraphrase of §4.1.2.1 in words RFC 5280 never uses — and six sentences pkinative already diagnosed with no clause behind them; the quotes are now verbatim and the six are clauses. The verified errata that touch §4 (3579, 5802, 6414) change ASN.1 and its comments, not a requirement sentence, so they are recorded in the inventory as reviewed rather than pinned; the RFCs that update RFC 5280 are separate documents, and one that changes a §4 requirement would be a new corpus.

  **The other RFCs pkinative implements are inventoried the same way** — the same extractor over their own pinned text, one registry each in `scripts/data/rfc<NNNN>-requirements.json`, the same ids, the same blocking checks, their counts canaries under `declared` in `ecosystem.json`. They have no clause table, so a sentence there is either held by a **test** — the entry names the Vitest file and the title of the case that exercises the sentence's keyword, and says how in a sentence of its own; a case renamed or removed fails the gate — or excluded with its reason. Two reasons join the vocabulary for them: `producer-policy`, a sentence that constrains the signer, TSA, OCSP responder or file writer in a way the bytes pkinative reads cannot show, or in a choice it leaves to its caller (a PEM label); and `not-implemented`, a sentence about a feature pkinative deliberately does not implement, naming the ADR, guide or registry entry that records the decision. RFC 7292 is the one text read in any case: it republishes PKCS #12 v1.1, does not cite RFC 2119, and writes every requirement as a lower-case "shall" or "should"; its Appendix B is not read, because the extractor reads numbered sections only (and pkinative refuses that KDF, [ADR 0002](../adr/0002-pkcs12-pbes2-only.md)).

  - RFC 5652 §5, §11.1–§11.4: **50 sentences**, **44 held by a test**, **6 excluded** — 4 `not-implemented` (AuthenticatedData), 1 `producer-policy`, 1 `relying-party`. Seven of the tests are the diagnostics that closed the gaps recorded until 1.0: the contents of a countersignature, which pkinative still carries unverified but now looks into as far as its attribute types (`PKI_DIAG_CMS_COUNTERSIGNATURE_CONTENT_TYPE`, `PKI_DIAG_CMS_COUNTERSIGNATURE_NO_MESSAGE_DIGEST`, `PKI_DIAG_CMS_COUNTERSIGNATURE_EMPTY`); a signer-less SignedData whose content is not the certificates-only form (`PKI_DIAG_CMS_CERTS_ONLY_CONTENT`); a signing time in GeneralizedTime for 1950–2049, or with fractional seconds (`PKI_DIAG_CMS_SIGNING_TIME_NOT_UTC`, `PKI_DIAG_CMS_SIGNING_TIME_FRACTION`).
  - RFC 3161 §2.3–§2.4: **38 sentences**, **28 held by a test**, **10 excluded** — 10 `producer-policy` (what the TSA checks, issues and keeps unique). The certReq contract between a request and its token is decided where the two meet, in `verifyTimeStampToken`, and reported through its `onDiagnostic` (`PKI_DIAG_TSP_CERTREQ_UNMET`, `PKI_DIAG_TSP_CERTS_UNREQUESTED`); the verdict never depends on it.
  - RFC 6960 §4.1–§4.2: **23 sentences**, **18 held by a test**, **5 excluded** — 2 `producer-policy`, 2 `issuer-policy`, 1 `not-implemented` (signed requests). The parser reports an empty certs field and an explicit version (`PKI_DIAG_OCSP_CERTS_EMPTY`, `PKI_DIAG_OCSP_VERSION_NOT_V1`, or `PKI_DIAG_DEFAULT_ENCODED` for a v1 written out); `checkOcspStatus`, given the certificate that signed, compares the responderID with it and reads its ocsp-nocheck flag, and counts the SingleResponses nobody asked about (`PKI_DIAG_OCSP_RESPONDER_ID_MISMATCH`, `PKI_DIAG_OCSP_NOCHECK_CRITICAL`, `PKI_DIAG_OCSP_SINGLE_RESPONSE_UNREQUESTED`) — `verifyCertificateChain` passes the signer it established and forwards all of it to its own `onDiagnostic`.
  - RFC 7292 §4–§5: **9 sentences**, **7 held by a test**, **2 excluded** — 2 `relying-party` (what an importing application does with each bag).
  - RFC 7468 §2–§5: **12 sentences**, **8 held by a test**, **4 excluded** — 2 `not-decidable-from-bytes`, 1 `not-implemented` (no PEM inside the certificate parser), 1 `producer-policy` (the label is the caller's).

  Writing them down added the tests that were missing where pkinative already had the behaviour — a non-DER BasicOCSPResponse, a non-canonical genTime and signing time, the single-instance rule for every signed attribute — and found one inconsistency kept as a recorded fact rather than hidden: under `encodingRules: 'ber'` the signed attributes are refused for a BER length or a constructed string, yet a signing time without seconds inside them is read with `PKI_DIAG_BER_CONSTRUCT_ACCEPTED`. The verified errata in the inventoried sections (RFC 3161 2931 and 2932, RFC 6960 6167, RFC 7468 4508) change ASN.1 comments or ABNF, not a requirement sentence, and are recorded as reviewed.
- **L6 — path validation, scored.** L1 to L5 judge certificates. L6 judges **chains**, which is the other half of what this library is for and the only half a boolean can be wrong about silently. Every x509-limbo case is built, name-matched and revocation-checked the way a caller would, and pkinative's verdict must match the one the corpus writes down: **9 158 of 9 208 agree, 99.46 %**, with 20 skipped and the rest refused at L1.

  Three things make that a claim rather than a statistic.

  **Reason codes, not the boolean.** A validator that rejects every chain scores well against a corpus that is mostly FAILURE, so 30 cases are pinned on their `PkiReasonCode` — *rejected for the wrong reason* is a defect no pass/fail count can see. CVE-2025-61727 is pinned on `PKI_REASON_NAME_EXCLUDED`, not merely on "refused".

  **Canaries.** One case that must succeed and one that must fail. A harness whose trust store silently stopped being passed through would still score in the high nineties; the positive canary is the only thing that notices.

  **Every deviation carries a sentence someone wrote.** The 52 disagreements are each either a defect or a decision, and only prose tells them apart — so `--update-baseline` records the id it measured and **never** fills in the reason, and an empty one fails the gate. `tests/conformance/limbo-score.test.ts` holds that discipline in the fast gate, where the corpus is not needed: the one thing a reviewer cannot catch by reading a diff is an empty string. What the deviations are, in short: CA/Browser Forum policy the RFC does not state (key sizes, a `commonName` that no `subjectAltName` repeats, webpki's extended-key-usage pedantry), profile sentences addressed to the issuing CA that pkinative reports as diagnostics instead of refusing, two readings x509-limbo itself calls ambiguous, and two gaps where this runner translates the corpus's vocabulary into pkinative's and the translation is what falls short.
- **L7 — NIST PKITS, the second corpus.** L6 proves agreement with **one** corpus. Two corpora written independently is a different claim, and it is the only one that catches a misreading the first shares: x509-limbo comes from the Python cryptography project and is built around the Web PKI, PKITS comes from NIST and is built around the US Federal PKI, with certificate policies, policy mapping and indirect CRLs the Web has never used. **195 of 203 NIST paths agree**, and every one of the 405 certificates and 173 lists parses — with no reviewed refusal list at all, because a certificate a standards body publishes in a conformance suite and this library cannot read is a defect here.

  **The expectations come from the file names and nowhere else.** PKITS states its results in a PDF, and parsing one to decide what a gate expects makes the gate as stable as a text-extraction heuristic. What the archive does carry is NIST's own convention: `Valid…Test1EE.crt` belongs to a test that must succeed and `Invalid…` to one that must fail. 203 of the 223 tests say so in their own name; the other 20 are §4.8 policy tests whose answer depends on a `user-initial-policy-set` the archive never states, and they are skipped with that reason rather than guessed.

  **The path is built, not transcribed.** PKITS describes each test's chain in the same prose, and deriving it removes 223 hand-copied lists — replacing them with something better: the whole bag of 404 certificates goes to `buildCertificatePath` every time, so each test also exercises path building on a candidate set that has cross-certificates and key rollovers in it. A test that passes only because somebody told the validator which chain to walk is a test of the transcription.

  The 8 deviations are three things and they are written out beside each test. Five are **name chaining**: §7.1 asks for LDAP string preparation and pkinative compares encoded bytes, as Go's `crypto/x509` and webpki do, because byte equality is the only comparison under which a cross-signed pair is decidable ([ADR 0005](../adr/0005-names-compared-by-encoded-bytes.md)). Two are **DSA**, which Web Crypto does not implement and pkinative will never implement itself. One is a suspected defect in §6.1.5's policy wrap-up, recorded alone for exactly that reason.

  **Every one of them refuses something PKITS says should pass.** Not one accepts something a standards body says to reject, in either baseline. That asymmetry is the point of writing the reasons down: it is a property a pass rate cannot show, and the only one that says which direction the remaining gaps err in. Until 0.9.0 it did not hold: a ninth deviation, `InvalidDNandRFC822nameConstraintsTest29`, was an **acceptance**, recorded as a name-comparison miss. It was not one — the end entity has no `subjectAltName`, and its subject `emailAddress` lies outside its issuer's permitted `rfc822Name` subtree, which §4.2.1.10 says the constraint MUST reach. 0.9.0 applies it, and the test agrees. The name-comparison rule has one fail-closed exception since the same release: an **excluded** `directoryName` subtree also matches after §7.1 preparation, because there a miss would be an acceptance.

  The archive is pinned **twice**, because one digest cannot do both jobs. Its own SHA-256 fixes every byte NIST published — which matters more here than for a repository, since NIST publishes no version and would overwrite the same URL. A per-file list in `.github/checksums/` fixes what this project's own ZIP reader (`scripts/lib/zip.ts`, about a hundred lines over `node:zlib`) made of it. And the instant every test is judged at is **fixed, not `Date.now()`**: the corpus lives between 2010 and 2030, and a gate that asked about today would answer the same thing every day until 2031 and then go red for reasons having nothing to do with this library. The runner fails with that message instead.
- **L8 — the PKITS signed messages, verified whole.** L7 judges a path to an end-entity certificate; L8 judges what that certificate **signed**. The pinned archive also holds 224 S/MIME messages, each a `multipart/signed` e-mail whose detached CMS SignedData was made with the end-entity key of one PKITS test. `scripts/lib/pkits-smime.ts` takes each apart — the exact octets the signer hashed, and the signature — and the runner hands both to `verifySignedData`, which is the call an S/MIME client makes: the PKITS trust anchor, the same 173 lists, the same fixed instant and the same `requireRevocation` as L7, and **no certificate beyond those the message carries**. L7 uses the default initial policy settings, so L8 does too. **All 224 messages are verified, 221 are intact at the CMS layer, and 196 of 204 verdicts agree with NIST — each one equal to the L7 verdict on its signer's own path.**

  **Which test a message belongs to is read from its signer, not from its name.** The message names are close to the certificate names and not equal to them: `SignedMissingCRLTest1.eml` is signed by `InvalidMissingCRLTest1EE.crt`, and both `SignedValidSignaturesTest1.eml` and `SignedAllCertificatesSamePolicyTest1.eml` by `ValidCertificatePathTest1EE.crt`. A table of those pairs would be another transcription; the `SignerIdentifier` inside each message already names its certificate, so the link is read from there, the expectation is L7's (the certificate's own name), and a message whose name says Valid or Invalid must agree with it. The 20 messages signed for the §4.8 policy tests are skipped for the same reason L7 skips those tests; they are still verified, and still held to claim (a).

  Two claims, kept apart because they fail for different reasons:

  - **(a) The CMS layer finds every message intact** — attributes, algorithms, the digest computed here, the signature, and the certificate the signer committed to. NIST signed every message correctly, so a signer that is not intact is a CMS defect or a signature this platform cannot check, and only a reviewed `notIntact` entry says which. Three are reviewed, all DSA: `ValidDSASignaturesTest4`, `ValidDSAParameterInheritanceTest5` and `InvalidDSASignatureTest6` are signed with DSA over SHA-1, which Web Crypto does not implement, so the signature is reported `PKI_REASON_SIGNATURE_NOT_CHECKED` — never invalid — and no chain is judged. The third agrees with NIST on the verdict, and is pinned on the code that actually decides it.
  - **(b) End to end, a message is its path.** The verdict on each scored message must equal the L7 verdict on its signer's test — L7's 8 reviewed deviations included — and a refused message must be refused for a reason that path is refused for. The comparison is on the *set* of codes, for two measured reasons: L7 hands the path builder the whole bag of 404 certificates, whose cross-certificates cost a `PKI_REASON_SIGNATURE_INVALID` detour a message that carries only its own chain never takes; and a list the message carries and the caller supplies again is reported once per copy. A message may not disagree with its own test without a reviewed `pathDisagreements` entry, and there are none.

  The 8 deviations are therefore L7's, message for message, and `scripts/data/pkits-smime-score.json` says so beside each: five name-chaining tests where §7.1 string preparation would match names pkinative compares by bytes, the suspected §6.1.5 policy wrap-up defect of `ValidSelfIssuedinhibitAnyPolicyTest9`, and the two DSA tests — which here stop one step earlier, at the message's own DSA signature. The discipline is L7's too: the gate fails on a new disagreement, an unexpected agreement, a changed reason code on any of the 68 pinned messages, a signer that becomes intact or stops being so, a canary that stops behaving (`ValidSignaturesTest1` must verify, `InvalidRevokedEETest3` must be refused), a count that drifts from `ecosystem.json`, or a reason left unwritten; `tests/conformance/pkits-smime.test.ts` holds that discipline in the fast gate, where the corpus is not needed.
- **Wycheproof.** Every ECDSA signature is decoded as a strict `Ecdsa-Sig-Value`: every valid vector must decode, and every vector flagged `BerEncodedSignature`, `InvalidEncoding` or `InvalidTypesInSignature` must be refused.

## The write direction

Everything above points one way: bytes someone else produced, read by pkinative. For what pkinative writes, the arrow points outward, and that direction has **no corpus** — nobody publishes a set of certificates a library is supposed to have written. The only oracle available is the tools themselves.

`npm run interop` (`scripts/run-interop.ts`) hands two sets of artefacts to every foreign tool it can find, and requires agreement on facts that have exactly one right answer — a serial number as an integer, a name attribute, the DNS names, whether a chain or a self-signature or a SignedData verifies, the digest and nonce a request carries — never on rendered text.

- **The frozen samples** of `scripts/lib/samples.ts`: the bytes `verify:samples` freezes by hash, so the bytes a baseline blesses are the bytes the tools are asked to read. They are Ed25519 only, because only a deterministic signature has a stable hash.
- **A set generated afresh on every run** (`scripts/lib/interop-artefacts.ts`), for every other signature family the API writes: RSA PKCS #1 v1.5, RSASSA-PSS with SHA-256, SHA-384 and SHA-512, ECDSA on P-256, P-384 and P-521, and Ed25519. For each: a CA with critical name constraints; a leaf carrying every extension a server certificate carries, a BMPString and a multi-valued RDN in its subject; a leaf shaped by the CA/Browser Forum baseline requirements; a CSR with and without the extensionRequest attribute; CMS SignedData attached, detached and identified by subject key identifier; an OCSP request — then three RFC 3161 TimeStampReqs. Every artefact as DER and as PEM. The keys are made by the harness through Web Crypto `generateKey`, never by `src/`, so nothing here has a stable hash and nothing is compared by one.

The readers, and what each is asked:

| Tool | Reads |
|---|---|
| `openssl` | certificates (DER and PEM), the chain with `-x509_strict`, the TLS purpose and the host name, CSR self-signatures, every SignedData with `cms -verify` and the content it returns, the OCSP request with `-req_text`, the TimeStampReqs with `ts -query -text` |
| `windows-cryptoapi` | every certificate, through CryptoAPI under Windows PowerShell 5.1 |
| `dotnet` | certificates (DER and PEM), the chain through `X509Chain` with a custom root and the server-authentication policy — on Windows, CryptoAPI's chain engine — CSRs through `LoadSigningRequest`, every SignedData through `SignedCms.CheckSignature`, the TimeStampReqs through `Rfc3161TimestampRequest` |
| `gnutls-certtool` | certificates, the chain with the host name and the purpose, CSR self-signatures |
| `go-x509` | certificates, the signature and the whole path as a TLS client builds it, CSR self-signatures |
| `python-cryptography` | certificates, each leaf's signature by its issuer, the Web PKI verifier on the baseline-shaped leaves, CSRs, the certificates a SignedData carries, the OCSP request |
| `java-keytool` | certificates and the PKIX path through the JDK, CSRs through `keytool -printcertreq` |
| `gpgsm` | every SignedData, with the CA in its trust list |
| `zlint`, `pkilint` | every certificate created: zlint with the RFC sources everywhere and the CA/Browser Forum, Mozilla, Chrome and Apple sources on what is shaped for the Web PKI in a family it admits; pkilint's `lint_pkix_cert` on each and `lint_pkix_signer_signee_cert_chain` on each leaf with its CA |

What decides whether the result means anything (`scripts/lib/interop-judge.ts`, every rule of it proved to fire by `tests/tools/interop.test.ts`):

- **A tool that refuses an artefact is a finding about pkinative.** This is the opposite of the read direction, where a foreign acceptance policy stricter than ours is not our defect. Here the bytes are ours. The one exception is a build that is not the reference — OpenSSL before 3.0, LibreSSL — whose refusals are reported as not applicable, exactly as L3 treats them, while every fact it does read must still agree.
- **A tool this runner cannot parse is a defect in the runner, never evidence about the certificate.** The two are separate states because conflating them is how a gate ends up red in one language and green in another: Windows `certutil` reads the artefacts perfectly and prints *"Numéro de série"* on a French system, and a runner with two states reported a good certificate as refused. Libraries are therefore driven by small programs in `scripts/validators/` that answer in NDJSON with API values; a command line is parsed for values, never for sentences.
- **A known limitation of a tool is reviewed, proved, and must stay true.** `TOOL_LIMITATIONS` in `scripts/lib/interop.ts` lists each check a tool cannot pass for a reason that is the tool's — .NET has no Ed25519, OpenSSL 3.0 has no EdDSA in CMS (declared only when the installed openssl fails to sign an Ed25519 SignedData itself), libksba cannot parse a subject-key-identifier signer, gpgsm 2.4.4 exits non-zero after importing a certificate with a multi-valued RDN (declared only on that exit), pyca reads PKCS #7 version 1 only, pkilint cannot verify an RSASSA-PSS signature, zlint mistakes a BMPString's zero octets for control characters — each with the control that proved it: the same check failing identically on an artefact OpenSSL made. A limitation whose check starts to pass fails the run as stale.
- **A lint error is never waived; a lint warning is reviewed.** zlint and pkilint must report no error on anything pkinative creates, except where `TOOL_LIMITATIONS` proves the linter wrong (above). Every warning is recorded in `scripts/data/lint-waivers.json` with the reason it is accepted — a commonName the baseline requirements call NOT RECOMMENDED, a subjectKeyIdentifier RFC 5280 asks for and the baseline requirements advise against — and the rule `lint-waiver-reviewed` holds the file's shape. A warning nobody reviewed fails the run, and so does a waiver that matched nothing.
- **A tool that compares nothing fails as vacuous.**

The matrix runs blocking inside the `conformance` job, whose three contexts the ruleset already requires, and in the release gate, both with `--require-all` per platform: a tool `REQUIRED_TOOLS` names for the runner fails the job when it is missing. On Linux that is `openssl` and `java-keytool`, which the image carries, and `gnutls-certtool`, `gpgsm`, `python-cryptography`, `go-x509`, `zlint` and `pkilint`, which the workflow installs pinned; on Windows `openssl`, `windows-cryptoapi` and `dotnet`, which the image carries; on macOS `openssl`, the system build. Windows CryptoAPI runs on Windows only. Every other implemented tool runs where the image carries it and where it has been proved (`TOOL_PLATFORMS`), and a Windows workstation reaches the Linux readers through WSL. Windows `certutil`, macOS `security`, and `dotnet` on Linux and macOS (`dotnet-linux`, `dotnet-macos` — it is proved on Windows only) are declared in `scripts/lib/interop.ts` with the reason each is pending, and `interop-matrix-declared` holds the declarations to ROADMAP.md, THIRD-PARTY-NOTICES.md, this page and the workflow, so a gap cannot quietly disappear.

The first run of the extended set found one defect: the RSASSA-PSS AlgorithmIdentifier was written without the NULL after each digest OID, which the CA/Browser Forum baseline requirements fix byte for byte — zlint and pyca's Web PKI verifier refused every RSASSA-PSS certificate pkinative wrote, while the whole gate had been green, because no foreign tool had ever been shown one.

### Key containers: the arrow turned back

pkinative reads PKCS#8 and PKCS#12 and never writes either — it may not encrypt or wrap a key, by policy — so for key containers the same runner points the other way: each implemented tool writes, pkinative reads (`scripts/lib/interop-keys.ts`, the `KEYS` lines of `npm run interop`). Every case is declared in `KEY_CONTAINER_CASES` of `scripts/lib/interop.ts`, the runner refuses to evaluate one that is not, and `interop-matrix-declared` holds that list to this page in both directions. Three things are compared on every file, none of them decided by pkinative alone:

- **What was written.** The tool says what it wrote — OpenSSL through `pkcs12 -info`, a PKCS#8 command through the scheme it was asked for, Windows through facts observed once and written beside its writer — and `parsePkcs12` or `parseEncryptedPrivateKeyInfo` must read the same MAC construction and the same encryption schemes.
- **What pkinative makes of it.** A file it can open, `openPkcs12` opens with the integrity the case promises, the certificate byte-identical to the tool's, and a key that signs data the certificate's public key verifies — checked by `openssl dgst` or `pkeyutl` where OpenSSL wrote the file, by node:crypto otherwise, never by pkinative. A file it refuses is reported and never thrown for, with the exact reason codes and every refused scheme named.
- **What the tool could not write is a skip, never a pass.** OpenSSL before 3.4 has no PBMAC1, LibreSSL has no `-legacy`, and the legacy provider is looked for where installers put it (the compiled-in `MODULESDIR`, beside the executable, `lib/ossl-modules`) and proved by loading it.

| Case | Written by | pkinative reads |
|---|---|---|
| `openssl:pkcs12-pbmac1` | `openssl pkcs12 -export -pbmac1_pbkdf2`, EC P-256, RSA 2048 and Ed25519: PBES2 with PBKDF2/HMAC-SHA-256 and AES-256-CBC on the key and the certificates, an RFC 9579 PBMAC1 MAC | valid, integrity `verified`, the key signs |
| `openssl:pkcs12-default` | `openssl pkcs12 -export`, the same three keys: the same PBES2 encryption under an HMAC-SHA-256 MAC keyed with RFC 7292 Appendix B (OpenSSL 3.0 and later; the tool's own `-info` decides, so an older default is held to the legacy expectation) | `PKI_REASON_PKCS12_INTEGRITY_UNVERIFIED` alone; valid with `allowUnverifiedIntegrity`, the key signs |
| `openssl:pkcs12-legacy` | `openssl pkcs12 -export -legacy`: the certificates under `pbeWithSHAAnd40BitRC2-CBC`, the key under `pbeWithSHAAnd3-KeyTripleDES-CBC`, an HMAC-SHA-1 Appendix B MAC | reported, never thrown: the integrity reason and `PKI_REASON_PKCS12_ENCRYPTION_UNSUPPORTED` naming each scheme |
| `openssl:pkcs12-wrong-password` | the PBMAC1 file, read under another password | `PKI_REASON_PKCS12_MAC_MISMATCH`, nothing decrypted |
| `openssl:pkcs12-converted` | the conversion SECURITY.md documents: `openssl pkcs12 -in legacy.p12 -legacy -aes256 -out bundle.pem`, then `openssl pkcs12 -export -in bundle.pem -pbmac1_pbkdf2 -out modern.p12` | valid, integrity `verified`, the key signs; when the conversion fails, a failure — unless that openssl cannot run the second command on an encrypted PEM key it wrote itself with `pkey -aes256`, and then a skip naming it |
| `openssl:pkcs8-pbes2` | `openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256`, the three keys | `decryptPrivateKey` returns a key that signs |
| `openssl:pkcs8-pbes1` | `openssl pkcs8 -topk8 -v1 PBE-SHA1-3DES` | `decryptPrivateKey` throws `PKI_KEY_ENCRYPTION_UNSUPPORTED` naming the scheme |
| `windows-cryptoapi:pfx-export` | `X509Certificate2.Export(X509ContentType.Pkcs12, password)` from Windows PowerShell, on a certificate made in memory by `CertificateRequest.CreateSelfSigned` (no store touched), EC P-256 and RSA 2048: the key shrouded and the certificates encrypted, both under `pbeWithSHAAnd3-KeyTripleDES-CBC`, an HMAC-SHA-1 Appendix B MAC, 2000 iterations, DER throughout | reported, never thrown: the integrity reason and 3DES named twice |
| `windows-cryptoapi:pfx-pbes2` | `X509Certificate2.ExportPkcs12(Pkcs12ExportPbeParameters.Pbes2Aes256Sha256, password)` from PowerShell 7 on .NET 9 or later: PBES2 with PBKDF2/HMAC-SHA-256 and AES-256-CBC, an HMAC-SHA-256 Appendix B MAC — .NET writes no PBMAC1 — 2000 iterations, DER throughout | `PKI_REASON_PKCS12_INTEGRITY_UNVERIFIED` alone; valid with `allowUnverifiedIntegrity`, the key signs |

The first run found one defect, in the report rather than the reading: a key shrouded under a PKCS#12 scheme, in a file whose certificates sit in contents the same refusal keeps shut — exactly what `-legacy` and a default Windows export write — was reported as `PKI_REASON_PKCS12_KEY_UNMATCHED`, whose remedy sends the caller after a certificate that could not have helped. It is now reported under its own scheme. Windows wrote DER at every level `parsePkcs12` reads; `encodingRules: 'ber'` was not needed for any of it.

### Signed structures: what the tools sign, pkinative verifies

The same arrow for everything else pkinative verifies and never writes in the tool's dialect: CMS SignedData, RFC 3161 tokens, OCSP responses and CRLs (`scripts/lib/interop-reads.ts`, the `READS` lines). Each tool makes its own keys, its own CA and its own signers, signs, revokes and stamps — so the tool is the oracle: what it signed verifies, what it revoked is revoked, what it stamped is that imprint. Every case is declared in `READ_CASES` of `scripts/lib/interop.ts` with the verdict pkinative must reach, and `interop-matrix-declared` holds that list to the table below in both directions. pkinative is handed only the DER the tool wrote; PEM is unwrapped with Node's base64, never with pkinative's own decoder. A case the installed build cannot write is a skip when the tool is not the reference build, and a failure when it is.

| Case | Written by | pkinative decides |
|---|---|---|
| `openssl:cms-rsa` | `openssl cms -sign -nodetach`, RSA 2048 with PKCS #1 v1.5 | `verifySignedData` valid |
| `openssl:cms-rsa-pss` | `openssl cms -sign -keyopt rsa_padding_mode:pss`, detached | valid |
| `openssl:cms-ecdsa` | `openssl cms -sign -nodetach -md sha384`, ECDSA P-384 | valid |
| `openssl:cms-ed25519` | `openssl cms -sign -md sha512`, Ed25519, detached | valid |
| `openssl:cms-ber-stream` | `openssl cms -sign -stream -nodetach`: indefinite-length BER | refused under the default DER rules with a message naming `encodingRules: 'ber'`; valid under them |
| `openssl:cms-tampered` | the attached SignedData with one content octet changed afterwards | not valid: `PKI_REASON_CMS_DIGEST_MISMATCH` |
| `openssl:tsp-reply` | `openssl ts -reply` to an `openssl ts -query -cert`, an RSA TSA whose timeStamping EKU is critical | `verifyTimeStampToken` valid against the request and the data; `PKI_REASON_TSP_IMPRINT_MISMATCH` against other data |
| `openssl:ocsp-good` | `openssl ocsp -index`, answered by a delegated responder (`id-kp-OCSPSigning`, `ocspNoCheck`) | `verifyCertificateChain` valid with `requireRevocation` |
| `openssl:ocsp-revoked` | the same responder, after `openssl ca -revoke -crl_reason keyCompromise` | `PKI_REASON_REVOKED` |
| `openssl:crl-base` | `openssl ca -gencrl` after that revocation | the revoked certificate `PKI_REASON_REVOKED`, another valid |
| `openssl:crl-delta` | `openssl crl -gendelta -sha256` between that CRL and the next, after a second revocation | with the base alone the second certificate is valid; with the base and the delta, `PKI_REASON_REVOKED` |
| `openssl:csr` | `openssl req -new` for every key the matrix issues a certificate to (RSA 2048, ECDSA P-256 and P-384, Ed25519) | `parseCertificationRequest` with zero diagnostics and the subject the tool was given; `verifyCertificationRequest` valid |
| `gnutls-certtool:crl` | `certtool --generate-crl` over an ECDSA CA and one certificate it issued | the listed certificate `PKI_REASON_REVOKED`, the other valid |
| `gpgsm:cms-detached` | `gpgsm --detach-sign`, with an RSA key imported from PKCS #12 | valid |
| `gpgsm:cms-attached` | `gpgsm --sign`: indefinite-length BER | valid with `encodingRules: 'ber'` |
| `dotnet:cms-rsa` | `SignedCms.ComputeSignature`, RSA PKCS #1 v1.5, detached, IssuerAndSerialNumber | valid |
| `dotnet:cms-rsa-ski` | the same, attached, SubjectKeyIdentifier, SHA-512 | valid |
| `dotnet:cms-rsa-pss` | the same with `RSASignaturePadding.Pss` | valid |
| `dotnet:cms-ecdsa` | the same, ECDSA P-256 | valid |

Writing the delta case found something worth knowing about the tool rather than the library: `openssl crl -gendelta` signs with SHA-1 unless it is given a digest, and pkinative does not take a SHA-1 signature as evidence, so a delta written that way is not applied — the certificate it revokes stays valid. The case passes `-sha256`.

## What is not claimed yet

The 20 PKITS §4.8 certificate-policy tests are skipped, at L7 and — for the 20 messages their end entities signed — at L8, and they are the only tests of either corpus that are. Their expected result depends on the `user-initial-policy-set` a validator is given; PKITS answers per setting in its prose, the archive states none of them, and a runner that guessed would be scoring its own guess. A reviewed transcription belongs in `scripts/data/pkits-score.json`, where the 8 deviations already live.

**And the score is a measurement, not a promise.** It moves when the corpus is re-pinned, which is why the 1.0 freeze does not name it: what gets frozen is the *decision surface* — which certificate pkinative refuses and with which code — because that is a property of this library rather than of somebody else's test suite. The deviations file is where the two meet, and it is meant to shrink — it has, twice, and each time because an entry said plainly what was missing. The key-identifier entries said `createCertificate` had to be able to emit `subjectKeyIdentifier` and `authorityKeyIdentifier` before their absence could be diagnosed, or everything this library builds would trip its own reader; `computeKeyIdentifier` closed that, and those certificates are now reported rather than passed over in silence. The entries remain, because a missing key identifier is a hint and not a verdict — but they now record a decision instead of a gap.

## Where it runs

`.github/workflows/conformance.yml` runs the gate on every push to `main` and every pull request into it, weekly, and on demand, on Linux, Windows and macOS — three required checks, `conformance`, `conformance-windows` and `conformance-macos`. Three platforms is not redundancy: the command-line tool the L3 level cross-checks against differs by runner (OpenSSL, the Git for Windows build, LibreSSL), and so do line endings, path separators and the default shell. The release gate (`npx tsx scripts/gate.ts --publish --require-all`) runs it too. Locally:

```bash
npm run build
npm run conformance:fetch
npx tsx scripts/validate-certs.ts --require-all
```

## Fixtures

Six public certificates are committed as known-answer fixtures, each with its source, retrieval date and SHA-256 in `tests/fixtures/PROVENANCE.md`: ISRG Root X1 and X2, Let's Encrypt E7 and R12, the letsencrypt.org end-entity certificate, and the RFC 8410 §10.2 example. Their expected values were read with OpenSSL, and every one is also run through the `node:crypto` differential.
