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 | 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 | 1 530 Wycheproof ECDSA vectors on P-256, P-384 and P-521, including deliberately mis-encoded signatures | commit 3fa63dd0344a |
| NIST PKITS | 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 | 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, RFC 3161, RFC 6960, RFC 7292, RFC 7468 | 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 baselinescripts/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 bymaxNameAttributes. A baseline entry that starts to parse is anUNEXPECTED-PASS; any other exception fails the gate. L1 also reads the decision surfacedocs/data/refusals.frozen.jsondirectly: 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).L2 β byte identity. Every certificate re-encodes byte for byte from its decoded tree, and the boundaries of
tbsCertificateandsignatureValueagree withscripts/lib/raw-der.ts, a walker written separately that never importssrc/.L3 β OpenSSL. Every parsed certificate agrees with
node:crypto.X509Certificateon 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 theopensslcommand-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:
subjectFp256andissuerFp256over the encoded subject and issuer,spkiKeyFp256over the public key octets,tbsFp256over the signed bytes, plus the exact identifierskeyAlgOidandversion. 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 β andVALIDATORSinscripts/lib/validators.tspins 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-cryptoapireads through CryptoAPI and supplies five fields β every one buttbsFp256, whichX509Certificate2does not expose. On Linux,go-x509reads through Go'scrypto/x509and supplies all six,tbsFp256included, from the slices Go itself read;python-cryptographyreads through pyca/cryptography's Rust parser and supplies five β every one butspkiKeyFp256, 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.jsonwith the reason it is accepted, the way refusals are recorded inlimbo-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-pinnedactions/setup-go, pyca/cryptography fromscripts/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:cryptowere wrong in the same way, L3 would be green. And a red L1 says "certificateab3fβ¦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.tsholds 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 thePkiDiagnosticCodethat must report it.scripts/validators/rfc5280-clauses.tsdecides each one from the raw bytes throughraw-der.ts, without importingsrc/β 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.tsbuilds 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).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β andscripts/lib/rfc-requirements.tsextracts 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.MAYandOPTIONALare left out because a certificate cannot violate a permission;SHOULDis 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 carryMUST, 21MUST NOT, 2SHALL, 44SHOULD, 8SHOULD NOT; 49 in Β§4.1, 133 in Β§4.2).scripts/data/rfc5280-requirements.jsonaccounts 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 β 31path-validation(decided by Β§6 over a chain, and scored at L6βL8), 30not-decidable-from-bytes, 24relying-party, 23covered-elsewhere(a structural refusal, a diagnostic or another clause, named), 16issuer-policyand 1nothing-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 inecosystem.json, and an entry lefttodofails under--require-allβ which is what the conformance workflow and the release gate run.tests/conformance/rfc-requirements.test.tsholds 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 underdeclaredinecosystem.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); andnot-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).- RFC 5652 Β§5, Β§11.1βΒ§11.4: 50 sentences, 44 held by a test, 6 excluded β 4
not-implemented(AuthenticatedData), 1producer-policy, 1relying-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, inverifyTimeStampToken, and reported through itsonDiagnostic(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, 2issuer-policy, 1not-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, orPKI_DIAG_DEFAULT_ENCODEDfor 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) βverifyCertificateChainpasses the signer it established and forwards all of it to its ownonDiagnostic. - 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, 1not-implemented(no PEM inside the certificate parser), 1producer-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 withPKI_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.- RFC 5652 Β§5, Β§11.1βΒ§11.4: 50 sentences, 44 held by a test, 6 excluded β 4
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 onPKI_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-baselinerecords the id it measured and never fills in the reason, and an empty one fails the gate.tests/conformance/limbo-score.test.tsholds 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, acommonNamethat nosubjectAltNamerepeats, 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.crtbelongs to a test that must succeed andInvalidβ¦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 auser-initial-policy-setthe 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
buildCertificatePathevery 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/x509and webpki do, because byte equality is the only comparison under which a cross-signed pair is decidable (ADR 0005). 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 nosubjectAltName, and its subjectemailAddresslies outside its issuer's permittedrfc822Namesubtree, 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 excludeddirectoryNamesubtree 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 overnode:zlib) made of it. And the instant every test is judged at is fixed, notDate.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/signede-mail whose detached CMS SignedData was made with the end-entity key of one PKITS test.scripts/lib/pkits-smime.tstakes each apart β the exact octets the signer hashed, and the signature β and the runner hands both toverifySignedData, which is the call an S/MIME client makes: the PKITS trust anchor, the same 173 lists, the same fixed instant and the samerequireRevocationas 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.emlis signed byInvalidMissingCRLTest1EE.crt, and bothSignedValidSignaturesTest1.emlandSignedAllCertificatesSamePolicyTest1.emlbyValidCertificatePathTest1EE.crt. A table of those pairs would be another transcription; theSignerIdentifierinside 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
notIntactentry says which. Three are reviewed, all DSA:ValidDSASignaturesTest4,ValidDSAParameterInheritanceTest5andInvalidDSASignatureTest6are signed with DSA over SHA-1, which Web Crypto does not implement, so the signature is reportedPKI_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_INVALIDdetour 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 reviewedpathDisagreementsentry, and there are none.
The 8 deviations are therefore L7's, message for message, and
scripts/data/pkits-smime-score.jsonsays 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 ofValidSelfIssuedinhibitAnyPolicyTest9, 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 (ValidSignaturesTest1must verify,InvalidRevokedEETest3must be refused), a count that drifts fromecosystem.json, or a reason left unwritten;tests/conformance/pkits-smime.test.tsholds that discipline in the fast gate, where the corpus is not needed.- (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
Wycheproof. Every ECDSA signature is decoded as a strict
Ecdsa-Sig-Value: every valid vector must decode, and every vector flaggedBerEncodedSignature,InvalidEncodingorInvalidTypesInSignaturemust 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 bytesverify:samplesfreezes 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 CryptogenerateKey, never bysrc/, 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
certutilreads 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 inscripts/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_LIMITATIONSinscripts/lib/interop.tslists 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_LIMITATIONSproves the linter wrong (above). Every warning is recorded inscripts/data/lint-waivers.jsonwith 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 rulelint-waiver-reviewedholds 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 β andparsePkcs12orparseEncryptedPrivateKeyInfomust read the same MAC construction and the same encryption schemes. - What pkinative makes of it. A file it can open,
openPkcs12opens 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 byopenssl dgstorpkeyutlwhere 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-inMODULESDIR, 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:
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.