37 KiB
Capability Spec: x509
Package: @suaveplan/x509
Status: CANONICAL — archived 2026-05-11.
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 13.)
Requirements
Requirement: ASN.1 BER/DER decoder for the X.509 subset
The package SHALL export decodeAsn1(bytes: Uint8Array, mode?: "der" | "ber"): Asn1Node covering every ASN.1 production used by RFC 5280 Appendix A: primitive types (INTEGER, BIT STRING, OCTET STRING, OBJECT IDENTIFIER, NULL, BOOLEAN), string types (UTF8String, PrintableString, IA5String, TeletexString, BMPString, UniversalString), time types (UTCTime, GeneralizedTime), and constructed types (SEQUENCE, SET, [n] EXPLICIT, [n] IMPLICIT, context-specific tags). DER mode (default) SHALL reject indefinite-length encodings; BER mode SHALL accept them. Trailing bytes after the outermost item SHALL be rejected in both modes.
Scenario: decodes a definite-length SEQUENCE
- WHEN
decodeAsn1(Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]))is called - THEN the result is an
Asn1Nodewithtag.tagNumber === 16(SEQUENCE) carrying a singleINTEGERchild whose value is5
Scenario: rejects indefinite-length encoding under DER
- WHEN
decodeAsn1(Uint8Array([0x30, 0x80, 0x02, 0x01, 0x05, 0x00, 0x00]), "der")is called - THEN the call throws
X509Errorwith code"X509.ASN1_MALFORMED"
Scenario: accepts indefinite-length encoding under BER
- WHEN
decodeAsn1(Uint8Array([0x30, 0x80, 0x02, 0x01, 0x05, 0x00, 0x00]), "ber")is called - THEN the result is a
SEQUENCEnode carrying the singleINTEGER 5child
Scenario: rejects trailing bytes after outermost item
- WHEN
decodeAsn1(Uint8Array([0x02, 0x01, 0x05, 0xff]))is called - THEN the call throws
X509Errorwith code"X509.ASN1_MALFORMED"
Verified by: packages/crypto/x509/src/asn1/decoder.test.ts::"decodes a definite-length SEQUENCE with one INTEGER child" AND packages/crypto/x509/src/asn1/decoder.test.ts::"rejects indefinite-length encoding under DER" AND packages/crypto/x509/src/asn1/decoder.test.ts::"accepts indefinite-length encoding under BER" AND packages/crypto/x509/src/asn1/decoder.test.ts::"rejects trailing bytes after the outermost item"
Requirement: ASN.1 DER encoder with round-trip guarantee
The package SHALL export encodeAsn1(node: Asn1Node): Uint8Array that produces canonical DER bytes per X.690 §10 for any node the decoder accepted. For every input bytes that decodeAsn1(bytes, "der") accepts, encodeAsn1(decodeAsn1(bytes, "der")) SHALL return a Uint8Array byte-identical to bytes.
Scenario: round-trips a primitive INTEGER
- GIVEN
bytes = Uint8Array([0x02, 0x01, 0x05]) - WHEN
encodeAsn1(decodeAsn1(bytes, "der"))is called - THEN the result equals
bytesbyte-for-byte
Scenario: round-trips a nested SEQUENCE OF SET
- GIVEN a 4-deep RFC 5280 §4.1.2.4
Namestructure - WHEN decode then encode is applied
- THEN the output equals the input byte-for-byte
Verified by: packages/crypto/x509/src/asn1/encoder.test.ts::"round-trips a primitive INTEGER" AND packages/crypto/x509/src/asn1/encoder.test.ts::"round-trips a nested SEQUENCE OF SET Name structure"
Requirement: PEM framing parser per RFC 7468
The package SHALL export decodePem(text: string): PemBlock[] and encodePem(label: string, bytes: Uint8Array): string per RFC 7468 §5. The decoder SHALL accept -----BEGIN <LABEL>----- / -----END <LABEL>----- framing with \n or \r\n line endings, base64 bodies with arbitrary line breaks, multi-block bundles (RFC 7468 §5.1), and SHALL reject malformed input with X509.PEM_MALFORMED.
Scenario: decodes a single CERTIFICATE block
- WHEN
decodePem(VALID_CERT_PEM)is called whereVALID_CERT_PEMis the conventional-----BEGIN CERTIFICATE-----\n<base64>\n-----END CERTIFICATE-----text - THEN the result is a single-element array;
result[0].label === "CERTIFICATE";result[0].bytesmatches the base64-decoded body byte-for-byte
Scenario: decodes a multi-cert bundle
- GIVEN a PEM bundle containing three concatenated certificates separated by single newlines
- WHEN
decodePem(bundle)is called - THEN the result is a length-3 array preserving certificate order
Scenario: tolerates Windows line endings
- WHEN the same PEM bundle is supplied with every newline replaced by
\r\n - THEN
decodePemreturns the same length-3 array with byte-identicalbytesfields
Scenario: rejects missing END marker
- WHEN
decodePem("-----BEGIN CERTIFICATE-----\nAAAA\n")is called - THEN the call throws
X509Errorwith code"X509.PEM_MALFORMED"
Verified by: packages/crypto/x509/src/pem/pem.test.ts::"decodes a single CERTIFICATE block" AND packages/crypto/x509/src/pem/pem.test.ts::"decodes a multi-cert bundle" AND packages/crypto/x509/src/pem/pem.test.ts::"tolerates Windows line endings" AND packages/crypto/x509/src/pem/pem.test.ts::"rejects missing END marker as PEM_MALFORMED"
Requirement: parseCertificate(bytes) per RFC 5280 §4.1
The package SHALL export parseCertificate(bytes: Uint8Array): X509Certificate that decodes a DER- or PEM-encoded Certificate into a typed X509Certificate value object carrying every field from RFC 5280 §4.1.2: version (mapped from raw 0/1/2 → "v1"/"v2"/"v3"), serialNumber (positive INTEGER, length-bounded to 20 bytes per §4.1.2.2 — over-length serial → X509.CERTIFICATE_MALFORMED), signature AlgorithmIdentifier, issuer Name, validity { notBefore: number, notAfter: number } in epoch-ms, subject Name, subjectPublicKeyInfo, optional issuerUniqueID / subjectUniqueID (v2+), and extensions (v3 only). The result SHALL also carry the canonical TBSCertificate DER bytes (tbsCertificateBytes: Uint8Array) and the raw outer signature value (signatureValue: Uint8Array) so signature verify does not re-encode the TBS itself.
Scenario: parses a v3 leaf certificate to typed fields
- GIVEN a DER-encoded v3 leaf certificate from the Mozilla NSS test PKI
- WHEN
parseCertificate(bytes)is called - THEN the result has
version === "v3", a non-emptyserialNumber, populatedissuer+subject+validity, a non-emptyextensionsarray, andtbsCertificateBytes.length > 0
Scenario: rejects a serial number longer than 20 bytes
- GIVEN a synthetic DER certificate whose
serialNumberINTEGER is 21 bytes - WHEN
parseCertificate(bytes)is called - THEN the call throws
X509Errorwith code"X509.CERTIFICATE_MALFORMED"
Scenario: rejects v4 or higher
- GIVEN a synthetic certificate with
versionINTEGER set to 3 (i.e. v4) - WHEN
parseCertificate(bytes)is called - THEN the call throws
X509Errorwith code"X509.UNSUPPORTED_VERSION"
Verified by: packages/crypto/x509/src/certificate/parse.test.ts::"parses a v3 leaf certificate to typed fields" AND packages/crypto/x509/src/certificate/parse.test.ts::"rejects a serial number longer than 20 bytes" AND packages/crypto/x509/src/certificate/parse.test.ts::"rejects v4 or higher with UNSUPPORTED_VERSION" AND packages/crypto/x509/src/certificate/parse.spec-conformance.test.ts::"fast-check round-trip parse → DER encode → parse byte-identical"
Requirement: Extension decoder dispatch with RawExtension fallback
The package SHALL maintain an OID-keyed extension registry that dispatches each registered extension to its typed decoder (basic constraints, key usage, EKU, SAN, name constraints, AKI, SKI, CRL distribution points). Unknown extensions SHALL be retained as RawExtension { oid: string; critical: boolean; value: Uint8Array } so the validator can still observe their critical flag.
Scenario: dispatches basic constraints to typed decoder
- GIVEN an
Extensionwhose OID is2.5.29.19(basic constraints) and whose value encodescA: true, pathLenConstraint: 3 - WHEN the registry resolves the OID
- THEN the result is a
BasicConstraintsvalue withcA === trueandpathLenConstraint === 3
Scenario: unknown non-critical extension yields RawExtension
- GIVEN an
Extensionwith OID1.2.3.4.5.6.7.8.9andcritical: false - WHEN the registry processes it
- THEN the result is a
RawExtensionwithoid === "1.2.3.4.5.6.7.8.9",critical === false, andvaluebyte-identical to the input OCTET STRING contents
Scenario: unknown critical extension yields RawExtension marked critical
- GIVEN an
Extensionwith OID1.2.3.4.5.6.7.8.10andcritical: true - WHEN the registry processes it
- THEN the result is a
RawExtensionwithcritical === truesovalidateChaincan fail it per RFC 5280 §6.1.4(f)
Verified by: packages/crypto/x509/src/extensions/registry.test.ts::"dispatches basic constraints to typed decoder" AND packages/crypto/x509/src/extensions/registry.test.ts::"unknown non-critical extension yields RawExtension" AND packages/crypto/x509/src/extensions/registry.test.ts::"unknown critical extension yields RawExtension with critical: true"
Requirement: Critical-extension enforcement under path validation
validateChain SHALL fail with X509.CRITICAL_EXTENSION_UNRECOGNIZED whenever any certificate in the chain carries an extension marked critical: true whose OID is not in the package's recognised set (basic constraints, key usage, EKU, SAN, name constraints, AKI, SKI, CRL distribution points). This enforces RFC 5280 §6.1.4(f).
Scenario: critical unknown extension on the leaf fails the chain
- GIVEN a leaf certificate with a critical extension at OID
1.2.3.4.5.6.7.8.10 - WHEN
validateChain({ leaf, intermediates: [], trustAnchors: [root] })is called - THEN the call rejects with
X509Errorcode"X509.CRITICAL_EXTENSION_UNRECOGNIZED"
Scenario: critical unknown extension on an intermediate fails the chain
- GIVEN an intermediate certificate carrying the same critical-unknown extension
- WHEN
validateChain({ leaf, intermediates: [intermediate], trustAnchors: [root] })is called - THEN the call rejects with
X509Errorcode"X509.CRITICAL_EXTENSION_UNRECOGNIZED"
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"critical unknown extension on the leaf fails the chain" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"critical unknown extension on an intermediate fails the chain"
Requirement: Basic constraints (RFC 5280 §4.2.1.9)
The package SHALL decode the basic constraints extension (OID 2.5.29.19) into BasicConstraints { cA: boolean; pathLenConstraint?: number }. During path validation, every issuer certificate SHALL have cA: true; the pathLenConstraint field SHALL bound the number of non-self-issued intermediates between the cert and the leaf per RFC 5280 §6.1.4(h).
Scenario: end-entity cert with cA false used as issuer is rejected
- GIVEN an intermediate cert with
basicConstraints.cA === false - WHEN
validateChainwalks the chain - THEN the call rejects with
X509Errorcode"X509.ISSUER_NOT_CA"
Scenario: pathLenConstraint bound is enforced
- GIVEN a chain leaf → I3 → I2 → I1 → root where I1.basicConstraints.pathLenConstraint === 1 (allowing only one non-self-issued intermediate beneath it)
- WHEN
validateChainwalks the chain - THEN the call rejects with
X509Errorcode"X509.CHAIN_TOO_DEEP"once I2/I3 push the count over 1
Verified by: packages/crypto/x509/src/extensions/basic-constraints.test.ts::"decodes cA: true with pathLenConstraint: 3" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"end-entity cert with cA false used as issuer is rejected with ISSUER_NOT_CA" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"pathLenConstraint bound is enforced with CHAIN_TOO_DEEP"
Requirement: Key usage (RFC 5280 §4.2.1.3)
The package SHALL decode the key usage extension (OID 2.5.29.15) as a typed flags object: digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment, keyAgreement, keyCertSign, cRLSign, encipherOnly, decipherOnly. During path validation, every issuer SHALL have keyCertSign: true whenever the key usage extension is present.
Scenario: decodes a digitalSignature + keyEncipherment flags bit string
- GIVEN an extension whose
BIT STRINGvalue is0b1010_0000(digitalSignature=1, nonRepudiation=0, keyEncipherment=1, ...) - WHEN the extension is decoded
- THEN the result is
{ digitalSignature: true, nonRepudiation: false, keyEncipherment: true, ...rest false }
Scenario: issuer without keyCertSign is rejected
- GIVEN an intermediate that explicitly declares key usage but omits
keyCertSign - WHEN
validateChainwalks the chain - THEN the call rejects with
X509Errorcode"X509.KEY_USAGE_INVALID"
Verified by: packages/crypto/x509/src/extensions/key-usage.test.ts::"decodes digitalSignature + keyEncipherment flags" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"issuer without keyCertSign is rejected with KEY_USAGE_INVALID"
Requirement: Extended key usage (RFC 5280 §4.2.1.12)
The package SHALL decode the extended key usage extension (OID 2.5.29.37) as ExtendedKeyUsage { purposes: string[] }, mapping the recognised OIDs to readable identifiers (serverAuth, clientAuth, codeSigning, emailProtection, timeStamping, OCSPSigning, anyExtendedKeyUsage). When ValidateChainOptions.ekuRequired is set, every cert in the chain SHALL include either an exact EKU match or anyExtendedKeyUsage.
Scenario: anyExtendedKeyUsage is treated as a permissive wildcard
- GIVEN a leaf with EKU =
anyExtendedKeyUsageandekuRequired: "serverAuth" - WHEN
validateChainis called - THEN the call succeeds
Scenario: missing required EKU fails
- GIVEN a leaf whose EKU is exactly
[clientAuth]andekuRequired: "serverAuth" - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.EXTENDED_KEY_USAGE_INVALID"
Verified by: packages/crypto/x509/src/extensions/extended-key-usage.test.ts::"recognises anyExtendedKeyUsage as wildcard" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"missing required EKU fails with EXTENDED_KEY_USAGE_INVALID"
Requirement: Subject Alternative Name (RFC 5280 §4.2.1.6 + §4.2.2.6)
The package SHALL decode the subject alternative name extension (OID 2.5.29.17) as SubjectAltName { names: GeneralName[] } where GeneralName is a discriminated union over every name choice in RFC 5280 §4.2.1.6: rfc822Name, dNSName, uniformResourceIdentifier, iPAddress (4-byte v4 or 16-byte v6), directoryName, otherName, x400Address, ediPartyName, registeredID.
Scenario: decodes a dNSName
- GIVEN a SAN extension with a single
dNSNameof"example.com" - WHEN the extension is decoded
- THEN the result is
{ names: [{ kind: "dNSName", value: "example.com" }] }
Scenario: decodes an iPAddress for IPv6
- GIVEN a SAN extension with an
iPAddressof 16 bytes equal to::1 - WHEN the extension is decoded
- THEN the result is
{ names: [{ kind: "iPAddress", value: "::1" }] }
Scenario: decodes mixed name choices
- GIVEN a SAN extension containing
dNSName "a.example",rfc822Name "user@example.com", anduniformResourceIdentifier "https://example.com/" - WHEN the extension is decoded
- THEN the result preserves order with three correctly-typed entries
Verified by: packages/crypto/x509/src/extensions/subject-alt-name.test.ts::"decodes a dNSName" AND packages/crypto/x509/src/extensions/subject-alt-name.test.ts::"decodes an iPAddress for IPv6" AND packages/crypto/x509/src/extensions/subject-alt-name.test.ts::"decodes mixed name choices preserving order"
Requirement: Name constraints (RFC 5280 §4.2.1.10)
The package SHALL decode the name constraints extension (OID 2.5.29.30) as NameConstraints { permittedSubtrees?: GeneralSubtree[]; excludedSubtrees?: GeneralSubtree[] } and SHALL enforce them during path validation: a chain cert's SAN entries that fall outside any permittedSubtrees for their kind, or inside any excludedSubtrees, SHALL trigger X509.NAME_CONSTRAINT_VIOLATION. DNS suffix matching, RFC 822 email matching, URI host matching, IP-prefix matching, and directory-name matching SHALL all be implemented.
Scenario: DNS suffix permitted-subtree matches
- GIVEN an intermediate with
permittedSubtrees: [{ base: { kind: "dNSName", value: ".example.com" } }]and a leaf with SANdNSName "host.example.com" - WHEN
validateChainis called - THEN the call succeeds
Scenario: DNS name outside permitted-subtree fails
- GIVEN the same intermediate constraint with a leaf SAN of
dNSName "host.other.com" - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.NAME_CONSTRAINT_VIOLATION"
Scenario: IP-prefix excluded-subtree triggers rejection
- GIVEN an intermediate with
excludedSubtrees: [{ base: { kind: "iPAddress", value: "10.0.0.0/8" } }]and a leaf SAN ofiPAddress "10.1.2.3" - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.NAME_CONSTRAINT_VIOLATION"
Verified by: packages/crypto/x509/src/extensions/name-constraints.test.ts::"DNS suffix permitted-subtree matches" AND packages/crypto/x509/src/extensions/name-constraints.test.ts::"DNS name outside permitted-subtree fails matching" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"DNS name outside permitted-subtree fails with NAME_CONSTRAINT_VIOLATION" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"IP-prefix excluded-subtree triggers NAME_CONSTRAINT_VIOLATION"
Requirement: Authority key identifier + subject key identifier
The package SHALL decode the authority key identifier extension (OID 2.5.29.35) into AuthorityKeyIdentifier { keyIdentifier?: Uint8Array; authorityCertIssuer?: GeneralName[]; authorityCertSerialNumber?: Uint8Array } per RFC 5280 §4.2.1.1, and the subject key identifier extension (OID 2.5.29.14) into SubjectKeyIdentifier { keyIdentifier: Uint8Array } per §4.2.1.2. The TrustAnchorStore.findBySki(keyId) helper SHALL look anchors up by their SKI in O(1).
Scenario: decodes AKI keyIdentifier byte-identically
- GIVEN an AKI extension wrapping a 20-byte keyIdentifier
- WHEN the extension is decoded
- THEN
result.keyIdentifieris a 20-byteUint8Arraybyte-identical to the source
Scenario: TrustAnchorStore.findBySki returns the anchor whose SKI matches
- GIVEN a
TrustAnchorStoreconstructed from a PEM bundle of three roots with distinct SKIs - WHEN
store.findBySki(roots[1].subjectKeyIdentifier.keyIdentifier)is called - THEN the result is
roots[1]
Verified by: packages/crypto/x509/src/extensions/authority-key-id.test.ts::"decodes AKI keyIdentifier byte-identically" AND packages/crypto/x509/src/extensions/subject-key-id.test.ts::"decodes SKI keyIdentifier byte-identically" AND packages/crypto/x509/src/validate/trust-anchor.test.ts::"findBySki returns the anchor whose SKI matches"
Requirement: CRL distribution points (RFC 5280 §4.2.1.13) — parse only
The package SHALL decode the CRL distribution points extension (OID 2.5.29.31) into CrlDistributionPoint[] carrying distributionPoint (fullName: GeneralName[] or nameRelativeToCRLIssuer), reasons: ReasonFlags, and cRLIssuer: GeneralName[]. Actual CRL fetching and revocation checking are out of scope for this package; the parsed structure is exposed verbatim so a sibling package (@suaveplan/x509-crl) can act on it.
Scenario: decodes a fullName URI distribution point
- GIVEN an extension with one distribution point whose
fullNamecontains a single URI"http://crl.example.com/intermediate.crl" - WHEN the extension is decoded
- THEN the result is a length-1 array whose
[0].distributionPoint.fullNameis[{ kind: "uniformResourceIdentifier", value: "http://crl.example.com/intermediate.crl" }]
Scenario: decodes multiple distribution points
- GIVEN an extension carrying two distribution points with different URIs
- WHEN the extension is decoded
- THEN the result has length 2 preserving original order
Verified by: packages/crypto/x509/src/extensions/crl-distribution-points.test.ts::"decodes a fullName URI distribution point" AND packages/crypto/x509/src/extensions/crl-distribution-points.test.ts::"decodes multiple distribution points preserving order"
Requirement: validateChain per RFC 5280 §6.1 12-step algorithm
The package SHALL export validateChain(opts: ValidateChainOptions): Promise<ValidateChainResult> implementing the path-validation algorithm in RFC 5280 §6.1: initialise state per §6.1.2; for each non-trust-anchor certificate verify signature against the issuer's SubjectPublicKeyInfo (§6.1.3(a)(1)), validity against the injected Clock (§6.1.3(a)(2)), name chaining via canonical Name equality (§6.1.3(a)(4)), name constraints (§6.1.3(b)/(c)), critical extensions (§6.1.4(f)); on the final wrap-up step verify the leaf, decrement maxPathLength, and return the trusted path. The returned ValidateChainResult.trustedPath SHALL be ordered leaf-first ending at the trust anchor.
Scenario: validates a 3-cert chain successfully
- GIVEN a real WebAuthn TPM attestation chain
leaf → intermediate → Microsoft TPM Root CAwith the root supplied as a trust anchor - WHEN
validateChain({ leaf, intermediates: [intermediate], trustAnchors: [root] })is called - THEN the call resolves with
trustedPath.length === 3ordered[leaf, intermediate, root]
Scenario: missing trust anchor fails
- GIVEN the same chain with
trustAnchors: [] - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.TRUST_ANCHOR_NOT_FOUND"
Scenario: broken name chaining fails
- GIVEN a chain whose intermediate's
subjectdoes not equal the leaf'sissuerunder canonical-name equality - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.NAME_CHAIN_BROKEN"
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"validates a 3-cert chain successfully and returns trustedPath ordered leaf-first" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"missing trust anchor fails with TRUST_ANCHOR_NOT_FOUND" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"broken name chaining fails with NAME_CHAIN_BROKEN" AND packages/crypto/x509/src/validate/validate-chain.spec-conformance.test.ts::"real WebAuthn TPM attestation chain validates" AND packages/crypto/x509/src/validate/validate-chain.spec-conformance.test.ts::"real Apple WebAuthn attestation chain validates"
Requirement: Validity period check via injectable Clock
The validity period check SHALL consult the Clock injected via ValidateChainOptions.clock (defaulting to a system clock from @suaveplan/types). A certificate SHALL be valid when notBefore ≤ now < notAfter. Out-of-range values SHALL raise distinct error codes for diagnostics.
Scenario: expired certificate fails with CERT_EXPIRED
- GIVEN a chain whose leaf
notAfteris one second before the injected clock'snow - WHEN
validateChainis called with that clock - THEN the call rejects with
X509Errorcode"X509.CERT_EXPIRED"
Scenario: not-yet-valid certificate fails with CERT_NOT_YET_VALID
- GIVEN a chain whose leaf
notBeforeis one second after the injected clock'snow - WHEN
validateChainis called with that clock - THEN the call rejects with
X509Errorcode"X509.CERT_NOT_YET_VALID"
Scenario: boundary case — now equals notAfter is rejected
- GIVEN an injected clock returning a
nowexactly equal to the leaf'snotAfter - WHEN
validateChainis called - THEN the call rejects with
X509Errorcode"X509.CERT_EXPIRED"(the half-open interval treatsnotAfteras exclusive)
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"expired certificate fails with CERT_EXPIRED" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"not-yet-valid certificate fails with CERT_NOT_YET_VALID" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"boundary case now equals notAfter is rejected as CERT_EXPIRED"
Requirement: Signature verify dispatch over algorithm OID
The package SHALL maintain an OID → @suaveplan/crypto-curve algorithm-ID table covering sha256WithRSAEncryption (1.2.840.113549.1.1.11), sha384WithRSAEncryption (…1.12), sha512WithRSAEncryption (…1.13), ecdsa-with-SHA256 (1.2.840.10045.4.3.2), ecdsa-with-SHA384 (…4.3.3), ecdsa-with-SHA512 (…4.3.4), id-Ed25519 (1.3.101.112), and id-Ed448 (1.3.101.113, RFC 8410). verifyCertificateSignature(cert, parentSpki) SHALL re-encode the TBSCertificate (or reuse cert.tbsCertificateBytes), decode the outer signature (handling the ECDSA SEQUENCE { r INTEGER, s INTEGER } shape), and dispatch to the curve package. Unknown OIDs SHALL throw X509.SIGNATURE_ALG_UNSUPPORTED.
Scenario: verifies an ECDSA-P256-SHA256 signature
- GIVEN a leaf signed by an intermediate using ECDSA-P256-SHA256 from the Mozilla NSS root store
- WHEN
verifyCertificateSignature(leaf, intermediate.subjectPublicKeyInfo)is called - THEN the promise resolves to
true
Scenario: rejects an unknown signature algorithm OID
- GIVEN a synthetic certificate whose
signatureAlgorithm.algorithmOID is"1.2.3.4.5.6.7.8.99" - WHEN
verifyCertificateSignatureis called - THEN the promise rejects with
X509Errorcode"X509.SIGNATURE_ALG_UNSUPPORTED"
Scenario: rejects a tampered TBSCertificate
- GIVEN a valid leaf whose
tbsCertificateByteshave had a single inner byte flipped before verification - WHEN
verifyCertificateSignatureis called against the original signature - THEN the promise rejects with
X509Errorcode"X509.SIGNATURE_INVALID"
Verified by: packages/crypto/x509/src/signature/verify.test.ts::"verifies an ECDSA-P256-SHA256 signature" AND packages/crypto/x509/src/signature/alg-map.test.ts::"rejects an unknown signature algorithm OID with SIGNATURE_ALG_UNSUPPORTED" AND packages/crypto/x509/src/signature/verify.test.ts::"rejects a tampered TBSCertificate with SIGNATURE_INVALID"
Requirement: Self-signed leaf rejection unless explicitly in trust anchors
The package SHALL reject a chain whose leaf certificate is self-signed (issuer === subject under canonical-name equality AND signature verifies against the leaf's own SPKI) UNLESS that exact leaf is also present in trustAnchors. This prevents accidental trust of a self-issued credential when the trust store does not explicitly enumerate it.
Scenario: self-signed leaf not in trust anchors fails
- GIVEN a self-signed leaf and
trustAnchors: [unrelatedRoot] - WHEN
validateChain({ leaf, intermediates: [], trustAnchors })is called - THEN the call rejects with
X509Errorcode"X509.SELF_SIGNED_LEAF"
Scenario: self-signed leaf present in trust anchors succeeds
- GIVEN the same self-signed leaf added to
trustAnchorsdirectly - WHEN
validateChain({ leaf, intermediates: [], trustAnchors: [leaf] })is called - THEN the call resolves with
trustedPath: [leaf]
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"self-signed leaf not in trust anchors fails with SELF_SIGNED_LEAF" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"self-signed leaf present in trust anchors succeeds"
Requirement: Chain depth bound
The package SHALL bound chain length at ValidateChainOptions.maxChainDepth (default 16). Exceeding the bound SHALL fail with X509.CHAIN_TOO_DEEP. The bound is in addition to any pathLenConstraint carried in basic constraints.
Scenario: chain of 17 certs exceeds default bound
- GIVEN a synthetic chain of length 17 (leaf + 15 intermediates + root)
- WHEN
validateChain({ leaf, intermediates, trustAnchors: [root] })is called with default options - THEN the call rejects with
X509Errorcode"X509.CHAIN_TOO_DEEP"
Scenario: configurable bound permits longer chains
- GIVEN the same 17-cert chain
- WHEN
validateChain({ leaf, intermediates, trustAnchors: [root], maxChainDepth: 32 })is called - THEN the call resolves (provided every other check passes)
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"chain of 17 certs exceeds default bound with CHAIN_TOO_DEEP" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"configurable maxChainDepth permits longer chains"
Requirement: AbortSignal honoured
validateChain SHALL honour ValidateChainOptions.signal and reject with X509.ABORTED when the signal is already aborted at the call site, or when it aborts mid-validation between per-cert iterations.
Scenario: pre-aborted signal rejects immediately
- GIVEN
const ac = new AbortController(); ac.abort(); - WHEN
validateChain({ ..., signal: ac.signal })is called - THEN the call rejects with
X509Errorcode"X509.ABORTED"
Scenario: mid-validation abort short-circuits
- GIVEN a multi-cert chain and a signal scheduled to abort via
queueMicrotaskafter the first per-cert step - WHEN
validateChainis invoked - THEN the call rejects with
X509Errorcode"X509.ABORTED"before reaching the trust anchor
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"pre-aborted signal rejects immediately with ABORTED" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"mid-validation abort short-circuits with ABORTED"
Requirement: Error registry — every code thrown by a production path
The package SHALL declare its error registry via defineCodes("X509", [...]) with the following codes, each thrown by ≥1 production code path: PEM_MALFORMED, ASN1_MALFORMED, CERTIFICATE_MALFORMED, UNSUPPORTED_VERSION, EXTENSION_MALFORMED, CRITICAL_EXTENSION_UNRECOGNIZED, SIGNATURE_ALG_UNSUPPORTED, SIGNATURE_INVALID, CERT_EXPIRED, CERT_NOT_YET_VALID, NAME_CHAIN_BROKEN, NAME_CONSTRAINT_VIOLATION, ISSUER_NOT_CA, KEY_USAGE_INVALID, EXTENDED_KEY_USAGE_INVALID, CHAIN_TOO_DEEP, SELF_SIGNED_LEAF, TRUST_ANCHOR_NOT_FOUND, OCSP_REVOKED, ABORTED. X509Error extends SuaveplanError<X509ErrorCode>; the type-level union SHALL be pinned by expectTypeOf in the test suite.
Scenario: every code is thrown by at least one production path
- WHEN the test suite runs
- THEN for every code
cinX509_CODES, at least one test inerror-codes.test.tsproves the code is thrown by asrc/code path
Scenario: literal union is pinned by expectTypeOf
- WHEN the test runs
expectTypeOf<X509ErrorCode>().toEqualTypeOf<…>()with the explicit 20-element literal union - THEN the assertion compiles without error
Verified by: packages/crypto/x509/src/errors/error-codes.test.ts::"every code in the registry is thrown by at least one production path" AND packages/crypto/x509/src/errors/error-codes.test.ts::"X509ErrorCode literal union is pinned by expectTypeOf"
Requirement: Zod boundary schemas at every public option type
The package SHALL validate every public option and result type through Zod schemas at the boundary: ParseCertificateOptions, ValidateChainOptions, X509Certificate, GeneralName, KeyUsage, ExtendedKeyUsage, BasicConstraints, NameConstraints, CrlDistributionPoint, AuthorityKeyIdentifier, SubjectKeyIdentifier. Malformed input at the boundary SHALL throw X509Error with code "X509.CERTIFICATE_MALFORMED" (decoder boundary) or the corresponding extension code (extension boundary), with the original ZodError preserved as cause.
Scenario: malformed ValidateChainOptions rejected at boundary
- WHEN
validateChain({ leaf: null, intermediates: [], trustAnchors: [] } as unknown as ValidateChainOptions)is called - THEN the call rejects with
X509Errorcarrying acausewhosename === "ZodError"
Scenario: malformed BasicConstraints bytes rejected at boundary
- WHEN the basic-constraints decoder is fed a non-SEQUENCE input
- THEN the call throws
X509Errorwith code"X509.EXTENSION_MALFORMED"
Verified by: packages/crypto/x509/src/schemas/schemas.test.ts::"malformed ValidateChainOptions rejected at boundary" AND packages/crypto/x509/src/schemas/schemas.test.ts::"malformed BasicConstraints bytes rejected at boundary with EXTENSION_MALFORMED"
Requirement: OpenTelemetry surface
The package SHALL emit the following OTel instruments from production code paths and SHALL be verifiable against a real in-memory MeterProvider: x509.parse_duration_ms histogram with attribute outcome ∈ {success, error} (and error_code on error); x509.validate_duration_ms histogram with the same attribute schema plus chain_length on success; x509.parse_total{outcome} counter; x509.validate_total{outcome} counter; x509.extension_total{oid, critical} counter (where oid is bounded by the recognised set plus "unknown"); x509.error_total{code} counter; x509.chain_length histogram on successful validations.
Scenario: parse_duration histogram records on success
- GIVEN a valid certificate and an in-memory
MeterProvider - WHEN
parseCertificateis called - THEN
x509.parse_duration_msrecords exactly one data point withoutcome === "success"
Scenario: error_total counter increments per X509Error
- GIVEN an invalid PEM blob and an in-memory
MeterProvider - WHEN the parser fails
- THEN
x509.error_totalincrements by 1 withcode === "PEM_MALFORMED"
Scenario: extension_total counter is bounded cardinality
- GIVEN a leaf carrying basic constraints, key usage, EKU, SAN, and an unknown non-critical extension
- WHEN the leaf is parsed
- THEN
x509.extension_totalrecords five data points, four against recognised OIDs and one against the literal"unknown"bucket
Verified by: packages/crypto/x509/src/observability/metrics.test.ts::"parse_duration histogram records on success" AND packages/crypto/x509/src/observability/metrics.test.ts::"error_total counter increments per X509Error" AND packages/crypto/x509/src/observability/metrics.test.ts::"extension_total counter buckets unknown OIDs into a single label"
Requirement: Trust anchor store with O(1) SKI lookup
The package SHALL export TrustAnchorStore with findBySki(keyId: Uint8Array): X509Certificate | undefined and findIssuer(child: X509Certificate): X509Certificate | undefined. The store SHALL accept a raw PEM bundle (string), a pre-parsed X509Certificate[], or a mixed iterable. Duplicate SKIs across multiple roots SHALL be tolerated (last-wins) and surfaced via an opt-in onDuplicate callback for diagnostics.
Scenario: constructed from a PEM bundle
- GIVEN a multi-cert PEM bundle of three roots
- WHEN
new TrustAnchorStore(bundle)is called - THEN
store.size === 3andfindBySkireturns the matching anchor for each root's SKI
Scenario: findIssuer uses leaf AKI then falls back to subject/issuer match
- GIVEN a leaf with AKI set
- WHEN
store.findIssuer(leaf)is called - THEN the result is the anchor whose SKI matches the AKI keyIdentifier; if no AKI is present, the result is the anchor whose
subjectequals the leaf'sissuerunder canonical-name equality
Verified by: packages/crypto/x509/src/validate/trust-anchor.test.ts::"constructed from a PEM bundle yields the expected size and per-SKI lookups" AND packages/crypto/x509/src/validate/trust-anchor.test.ts::"findIssuer uses leaf AKI then falls back to subject/issuer match"
Requirement: OCSP responder interface (no implementation)
The package SHALL export OcspResponderLike as a type-only contract: { check(cert: X509Certificate, issuer: X509Certificate, signal?: AbortSignal): Promise<OcspStatus> } with OcspStatus = "good" | "revoked" | "unknown". validateChain SHALL thread opts.ocspResponder (when supplied) into RFC 5280 §6.1.3(a)(3) and SHALL fail with X509.OCSP_REVOKED when the responder returns "revoked". No responder implementation ships in this package — @suaveplan/x509-ocsp is the follow-up that will provide a fetch-backed implementation.
Scenario: OCSP-revoked status fails the chain
- GIVEN a stub
OcspResponderLikewhosecheckresolves to"revoked" - WHEN
validateChain({ ..., ocspResponder: stub })is called - THEN the call rejects with
X509Errorcode"X509.OCSP_REVOKED"
Scenario: missing responder skips OCSP step entirely
- GIVEN the same chain without an
ocspResponderoption - WHEN
validateChainis called - THEN the call succeeds (OCSP is opt-in per the algorithm)
Verified by: packages/crypto/x509/src/validate/validate-chain.test.ts::"OCSP-revoked status fails the chain with OCSP_REVOKED" AND packages/crypto/x509/src/validate/validate-chain.test.ts::"missing OCSP responder skips OCSP step entirely"