Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,551 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: x509 (enterprise-x509)
**Package**: `@suaveplan/x509`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED 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/security/x509/src/asn1/decoder.test.ts::"decodes a definite-length SEQUENCE with one INTEGER child"` AND `packages/security/x509/src/asn1/decoder.test.ts::"rejects indefinite-length encoding under DER"` AND `packages/security/x509/src/asn1/decoder.test.ts::"accepts indefinite-length encoding under BER"` AND `packages/security/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/security/x509/src/asn1/encoder.test.ts::"round-trips a primitive INTEGER"` AND `packages/security/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/security/x509/src/pem/pem.test.ts::"decodes a single CERTIFICATE block"` AND `packages/security/x509/src/pem/pem.test.ts::"decodes a multi-cert bundle"` AND `packages/security/x509/src/pem/pem.test.ts::"tolerates Windows line endings"` AND `packages/security/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/security/x509/src/certificate/parse.test.ts::"parses a v3 leaf certificate to typed fields"` AND `packages/security/x509/src/certificate/parse.test.ts::"rejects a serial number longer than 20 bytes"` AND `packages/security/x509/src/certificate/parse.test.ts::"rejects v4 or higher with UNSUPPORTED_VERSION"` AND `packages/security/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/security/x509/src/extensions/registry.test.ts::"dispatches basic constraints to typed decoder"` AND `packages/security/x509/src/extensions/registry.test.ts::"unknown non-critical extension yields RawExtension"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"critical unknown extension on the leaf fails the chain"` AND `packages/security/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/security/x509/src/extensions/basic-constraints.test.ts::"decodes cA: true with pathLenConstraint: 3"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"end-entity cert with cA false used as issuer is rejected with ISSUER_NOT_CA"` AND `packages/security/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/security/x509/src/extensions/key-usage.test.ts::"decodes digitalSignature + keyEncipherment flags"` AND `packages/security/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/security/x509/src/extensions/extended-key-usage.test.ts::"recognises anyExtendedKeyUsage as wildcard"` AND `packages/security/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/security/x509/src/extensions/subject-alt-name.test.ts::"decodes a dNSName"` AND `packages/security/x509/src/extensions/subject-alt-name.test.ts::"decodes an iPAddress for IPv6"` AND `packages/security/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/security/x509/src/extensions/name-constraints.test.ts::"DNS suffix permitted-subtree matches"` AND `packages/security/x509/src/extensions/name-constraints.test.ts::"DNS name outside permitted-subtree fails matching"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"DNS name outside permitted-subtree fails with NAME_CONSTRAINT_VIOLATION"` AND `packages/security/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/security/x509/src/extensions/authority-key-id.test.ts::"decodes AKI keyIdentifier byte-identically"` AND `packages/security/x509/src/extensions/subject-key-id.test.ts::"decodes SKI keyIdentifier byte-identically"` AND `packages/security/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/security/x509/src/extensions/crl-distribution-points.test.ts::"decodes a fullName URI distribution point"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"validates a 3-cert chain successfully and returns trustedPath ordered leaf-first"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"missing trust anchor fails with TRUST_ANCHOR_NOT_FOUND"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"broken name chaining fails with NAME_CHAIN_BROKEN"` AND `packages/security/x509/src/validate/validate-chain.spec-conformance.test.ts::"real WebAuthn TPM attestation chain validates"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"expired certificate fails with CERT_EXPIRED"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"not-yet-valid certificate fails with CERT_NOT_YET_VALID"` AND `packages/security/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/security/x509/src/signature/verify.test.ts::"verifies an ECDSA-P256-SHA256 signature"` AND `packages/security/x509/src/signature/alg-map.test.ts::"rejects an unknown signature algorithm OID with SIGNATURE_ALG_UNSUPPORTED"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"self-signed leaf not in trust anchors fails with SELF_SIGNED_LEAF"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"chain of 17 certs exceeds default bound with CHAIN_TOO_DEEP"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"pre-aborted signal rejects immediately with ABORTED"` AND `packages/security/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/security/x509/src/errors/error-codes.test.ts::"every code in the registry is thrown by at least one production path"` AND `packages/security/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/security/x509/src/schemas/schemas.test.ts::"malformed ValidateChainOptions rejected at boundary"` AND `packages/security/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/security/x509/src/observability/metrics.test.ts::"parse_duration histogram records on success"` AND `packages/security/x509/src/observability/metrics.test.ts::"error_total counter increments per X509Error"` AND `packages/security/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/security/x509/src/validate/trust-anchor.test.ts::"constructed from a PEM bundle yields the expected size and per-SKI lookups"` AND `packages/security/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/security/x509/src/validate/validate-chain.test.ts::"OCSP-revoked status fails the chain with OCSP_REVOKED"` AND `packages/security/x509/src/validate/validate-chain.test.ts::"missing OCSP responder skips OCSP step entirely"`
<!-- markdownlint-enable MD024 -->