Files
suaveplan/openspec/specs/x509/spec.md
2026-05-27 23:29:53 +01:00

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. (See openspec/AGENTS.md Section 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 Asn1Node with tag.tagNumber === 16 (SEQUENCE) carrying a single INTEGER child whose value is 5

Scenario: rejects indefinite-length encoding under DER

  • WHEN decodeAsn1(Uint8Array([0x30, 0x80, 0x02, 0x01, 0x05, 0x00, 0x00]), "der") is called
  • THEN the call throws X509Error with 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 SEQUENCE node carrying the single INTEGER 5 child

Scenario: rejects trailing bytes after outermost item

  • WHEN decodeAsn1(Uint8Array([0x02, 0x01, 0x05, 0xff])) is called
  • THEN the call throws X509Error with 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 bytes byte-for-byte

Scenario: round-trips a nested SEQUENCE OF SET

  • GIVEN a 4-deep RFC 5280 §4.1.2.4 Name structure
  • 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 where VALID_CERT_PEM is the conventional -----BEGIN CERTIFICATE-----\n<base64>\n-----END CERTIFICATE----- text
  • THEN the result is a single-element array; result[0].label === "CERTIFICATE"; result[0].bytes matches 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 decodePem returns the same length-3 array with byte-identical bytes fields

Scenario: rejects missing END marker

  • WHEN decodePem("-----BEGIN CERTIFICATE-----\nAAAA\n") is called
  • THEN the call throws X509Error with 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-empty serialNumber, populated issuer + subject + validity, a non-empty extensions array, and tbsCertificateBytes.length > 0

Scenario: rejects a serial number longer than 20 bytes

  • GIVEN a synthetic DER certificate whose serialNumber INTEGER is 21 bytes
  • WHEN parseCertificate(bytes) is called
  • THEN the call throws X509Error with code "X509.CERTIFICATE_MALFORMED"

Scenario: rejects v4 or higher

  • GIVEN a synthetic certificate with version INTEGER set to 3 (i.e. v4)
  • WHEN parseCertificate(bytes) is called
  • THEN the call throws X509Error with 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 Extension whose OID is 2.5.29.19 (basic constraints) and whose value encodes cA: true, pathLenConstraint: 3
  • WHEN the registry resolves the OID
  • THEN the result is a BasicConstraints value with cA === true and pathLenConstraint === 3

Scenario: unknown non-critical extension yields RawExtension

  • GIVEN an Extension with OID 1.2.3.4.5.6.7.8.9 and critical: false
  • WHEN the registry processes it
  • THEN the result is a RawExtension with oid === "1.2.3.4.5.6.7.8.9", critical === false, and value byte-identical to the input OCTET STRING contents

Scenario: unknown critical extension yields RawExtension marked critical

  • GIVEN an Extension with OID 1.2.3.4.5.6.7.8.10 and critical: true
  • WHEN the registry processes it
  • THEN the result is a RawExtension with critical === true so validateChain can 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 X509Error code "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 X509Error code "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 validateChain walks the chain
  • THEN the call rejects with X509Error code "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 validateChain walks the chain
  • THEN the call rejects with X509Error code "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 STRING value is 0b1010_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 validateChain walks the chain
  • THEN the call rejects with X509Error code "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 = anyExtendedKeyUsage and ekuRequired: "serverAuth"
  • WHEN validateChain is called
  • THEN the call succeeds

Scenario: missing required EKU fails

  • GIVEN a leaf whose EKU is exactly [clientAuth] and ekuRequired: "serverAuth"
  • WHEN validateChain is called
  • THEN the call rejects with X509Error code "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 dNSName of "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 iPAddress of 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", and uniformResourceIdentifier "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 SAN dNSName "host.example.com"
  • WHEN validateChain is 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 validateChain is called
  • THEN the call rejects with X509Error code "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 of iPAddress "10.1.2.3"
  • WHEN validateChain is called
  • THEN the call rejects with X509Error code "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.keyIdentifier is a 20-byte Uint8Array byte-identical to the source

Scenario: TrustAnchorStore.findBySki returns the anchor whose SKI matches

  • GIVEN a TrustAnchorStore constructed 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 fullName contains 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.fullName is [{ 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 CA with the root supplied as a trust anchor
  • WHEN validateChain({ leaf, intermediates: [intermediate], trustAnchors: [root] }) is called
  • THEN the call resolves with trustedPath.length === 3 ordered [leaf, intermediate, root]

Scenario: missing trust anchor fails

  • GIVEN the same chain with trustAnchors: []
  • WHEN validateChain is called
  • THEN the call rejects with X509Error code "X509.TRUST_ANCHOR_NOT_FOUND"

Scenario: broken name chaining fails

  • GIVEN a chain whose intermediate's subject does not equal the leaf's issuer under canonical-name equality
  • WHEN validateChain is called
  • THEN the call rejects with X509Error code "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 notAfter is one second before the injected clock's now
  • WHEN validateChain is called with that clock
  • THEN the call rejects with X509Error code "X509.CERT_EXPIRED"

Scenario: not-yet-valid certificate fails with CERT_NOT_YET_VALID

  • GIVEN a chain whose leaf notBefore is one second after the injected clock's now
  • WHEN validateChain is called with that clock
  • THEN the call rejects with X509Error code "X509.CERT_NOT_YET_VALID"

Scenario: boundary case — now equals notAfter is rejected

  • GIVEN an injected clock returning a now exactly equal to the leaf's notAfter
  • WHEN validateChain is called
  • THEN the call rejects with X509Error code "X509.CERT_EXPIRED" (the half-open interval treats notAfter as 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.algorithm OID is "1.2.3.4.5.6.7.8.99"
  • WHEN verifyCertificateSignature is called
  • THEN the promise rejects with X509Error code "X509.SIGNATURE_ALG_UNSUPPORTED"

Scenario: rejects a tampered TBSCertificate

  • GIVEN a valid leaf whose tbsCertificateBytes have had a single inner byte flipped before verification
  • WHEN verifyCertificateSignature is called against the original signature
  • THEN the promise rejects with X509Error code "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 X509Error code "X509.SELF_SIGNED_LEAF"

Scenario: self-signed leaf present in trust anchors succeeds

  • GIVEN the same self-signed leaf added to trustAnchors directly
  • 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 X509Error code "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 X509Error code "X509.ABORTED"

Scenario: mid-validation abort short-circuits

  • GIVEN a multi-cert chain and a signal scheduled to abort via queueMicrotask after the first per-cert step
  • WHEN validateChain is invoked
  • THEN the call rejects with X509Error code "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 c in X509_CODES, at least one test in error-codes.test.ts proves the code is thrown by a src/ 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 X509Error carrying a cause whose name === "ZodError"

Scenario: malformed BasicConstraints bytes rejected at boundary

  • WHEN the basic-constraints decoder is fed a non-SEQUENCE input
  • THEN the call throws X509Error with 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 parseCertificate is called
  • THEN x509.parse_duration_ms records exactly one data point with outcome === "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_total increments by 1 with code === "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_total records 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 === 3 and findBySki returns 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 subject equals the leaf's issuer under 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 OcspResponderLike whose check resolves to "revoked"
  • WHEN validateChain({ ..., ocspResponder: stub }) is called
  • THEN the call rejects with X509Error code "X509.OCSP_REVOKED"

Scenario: missing responder skips OCSP step entirely

  • GIVEN the same chain without an ocspResponder option
  • WHEN validateChain is 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"