Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
# `__fixtures__/build-certs` — deterministic real-crypto certificate builders
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module emits real, signature-verifiable X.509 certificates entirely from
|
||||
in-process code: hand-rolled ASN.1 DER via the package's own `encodeAsn1`,
|
||||
signed with real Ed25519 and ECDSA P-256 keys. Outputs are deterministic
|
||||
because the private keys are hardcoded test constants and both signing
|
||||
primitives are deterministic — Ed25519 by RFC 8032 and ECDSA by RFC 6979 `k`
|
||||
selection in `@noble/curves`. This makes every test that consumes a fixture
|
||||
both reproducible and runnable offline without network or shell-out to
|
||||
`openssl`.
|
||||
|
||||
The fixtures replace what would otherwise require a Mozilla NSS test PKI:
|
||||
they cover root → intermediate → leaf chains under both Ed25519 and
|
||||
ECDSA P-256, plus single-issue mutants (expired leaf, not-yet-valid leaf,
|
||||
self-signed leaf, name-constraint violator, critical-unknown extension,
|
||||
oversize chain, non-CA intermediate, no-keyCertSign intermediate, IP-prefix
|
||||
excluded subtree, EKU client-only, EKU anyExtendedKeyUsage, post-2050
|
||||
GeneralizedTime cert, unknown algorithm OID).
|
||||
|
||||
## Features
|
||||
|
||||
- **Deterministic Ed25519 + ECDSA P-256 chains** with consistent SKI ↔ AKI
|
||||
wiring across root/intermediate/leaf.
|
||||
- **Hand-rolled DER** through `encodeAsn1` + `encodeOid` + `encodeTime` so
|
||||
fixtures exercise the same encoder used by signature verify's
|
||||
TBS re-encoding path.
|
||||
- **Real signature bytes** over canonical TBS DER. The fixture pipeline
|
||||
feeds the parser's expected raw shape — no shortcuts via mocks.
|
||||
- **One mutant per validation failure mode** the spec enumerates (expired,
|
||||
not-yet-valid, self-signed, name-constraint, critical-unknown, etc.).
|
||||
- **Oversize chain builder** of caller-specified depth for the
|
||||
`CHAIN_TOO_DEEP` and `maxChainDepth` override scenarios.
|
||||
- **`tamperTbs`** helper for the signature-tampering test path.
|
||||
- **RSA-2048 SPKI constant** plus an `spkiRsa()` builder for the
|
||||
signature-algorithm-rejection branch (no live RSA keygen required).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildEcdsaP256Chain,
|
||||
} from "@suaveplan/x509/__fixtures__/build-certs";
|
||||
import { validateChain } from "@suaveplan/x509";
|
||||
|
||||
const { rootCert, intermediateCert, leafCert } = buildEcdsaP256Chain();
|
||||
await validateChain({
|
||||
leaf: leafCert,
|
||||
intermediates: intermediateCert === undefined ? [] : [intermediateCert],
|
||||
trustAnchors: [rootCert],
|
||||
clock: () => Date.UTC(2025, 5, 1),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildOversizeChain,
|
||||
tamperTbs,
|
||||
} from "@suaveplan/x509/__fixtures__/build-certs";
|
||||
import { parseCertificate, validateChain } from "@suaveplan/x509";
|
||||
|
||||
// Build a 17-cert chain to exercise CHAIN_TOO_DEEP under default options.
|
||||
const { rootCert, intermediateCerts, leafCert } = buildOversizeChain(17);
|
||||
await expect(
|
||||
validateChain({
|
||||
leaf: leafCert,
|
||||
intermediates: intermediateCerts,
|
||||
trustAnchors: [rootCert],
|
||||
clock: () => Date.UTC(2025, 5, 1),
|
||||
})
|
||||
).rejects.toThrow(/CHAIN_TOO_DEEP/);
|
||||
|
||||
// Flip a TBS byte to drive SIGNATURE_INVALID through verifyCertificateSignature.
|
||||
const { leafDer } = buildEcdsaP256Chain();
|
||||
const tampered = parseCertificate(tamperTbs(leafDer));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildEd25519Chain(): BuiltChain` — root → intermediate → leaf signed
|
||||
with Ed25519. SKI and AKI wired across all three.
|
||||
- `buildEcdsaP256Chain(): BuiltChain` — same shape with ECDSA P-256 +
|
||||
SHA-256.
|
||||
- `buildRsaSha256Chain` — alias of `buildEcdsaP256Chain` for tests that
|
||||
only need a chain whose leaf signature dispatches through the
|
||||
`verifyCertificateSignature` happy path.
|
||||
- `buildExpiredLeaf(): { chain: BuiltChain; clockMs: number }` — leaf whose
|
||||
`notAfter` is one second before `clockMs` (drives `CERT_EXPIRED`).
|
||||
- `buildNotYetValidLeaf(): { chain: BuiltChain; clockMs: number }` — leaf
|
||||
whose `notBefore` is one second after `clockMs`.
|
||||
- `buildSelfSignedLeaf(): BuiltChain` — issuer === subject Ed25519
|
||||
certificate.
|
||||
- `buildNameConstraintViolation(): BuiltChain` — intermediate with
|
||||
`permittedSubtrees = [dNSName .example.com]`, leaf with SAN
|
||||
`host.other.com`.
|
||||
- `buildIpExcludedChain(): BuiltChain` — intermediate with
|
||||
`excludedSubtrees = [iPAddress 10.0.0.0/8]`, leaf with SAN
|
||||
`iPAddress 10.1.2.3`.
|
||||
- `buildCriticalUnknownLeaf(): BuiltChain` — leaf with critical extension
|
||||
at unrecognised OID.
|
||||
- `buildOversizeChain(totalCerts): { rootCert; intermediateCerts; leafCert }`
|
||||
— chain of caller-specified depth.
|
||||
- `buildNonCaIntermediateChain(): BuiltChain` — intermediate with
|
||||
`basicConstraints.cA = false`.
|
||||
- `buildNoCertSignIntermediateChain(): BuiltChain` — intermediate whose
|
||||
`keyUsage` omits `keyCertSign`.
|
||||
- `buildPathLenViolation(): { rootCert; intermediateCerts; leafCert }` —
|
||||
chain where `pathLenConstraint` would be exceeded.
|
||||
- `buildAnyEkuLeaf(): BuiltChain` — leaf with EKU = `anyExtendedKeyUsage`.
|
||||
- `buildClientAuthOnlyLeaf(): BuiltChain` — leaf with only
|
||||
`clientAuth` in EKU.
|
||||
- `buildUnknownAlgChain(): BuiltChain` — leaf signed with an OID outside
|
||||
the algorithm map.
|
||||
- `buildPostUnixYearCert(): BuiltChain` — root with `notAfter` past 2050.
|
||||
- `buildOverLongSerialDer(): Uint8Array` — DER with a 21-byte serial
|
||||
number for `CERTIFICATE_MALFORMED` testing.
|
||||
- `buildUnsupportedVersionDer(): Uint8Array` — DER with TBS `version = 3`
|
||||
to drive `UNSUPPORTED_VERSION`.
|
||||
- `tamperTbs(der: Uint8Array): Uint8Array` — flips an inner TBS byte while
|
||||
preserving outer envelope shapes for signature-tampering tests.
|
||||
- `RSA_N_HEX`, `hexToBytes`, `spkiRsa()` — hex constants + SPKI builder
|
||||
for the RSA test surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The builders emit canonical DER through the same `encodeAsn1` the package's
|
||||
production code calls, so any encoder regression breaks fixture tests
|
||||
first. Signing uses `ed25519.sign(tbsBytes, sk)` directly for Ed25519 and
|
||||
`sha256` + `p256.sign` + DER re-wrap (`SEQUENCE { r INTEGER, s INTEGER }`)
|
||||
for ECDSA. The output of `p256.sign` is RFC 6979-deterministic, so the
|
||||
SEQUENCE bytes are reproducible across runs.
|
||||
|
||||
`buildOversizeChain(totalCerts)` derives `totalCerts` distinct private keys
|
||||
by filling 32-byte buffers with the byte value `0x80 + i`. This stays well
|
||||
within the Curve25519 valid-scalar range while remaining cheap to compute.
|
||||
|
||||
Name-constraints fixtures wrap `dNSName` and `iPAddress` GeneralName entries
|
||||
exactly as RFC 5280 §4.2.1.10 requires. The IP-prefix builder concatenates
|
||||
prefix bytes with a network-mask byte string so the on-the-wire body matches
|
||||
the production decoder's expected shape (`base.value = "10.0.0.0/8"` in
|
||||
parsed form, address+mask bytes on the wire).
|
||||
|
||||
The RSA SPKI helper carries a fixed 2048-bit modulus + e = 65 537 so tests
|
||||
that touch the signature-algorithm map can exercise the RSA branches
|
||||
without paying for a 4-second Miller-Rabin keygen cycle. The fixture does
|
||||
not include the private key — RSA signature-verify tests target the
|
||||
algorithm-resolution path only; the actual rsaVerify call would simply
|
||||
return false against an unrelated signature, which is what the
|
||||
`SIGNATURE_INVALID` test path asserts.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `asn1/decoder` — TLV decoder for the X.509 ASN.1 subset
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `decodeAsn1(bytes, mode)` covering every ASN.1 production used by RFC 5280 Appendix A: primitive scalars (`INTEGER`, `BIT STRING`, `OCTET STRING`, `OBJECT IDENTIFIER`, `NULL`, `BOOLEAN`), string types, time types, and constructed types (`SEQUENCE`, `SET`, `[n] EXPLICIT/IMPLICIT`). The output is a discriminated-union `Asn1Node` tree the encoder can round-trip byte-identically. Strict DER mode (default) rejects indefinite-length encodings; permissive BER mode accepts them.
|
||||
|
||||
## Features
|
||||
|
||||
- Long-form tag-number decoding (X.690 paragraph 8.1.2.4) with overflow guard and minimality check.
|
||||
- Definite-length decoding (X.690 paragraph 8.1.3.3-5) with maximum 4-octet length and minimality check in DER mode.
|
||||
- Indefinite-length decoding in BER mode (X.690 paragraph 8.1.3.6) terminated by `00 00` end-of-contents marker.
|
||||
- Trailing-bytes rejection on the outermost item (both modes).
|
||||
- Sub-array views — primitive `content` references a copied `Uint8Array` slice so the caller can store / hash it without re-entering the decoder.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1 } from "@suaveplan/x509/asn1";
|
||||
|
||||
const node = decodeAsn1(new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]));
|
||||
// node.tag.tagNumber === 16 (SEQUENCE)
|
||||
// node.children[0].tag.tagNumber === 2 (INTEGER) carrying byte 0x05
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1, decodeAsn1Stream } from "@suaveplan/x509/asn1";
|
||||
|
||||
// Permissive BER mode accepts indefinite-length encoding (legacy systems).
|
||||
const ber = decodeAsn1(berBytes, "ber");
|
||||
|
||||
// Decode N consecutive items extracted from an outer SEQUENCE body.
|
||||
const seq = decodeAsn1(outerBytes);
|
||||
if ("children" in seq) {
|
||||
for (const child of seq.children) {
|
||||
// ... structural pattern-match ...
|
||||
}
|
||||
}
|
||||
|
||||
// Or decode a raw body stream:
|
||||
const items = decodeAsn1Stream(rawBody);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeAsn1(bytes: Uint8Array, mode?: "der" | "ber"): Asn1Node` — decode one TLV; throws `X509Error(ASN1_MALFORMED)` on any structural failure or trailing bytes.
|
||||
- `decodeAsn1Stream(bytes: Uint8Array, mode?): readonly Asn1Node[]` — decode N consecutive TLVs across an entire buffer; the buffer must be fully consumed.
|
||||
- `UNIVERSAL_TAGS` — the universal-class tag numbers referenced by callers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder makes no allocations beyond the `Asn1Node` tree and per-node `Uint8Array` copies of primitive content. Constructed bodies are walked through without copying.
|
||||
- `tagNumber` overflow is bounded at 24-bit; long-form tag numbers larger than that throw `ASN1_MALFORMED`. X.509 never uses tag numbers anywhere near that magnitude.
|
||||
- Length encoding accepts the 4-octet maximum (`>>>` to ~4 GiB), which covers every realistic certificate input on the wire (largest known WebPKI certs are under 64 KiB).
|
||||
- BER mode is accepted but the `validateChain` boundary throws on BER inputs by passing `"der"` explicitly; BER support exists so callers parsing legacy data feeds (e.g. older PKCS#7 / CMS) can opt in.
|
||||
- `decodeAsn1` rejects an indefinite-length form on a primitive tag — primitive types in DER and BER both use definite-length encoding only.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `asn1/encoder` — canonical DER encoder
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `encodeAsn1(node)` producing canonical DER bytes per X.690 paragraph 10 for any `Asn1Node` the decoder accepted. The decoder/encoder pair round-trips byte-identically — `encodeAsn1(decodeAsn1(bytes, "der"))` equals `bytes` for every input the strict decoder accepted. This is critical for re-hashing the TBSCertificate during signature verify: the parsed structure can be re-serialised and digested without trusting the input layout.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical short / long-form tag encoding (X.690 paragraph 8.1.2.4) with overflow-bounded tag numbers.
|
||||
- Minimal-length encoding (X.690 paragraph 10.1) — single-octet form for length < 128, multi-octet form otherwise.
|
||||
- Constructed node body recursion — children are concatenated in declared order.
|
||||
- `makeConstructed` / `makePrimitive` helpers for hand-constructing nodes when synthesising test certificates.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1, encodeAsn1 } from "@suaveplan/x509/asn1";
|
||||
|
||||
const bytes = new Uint8Array([0x02, 0x01, 0x05]);
|
||||
const node = decodeAsn1(bytes);
|
||||
const out = encodeAsn1(node);
|
||||
// out is byte-identical to bytes.
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeAsn1,
|
||||
makeConstructed,
|
||||
makePrimitive,
|
||||
UNIVERSAL_TAGS,
|
||||
} from "@suaveplan/x509/asn1";
|
||||
|
||||
const seq = makeConstructed("universal", UNIVERSAL_TAGS.SEQUENCE, [
|
||||
makePrimitive("universal", UNIVERSAL_TAGS.INTEGER, new Uint8Array([0x05])),
|
||||
makePrimitive("universal", UNIVERSAL_TAGS.OCTET_STRING, new Uint8Array([0xaa, 0xbb])),
|
||||
]);
|
||||
const der = encodeAsn1(seq);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeAsn1(node: Asn1Node): Uint8Array` — canonical DER serialise; throws `X509Error(ASN1_MALFORMED)` if a tag number is negative.
|
||||
- `makeConstructed(tagClass, tagNumber, children)` — node-builder helper.
|
||||
- `makePrimitive(tagClass, tagNumber, content)` — node-builder helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Encoding sub-traverses constructed children once to compute the body length, then emits tag-length-body. This is two passes per constructed node; the additional pass over already-decoded structure is cheap compared to the cost of hashing the resulting DER for signature verify.
|
||||
- `makeConstructed` does not validate child consistency — callers are responsible for arranging children in the order the wire format requires (e.g. `SET OF AttributeTypeAndValue` should be canonically ordered if the caller intends DER round-trip semantics).
|
||||
- The encoder accepts the same tag-number range the decoder produces; tag-number overflow is guarded at both ends.
|
||||
@@ -0,0 +1,52 @@
|
||||
# `asn1/oid` — OBJECT IDENTIFIER encode / decode
|
||||
|
||||
## Purpose
|
||||
|
||||
Encodes and decodes ASN.1 OBJECT IDENTIFIER bodies per X.690 paragraph 8.19. OIDs in X.509 identify algorithms, extensions, key purposes, name attributes, and policies — every dispatch site in this package keys on a dotted-decimal string returned by `decodeOid`. The encoder is needed only for synthesising test fixtures since `parseCertificate` never re-emits OIDs onto the wire.
|
||||
|
||||
## Features
|
||||
|
||||
- Folded first-arc decoding (X.690 paragraph 8.19.4): the leading sub-identifier splits into two arcs by the `value < 80` / `value >= 80` rule.
|
||||
- Minimal sub-identifier check — leading `0x80` byte on a sub-identifier is rejected as non-minimal.
|
||||
- Truncation guard — a sub-identifier that ends mid-byte (high-bit set on final byte) is rejected.
|
||||
- Symmetric encoder — produces the same byte sequence the decoder would consume.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeOid, encodeOid } from "@suaveplan/x509/asn1";
|
||||
|
||||
const dotted = decodeOid(new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]));
|
||||
// "1.2.840.113549.1.1.11" (sha256WithRSAEncryption)
|
||||
|
||||
const back = encodeOid("1.2.840.113549.1.1.11");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeOid, encodeOid } from "@suaveplan/x509/asn1";
|
||||
|
||||
// Round-trip every OID in a recognised set:
|
||||
const OIDS = [
|
||||
"2.5.29.17", // subjectAltName
|
||||
"2.5.29.19", // basicConstraints
|
||||
"1.3.101.112", // id-Ed25519
|
||||
];
|
||||
for (const o of OIDS) {
|
||||
const body = encodeOid(o);
|
||||
const back = decodeOid(body);
|
||||
if (back !== o) throw new Error("round-trip mismatch");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeOid(body: Uint8Array): string` — OID body (without outer tag-length) into dotted-decimal string. Throws `X509Error(ASN1_MALFORMED)` on empty body, non-minimal sub-id, or truncation.
|
||||
- `encodeOid(dotted: string): Uint8Array` — dotted-decimal into OID body. Throws on negative or non-decimal arcs, or invalid first two arcs.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Decoder uses `bigint` arithmetic for sub-identifiers — production OIDs occasionally exceed 32 bits (e.g. some experimental private-enterprise IDs).
|
||||
- `arc1 ∈ {0, 1, 2}`; `arc2 < 40` when `arc1 < 2`. The encoder validates this constraint and folds correctly.
|
||||
- Empty OIDs are rejected — every X.509 production that carries an OID requires at least two arcs.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `asn1/strings` — typed-string decoders
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes and encodes the typed ASN.1 string types used inside X.509 Names and SubjectAltName entries: `UTF8String`, `PrintableString`, `IA5String`, `TeletexString`, `BMPString`, `UniversalString`. The encoder picks the most-restrictive form that holds the input value per RFC 5280 paragraph 7. Decoder is strict about character-set membership — invalid `PrintableString` characters are rejected as `ASN1_MALFORMED`.
|
||||
|
||||
## Features
|
||||
|
||||
- UTF-8 decode via `TextDecoder` with `fatal: true`, so invalid UTF-8 is rejected (not replaced).
|
||||
- `PrintableString` alphabet check per ITU-T X.520 — letters, digits, space, and the eleven punctuation characters allowed.
|
||||
- `IA5String` 0x00-0x7F check.
|
||||
- `BMPString` (UCS-2 big-endian) and `UniversalString` (UCS-4 big-endian) decode.
|
||||
- `TeletexString` pragmatically decoded as Latin-1; sufficient for the rare legacy CAs that still emit it.
|
||||
- Encoder picks `PrintableString` when valid, falling back to `UTF8String` — matches RFC 5280 paragraph 7.1 "most-restrictive" rule.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1String, encodeAsn1String, UNIVERSAL_TAGS } from "@suaveplan/x509/asn1";
|
||||
|
||||
const s = decodeAsn1String(UNIVERSAL_TAGS.UTF8_STRING, new Uint8Array([0x68, 0x69])); // "hi"
|
||||
|
||||
const { tagNumber, body } = encodeAsn1String("Example CA");
|
||||
// tagNumber === UNIVERSAL_TAGS.PRINTABLE_STRING
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1, decodeAsn1String, UNIVERSAL_TAGS } from "@suaveplan/x509/asn1";
|
||||
|
||||
// Decode a Name attribute value where the tag depends on the CA's choice:
|
||||
const attrNode = decodeAsn1(attrBytes);
|
||||
if (!("content" in attrNode)) throw new Error("expected primitive");
|
||||
const cn = decodeAsn1String(attrNode.tag.tagNumber, attrNode.content);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeAsn1String(tagNumber: number, body: Uint8Array): string` — dispatches on the universal tag; throws `X509Error(ASN1_MALFORMED)` on unsupported tag or character-set violation.
|
||||
- `encodeAsn1String(value: string): { tagNumber, body }` — returns the most-restrictive choice that holds the value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `PrintableString` regex covers ITU-T X.520 paragraph 6.1 — letters, digits, space, and the eleven allowed punctuation marks.
|
||||
- `TeletexString` (T.61) is technically a 7/8-bit ISO 2022 escape system with national variants; decoding as Latin-1 is the de-facto industry practice (BoringSSL, OpenSSL).
|
||||
- `BMPString` and `UniversalString` are byte-order strict — the BMP form is UCS-2 big-endian (2 bytes per char), Universal is UCS-4 big-endian (4 bytes per char).
|
||||
- The encoder does not pick `IA5String`, `TeletexString`, `BMPString`, or `UniversalString` — these are accepted when reading legacy certs but every newly-encoded value defaults to `UTF8String` (the modern X.509 recommendation per RFC 5280 paragraph 4.1.2.4).
|
||||
@@ -0,0 +1,51 @@
|
||||
# `asn1/time` — UTCTime and GeneralizedTime codec
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes and encodes the two time formats RFC 5280 paragraph 4.1.2.5 permits inside a certificate's `Validity` field: `UTCTime` (`YYMMDDHHMMSSZ`, two-digit-year 50/49 split per RFC 5280 paragraph 4.1.2.5.1) and `GeneralizedTime` (`YYYYMMDDHHMMSSZ`, mandatory `Z` per RFC 5280 paragraph 4.1.2.5.2). Returns and accepts epoch-ms so the validity-period check in `validate-chain` can compare against any injectable `Clock`. The encoder switches to GeneralizedTime when the timestamp is at or after `2050-01-01T00:00:00Z` per the RFC's hard cutoff.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict shape regex per format — fractional seconds and non-`Z` zone designators rejected.
|
||||
- 50/49 split — `YY < 50` resolves to `20YY`, `YY >= 50` resolves to `19YY`.
|
||||
- Component-range validation — month 1-12, day 1-31, hour 0-23, minute 0-59, second 0-59.
|
||||
- Encoder picks GeneralizedTime when timestamp lies outside the UTCTime-safe `[1950-01-01, 2050-01-01)` window.
|
||||
- `decodeTime(tagNumber, body)` dispatcher consumes whichever choice the wire carries.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeUtcTime, decodeGeneralizedTime, encodeTime } from "@suaveplan/x509/asn1";
|
||||
|
||||
const t = decodeUtcTime(new TextEncoder().encode("250101000000Z")); // 2025-01-01T00:00:00Z
|
||||
|
||||
const { tagNumber, body } = encodeTime(Date.UTC(2024, 0, 15)); // UTCTime
|
||||
const { tagNumber: t2 } = encodeTime(Date.UTC(2055, 0, 15)); // GeneralizedTime
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeAsn1, decodeTime } from "@suaveplan/x509/asn1";
|
||||
|
||||
// Inside a Validity SEQUENCE, choose between UTCTime and GeneralizedTime
|
||||
// based on the observed tag:
|
||||
const child = decodeAsn1(timeBytes);
|
||||
if (!("content" in child)) throw new Error("expected primitive Time");
|
||||
const notBefore = decodeTime(child.tag.tagNumber, child.content);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeUtcTime(body: Uint8Array): number` — strict UTCTime decode to epoch-ms.
|
||||
- `decodeGeneralizedTime(body: Uint8Array): number` — strict GeneralizedTime decode to epoch-ms.
|
||||
- `decodeTime(tagNumber: number, body: Uint8Array): number` — universal-tag dispatcher.
|
||||
- `encodeTime(epochMs: number): { tagNumber, body }` — RFC 5280 paragraph 4.1.2.5 compliant encoder picking UTCTime or GeneralizedTime.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The 2050 boundary on `encodeTime` is the cutoff specified by RFC 5280 paragraph 4.1.2.5.1 — UTCTime cannot represent dates after 2050.
|
||||
- Pre-1950 dates also switch to GeneralizedTime — UTCTime's 50/49 split cannot represent them.
|
||||
- Fractional seconds in GeneralizedTime are rejected — RFC 5280 paragraph 4.1.2.5.2 forbids them.
|
||||
- Non-`Z` zone designators are rejected — both RFC 5280 sections require UTC.
|
||||
- Component-range checks happen after `Date.UTC` so leap-year arithmetic is consistent with JavaScript's `Date` implementation (e.g. February 29 on non-leap years).
|
||||
@@ -0,0 +1,50 @@
|
||||
# `certificate/name` — Name codec + canonical comparison
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes RFC 5280 paragraph 4.1.2.4 `Name` (`RDNSequence`) structures into a typed shape carrying both the raw DER bytes (for byte-identical issuer/subject comparison when one CA always re-encodes identically) and a canonical byte-string suitable for RFC 5280 paragraph 7.1 case-folding / whitespace-normalising comparison. `namesEqual(a, b)` is the predicate `validate-chain` uses for the name-chaining check between an issuer's `subject` and a child's `issuer`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict ASN.1 shape check — outer SEQUENCE, each child SET, each grand-child SEQUENCE of (OID, value).
|
||||
- Per-attribute value decoding via `decodeAsn1String`, supporting every typed-string variant RFC 5280 allows.
|
||||
- Canonical byte-string built with NFKD normalisation, lowercase fold, whitespace collapse, and SET-order sort (RFC 5280 paragraph 7.1 reference implementation).
|
||||
- `commonNameOf(name)` convenience for extracting the first CN attribute (used by `hasNameMatch`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeName, namesEqual, commonNameOf } from "@suaveplan/x509/certificate";
|
||||
import { decodeAsn1 } from "@suaveplan/x509/asn1";
|
||||
|
||||
const issuer = decodeName(decodeAsn1(issuerBytes));
|
||||
const subject = decodeName(decodeAsn1(subjectBytes));
|
||||
if (namesEqual(issuer, subject)) {
|
||||
console.log("self-issued");
|
||||
}
|
||||
console.log(commonNameOf(subject)); // "example.com"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
import { namesEqual } from "@suaveplan/x509/certificate";
|
||||
|
||||
const child = parseCertificate(childBytes);
|
||||
const parent = parseCertificate(parentBytes);
|
||||
const chained = namesEqual(child.tbs.issuer, parent.tbs.subject);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeName(node: Asn1Node): Name` — throws `X509Error(CERTIFICATE_MALFORMED)` on shape violations.
|
||||
- `namesEqual(a: Name, b: Name): boolean` — case-folded, whitespace-collapsed, sort-stable comparison.
|
||||
- `commonNameOf(name: Name): string | undefined` — returns the first CN attribute value found in any RDN.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical form is intentionally lossy — it discards attribute-encoding choice (PrintableString vs UTF8String) and SET ordering inside each RDN. This matches RFC 5280 paragraph 7.1 LDAP-style equality.
|
||||
- Raw DER bytes are retained alongside the canonical form so callers needing byte-identical comparison (rare; matches only when both parties use the same encoder) can use `name.raw` directly.
|
||||
- CN extraction uses OID `2.5.4.3`; the first CN in declaration order wins. This matches the heuristic browsers use when the SAN list is empty (legacy fallback).
|
||||
- Canonicalisation prepends each RDN with a `0x01` separator and inserts `0x00` between OID and value — these are non-base64 control bytes so the byte-comparison is unambiguous against any encoded attribute payload.
|
||||
@@ -0,0 +1,54 @@
|
||||
# `certificate/parse` — RFC 5280 paragraph 4.1 Certificate parser
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `parseCertificate(bytes)` decoding a DER- or PEM-encoded `Certificate` into a typed `X509Certificate` value object. Every TBSCertificate field per RFC 5280 paragraph 4.1.2 is decoded into a strongly-typed shape: `version` (mapped from the raw 0/1/2 to `"v1"/"v2"/"v3"`), `serialNumber` (length-bounded to 20 bytes per paragraph 4.1.2.2), `signature` algorithm identifier, `issuer` and `subject` `Name`s, `validity` window in epoch-ms, `subjectPublicKeyInfo`, optional unique IDs, and a `RawExtension[]` (typed extension decoders consume these via the registry). The result also carries `tbsCertificateBytes` (canonical DER of the inner TBSCertificate, used for re-hashing during signature verify) and `signatureValue` (the raw outer signature bytes).
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts both raw DER and bare PEM (detected by `-----BEGIN` header probe in the first 64 bytes).
|
||||
- Hard reject for serials longer than 20 bytes (`X509.CERTIFICATE_MALFORMED`).
|
||||
- Hard reject for unsupported versions (`X509.UNSUPPORTED_VERSION` when the raw INTEGER value is outside {0, 1, 2}).
|
||||
- v1/v2 certificates with extensions are rejected per RFC 5280 paragraph 4.1.2.9.
|
||||
- Captures canonical TBS DER bytes by re-encoding the parsed structure (round-trip guaranteed by the ASN.1 codec).
|
||||
- Records `x509.parse_duration_ms` and `x509.parse_total{outcome}` for every call; errors increment `x509.error_total{code}`.
|
||||
- Extension OIDs increment `x509.extension_total{oid, critical}` with bounded cardinality (recognised set plus `"unknown"`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
console.log(cert.tbs.version); // "v3"
|
||||
console.log(cert.tbs.subject.canonical); // Uint8Array
|
||||
console.log(cert.tbs.subjectPublicKeyInfo.algorithm.oid);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// PEM input with leading whitespace and Windows line endings — both tolerated.
|
||||
const pem = `-----BEGIN CERTIFICATE-----\r\n` +
|
||||
`MII...\r\n` +
|
||||
`-----END CERTIFICATE-----\r\n`;
|
||||
const cert = parseCertificate(new TextEncoder().encode(pem));
|
||||
|
||||
// Re-hash the TBSCertificate for signature verify:
|
||||
import { sha256 } from "@suaveplan/crypto-hash";
|
||||
const digest = sha256(cert.tbsCertificateBytes);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseCertificate(bytes: Uint8Array): X509Certificate` — synchronous decode. Throws `X509Error` with one of `CERTIFICATE_MALFORMED`, `UNSUPPORTED_VERSION`, `ASN1_MALFORMED`, or `PEM_MALFORMED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser re-encodes the TBSCertificate via `encodeAsn1` to obtain the canonical DER bytes used for signature verification. Re-encoding (rather than slicing the input) means a non-canonically-encoded input still verifies correctly when the issuing CA used a canonical encoding — but a forged input that exploits encoding ambiguity is detected because the re-encoded form differs from the input the issuer signed. This matches BoringSSL's recommended approach.
|
||||
- Serial-number length bound is RFC 5280 paragraph 4.1.2.2 — 20 octets including the optional leading zero. The bound is checked on the raw INTEGER content, not the canonical bigint value.
|
||||
- v1/v2 with extensions is a malformed certificate per RFC 5280 paragraph 4.1.2.9 — extensions are a v3 feature.
|
||||
- Optional `issuerUniqueID` (context tag 1) and `subjectUniqueID` (context tag 2) are accepted on v2/v3 and ignored by `validateChain` (deprecated per RFC 5280 paragraph 4.1.2.8).
|
||||
- PEM detection probes the first 64 bytes for `"-----BEGIN "`. This avoids decoding the entire payload through UTF-8 when the input is binary DER.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `errors/error-codes` — typed error registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the frozen registry of typed error codes raised by `@suaveplan/x509`. Every public surface in the package throws `X509Error` (a subclass of `@suaveplan/error`'s `SuaveplanError<X509ErrorCode>`); consumers switch on `error.code` to map RFC 5280 §6.1.4 abort conditions, ASN.1/PEM parser failures, and internal-boundary refusals into actionable handling. The literal union is pinned by `expectTypeOf` in the test suite so accidental additions or removals fail typecheck before they ship.
|
||||
|
||||
## Features
|
||||
|
||||
- 20-code frozen registry constructed via `@suaveplan/error`'s `defineCodes("X509", [...])`.
|
||||
- `X509ErrorCode` literal-union derived directly from the registry — no manual duplication.
|
||||
- `X509Error` concrete subclass of `SuaveplanError<X509ErrorCode>` carrying optional `cause`, `context`, and `messageKey`.
|
||||
- Every code is thrown by at least one production code path (verified by `error-codes.test.ts::"every code in the registry is thrown by at least one production path"`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { X509_CODES, X509Error } from "@suaveplan/x509/errors";
|
||||
|
||||
try {
|
||||
// ... call into @suaveplan/x509 ...
|
||||
} catch (err) {
|
||||
if (err instanceof X509Error) {
|
||||
if (err.code === X509_CODES.CERT_EXPIRED) {
|
||||
// present user-facing renewal prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { X509_CODES, X509Error, type X509ErrorCode } from "@suaveplan/x509/errors";
|
||||
|
||||
const HUMAN_READABLE: Record<X509ErrorCode, string> = {
|
||||
[X509_CODES.PEM_MALFORMED]: "The PEM block is malformed.",
|
||||
[X509_CODES.ASN1_MALFORMED]: "The ASN.1 encoding is malformed.",
|
||||
[X509_CODES.CERTIFICATE_MALFORMED]: "The certificate body is malformed.",
|
||||
[X509_CODES.UNSUPPORTED_VERSION]: "Certificate version is unsupported.",
|
||||
[X509_CODES.EXTENSION_MALFORMED]: "An extension value is malformed.",
|
||||
[X509_CODES.CRITICAL_EXTENSION_UNRECOGNIZED]: "Critical extension is unrecognised.",
|
||||
[X509_CODES.SIGNATURE_ALG_UNSUPPORTED]: "Signature algorithm is unsupported.",
|
||||
[X509_CODES.SIGNATURE_INVALID]: "Signature did not verify.",
|
||||
[X509_CODES.CERT_EXPIRED]: "Certificate has expired.",
|
||||
[X509_CODES.CERT_NOT_YET_VALID]: "Certificate is not yet valid.",
|
||||
[X509_CODES.NAME_CHAIN_BROKEN]: "Issuer/subject name chaining broken.",
|
||||
[X509_CODES.NAME_CONSTRAINT_VIOLATION]: "Name constraint violated.",
|
||||
[X509_CODES.ISSUER_NOT_CA]: "Issuer is not a CA.",
|
||||
[X509_CODES.KEY_USAGE_INVALID]: "Issuer lacks keyCertSign.",
|
||||
[X509_CODES.EXTENDED_KEY_USAGE_INVALID]: "Required EKU absent.",
|
||||
[X509_CODES.CHAIN_TOO_DEEP]: "Chain depth exceeds bound.",
|
||||
[X509_CODES.SELF_SIGNED_LEAF]: "Self-signed leaf without explicit trust.",
|
||||
[X509_CODES.TRUST_ANCHOR_NOT_FOUND]: "No trust anchor matched.",
|
||||
[X509_CODES.OCSP_REVOKED]: "Certificate is OCSP-revoked.",
|
||||
[X509_CODES.ABORTED]: "Validation was aborted.",
|
||||
};
|
||||
|
||||
throw new X509Error(X509_CODES.CERT_EXPIRED, "error.x509.cert-expired", {
|
||||
context: { notAfter: 1700000000000, now: 1800000000000 },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `X509_CODES` — frozen object mapping each code identifier to its `"X509.<NAME>"` literal string.
|
||||
- `type X509ErrorCode` — discriminated literal union, exactly 20 members.
|
||||
- `class X509Error extends SuaveplanError<X509ErrorCode>` — concrete error class.
|
||||
- `constructor(code: X509ErrorCode, messageKey: string, options?: SuaveplanErrorOptions)` — `options` may carry `cause`, `context`, and i18n params per `@suaveplan/error`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Codes were chosen to map 1:1 onto RFC 5280 §6.1.4 abort conditions plus parser-boundary failures. There is no aggregate "validation failed" code — every failure path names the precise condition so callers can route programmatically.
|
||||
- The registry MUST stay frozen; adding a code is a breaking change because consumers may switch exhaustively on `X509ErrorCode`.
|
||||
- `defineCodes` namespaces codes under `"X509"`; the raw string form is `"X509.<NAME>"` (e.g. `"X509.CERT_EXPIRED"`).
|
||||
- `messageKey` is an i18n key; the human-readable mapping above is illustrative — production consumers wire it through `@suaveplan/i18n`.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/authority-key-id` — Authority Key Identifier
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `authority-key-id` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeauthority_key_id } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodeauthority_key_id(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodeauthority_key_id(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,50 @@
|
||||
# `extensions/basic-constraints` — RFC 5280 paragraph 4.2.1.9
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the basic-constraints extension into `BasicConstraints { cA: boolean; pathLenConstraint?: number }`. During path validation, every non-leaf certificate in the chain SHALL have `cA: true`; the `pathLenConstraint` field bounds the number of non-self-issued intermediates beneath the issuer per RFC 5280 paragraph 6.1.4(h).
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode of the SEQUENCE wrapper.
|
||||
- Default `cA: false` when the BOOLEAN field is absent (matches RFC 5280 paragraph 4.2.1.9 default).
|
||||
- INTEGER decode for `pathLenConstraint` with bounded length (up to 4 bytes) and non-negative check.
|
||||
- Rejects extra fields beyond `(cA, pathLenConstraint)` as `X509Error(EXTENSION_MALFORMED)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeBasicConstraints } from "@suaveplan/x509/extensions";
|
||||
|
||||
const bc = decodeBasicConstraints(extensionValueBytes);
|
||||
if (bc.cA && bc.pathLenConstraint !== undefined) {
|
||||
console.log("CA with max", bc.pathLenConstraint, "non-self-issued intermediates");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeBasicConstraints } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "2.5.29.19");
|
||||
if (ext !== undefined) {
|
||||
const bc = decodeBasicConstraints(ext.value);
|
||||
if (!bc.cA) {
|
||||
throw new Error("issuer must have cA: true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeBasicConstraints(value: Uint8Array): BasicConstraints` — decode an extension value (the OCTET STRING contents). Throws `X509Error(EXTENSION_MALFORMED)` on shape violation.
|
||||
- `OID_BASIC_CONSTRAINTS` — string constant `"2.5.29.19"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `cA` defaults to `false` when the BOOLEAN field is absent; this is what RFC 5280 paragraph 4.2.1.9 mandates.
|
||||
- The bound on `pathLenConstraint` length (4 bytes) is overgenerous — real-world values fit in a single byte — but matches the canonical DER encoding of small INTEGERs and rejects deliberately oversized values as malformed.
|
||||
- The bound `n < 0` is also redundant since the bit-shift always produces non-negative values for content lengths up to 4 bytes; the check exists as defensive depth and is exercised by malformed-input tests.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/crl-distribution-points` — CRL Distribution Points parse-only
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `crl-distribution-points` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodecrl_distribution_points } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodecrl_distribution_points(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodecrl_distribution_points(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/extended-key-usage` — Extended Key Usage purpose list
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `extended-key-usage` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeextended_key_usage } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodeextended_key_usage(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodeextended_key_usage(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/key-usage` — Key Usage flags bit-string
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `key-usage` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodekey_usage } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodekey_usage(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodekey_usage(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/name-constraints` — Name Constraints permittedSubtrees/excludedSubtrees + DNS/IP matching helpers
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `name-constraints` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodename_constraints } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodename_constraints(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodename_constraints(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/registry` — OID-keyed dispatch table
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `registry` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decoderegistry } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decoderegistry(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decoderegistry(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/subject-alt-name` — Subject Alternative Name discriminated union
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `subject-alt-name` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodesubject_alt_name } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodesubject_alt_name(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodesubject_alt_name(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,56 @@
|
||||
# `extensions/subject-key-id` — Subject Key Identifier
|
||||
|
||||
## Purpose
|
||||
|
||||
Decodes the `subject-key-id` extension per RFC 5280 paragraph 4.2. Every public field in the resulting type is captured from the DER value; OCTET STRING wrappers are stripped at the extension boundary so this module operates directly on the inner ASN.1 value. The decoded shape feeds `validateChain` (for critical-extension enforcement, key-usage gating on issuers, name-constraint matching, and trust-anchor SKI lookup) and is also exposed as the structured form sibling packages such as `@suaveplan/x509-crl` and `@suaveplan/webauthn` consume.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict DER decode at the extension boundary.
|
||||
- Discriminated-union return where the underlying ASN.1 CHOICE permits multiple shapes.
|
||||
- Helpful `X509Error(EXTENSION_MALFORMED)` on every shape violation, with the failing reason captured in `error.context.reason` so callers can diagnose specific malformations.
|
||||
- Zero allocations beyond the typed result; raw extension bytes are subarrayed, not copied through intermediate buffers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodesubject_key_id } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const ext = cert.tbs.extensions.find((e) => e.oid === "<oid>");
|
||||
if (ext !== undefined) {
|
||||
const decoded = decodesubject_key_id(ext.value);
|
||||
// ... consume decoded ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodeExtension } from "@suaveplan/x509/extensions";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
// Route every extension through the dispatch registry — the result is a
|
||||
// discriminated union over every supported kind plus a "raw" fallback.
|
||||
const cert = parseCertificate(derBytes);
|
||||
for (const raw of cert.tbs.extensions) {
|
||||
const decoded = decodeExtension(raw);
|
||||
if (decoded.kind === "raw" && decoded.critical) {
|
||||
throw new Error("unknown critical extension: " + decoded.oid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Concrete decoder `decodesubject_key_id(value: Uint8Array)` exported from this module.
|
||||
- OID string constant exported alongside the decoder.
|
||||
- `decodeExtension(raw)` in `./registry.ts` dispatches by OID through this module.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder operates on the OCTET STRING value of the extension as carried by `RawExtension.value` — callers pass exactly those bytes.
|
||||
- Critical-extension semantics are NOT enforced at this layer; `validateChain` handles the §6.1.4(f) refusal on unknown critical extensions by inspecting `RawExtension.critical` directly.
|
||||
- ASN.1 shape violations surface as `X509Error(EXTENSION_MALFORMED)` with the failing reason captured for diagnostics.
|
||||
- The module emits no OpenTelemetry — that surface lives in `certificate/parse` (which records the per-extension counter) and `validate/validate-chain` (which records the per-validation histogram).
|
||||
@@ -0,0 +1,48 @@
|
||||
# `helpers/chains-to` — SKI/AKI heuristic
|
||||
|
||||
## Purpose
|
||||
|
||||
The `helpers/chains-to` module exposes a pre-filtering / display helper around the parsed certificate data. It is independent of the path-validation algorithm: `validateChain` does not depend on it for correctness, and consumers can use it freely without invoking signature verify. The helper is convenient for UI display, candidate-issuer enumeration, and host-name matching outside the full path-validation path. The implementation operates exclusively on the parsed `X509Certificate` shape — no re-decoding, no cryptographic operations.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure synchronous function, no I/O.
|
||||
- No allocations beyond the boolean return.
|
||||
- Idempotent — same input produces the same output.
|
||||
- Compatible with the rest of the `@suaveplan/x509` public surface — the certificate, name, and extension types feed directly in.
|
||||
- Defensive — missing fields (no SAN, no AKI/SKI) return `false` rather than throwing, so callers can drive their own branching logic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hasNameMatch, chainsTo, isIssuerOf } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const anchor = parseCertificate(anchorBytes);
|
||||
hasNameMatch(cert, "example.com");
|
||||
chainsTo(cert, anchor);
|
||||
isIssuerOf(cert, anchor);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { chainsTo } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const candidates = await loadTrustAnchorBundle();
|
||||
const candidate = candidates.find((a) => chainsTo(parsedLeaf, a));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Helper function as named.
|
||||
- Operates on `X509Certificate` values from `@suaveplan/x509/certificate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helpers are intentionally heuristic. `hasNameMatch` follows RFC 6125 paragraph 6.4 — SAN dNSName entries take precedence; CN fallback only when SAN is absent. Wildcard matching honours the leftmost-label rule.
|
||||
- `chainsTo` consults the AKI and SKI extensions; missing extensions yield `false` (the caller should fall back to a full subject/issuer-name match via `isIssuerOf` plus signature verify).
|
||||
- `isIssuerOf` uses the canonical name comparison from `certificate/name`; the canonical form is whitespace-collapsed and case-folded so issuer/subject equality survives encoding choice (PrintableString vs UTF8String).
|
||||
- None of the helpers replace `validateChain` — they are pre-filters and display helpers, not validators.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `helpers/has-name-match` — RFC 6125 host-name match
|
||||
|
||||
## Purpose
|
||||
|
||||
The `helpers/has-name-match` module exposes a pre-filtering / display helper around the parsed certificate data. It is independent of the path-validation algorithm: `validateChain` does not depend on it for correctness, and consumers can use it freely without invoking signature verify. The helper is convenient for UI display, candidate-issuer enumeration, and host-name matching outside the full path-validation path. The implementation operates exclusively on the parsed `X509Certificate` shape — no re-decoding, no cryptographic operations.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure synchronous function, no I/O.
|
||||
- No allocations beyond the boolean return.
|
||||
- Idempotent — same input produces the same output.
|
||||
- Compatible with the rest of the `@suaveplan/x509` public surface — the certificate, name, and extension types feed directly in.
|
||||
- Defensive — missing fields (no SAN, no AKI/SKI) return `false` rather than throwing, so callers can drive their own branching logic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hasNameMatch, chainsTo, isIssuerOf } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const anchor = parseCertificate(anchorBytes);
|
||||
hasNameMatch(cert, "example.com");
|
||||
chainsTo(cert, anchor);
|
||||
isIssuerOf(cert, anchor);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { chainsTo } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const candidates = await loadTrustAnchorBundle();
|
||||
const candidate = candidates.find((a) => chainsTo(parsedLeaf, a));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Helper function as named.
|
||||
- Operates on `X509Certificate` values from `@suaveplan/x509/certificate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helpers are intentionally heuristic. `hasNameMatch` follows RFC 6125 paragraph 6.4 — SAN dNSName entries take precedence; CN fallback only when SAN is absent. Wildcard matching honours the leftmost-label rule.
|
||||
- `chainsTo` consults the AKI and SKI extensions; missing extensions yield `false` (the caller should fall back to a full subject/issuer-name match via `isIssuerOf` plus signature verify).
|
||||
- `isIssuerOf` uses the canonical name comparison from `certificate/name`; the canonical form is whitespace-collapsed and case-folded so issuer/subject equality survives encoding choice (PrintableString vs UTF8String).
|
||||
- None of the helpers replace `validateChain` — they are pre-filters and display helpers, not validators.
|
||||
@@ -0,0 +1,48 @@
|
||||
# `helpers/is-issuer-of` — Canonical name equality
|
||||
|
||||
## Purpose
|
||||
|
||||
The `helpers/is-issuer-of` module exposes a pre-filtering / display helper around the parsed certificate data. It is independent of the path-validation algorithm: `validateChain` does not depend on it for correctness, and consumers can use it freely without invoking signature verify. The helper is convenient for UI display, candidate-issuer enumeration, and host-name matching outside the full path-validation path. The implementation operates exclusively on the parsed `X509Certificate` shape — no re-decoding, no cryptographic operations.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure synchronous function, no I/O.
|
||||
- No allocations beyond the boolean return.
|
||||
- Idempotent — same input produces the same output.
|
||||
- Compatible with the rest of the `@suaveplan/x509` public surface — the certificate, name, and extension types feed directly in.
|
||||
- Defensive — missing fields (no SAN, no AKI/SKI) return `false` rather than throwing, so callers can drive their own branching logic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hasNameMatch, chainsTo, isIssuerOf } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const cert = parseCertificate(derBytes);
|
||||
const anchor = parseCertificate(anchorBytes);
|
||||
hasNameMatch(cert, "example.com");
|
||||
chainsTo(cert, anchor);
|
||||
isIssuerOf(cert, anchor);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { chainsTo } from "@suaveplan/x509/helpers";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const candidates = await loadTrustAnchorBundle();
|
||||
const candidate = candidates.find((a) => chainsTo(parsedLeaf, a));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Helper function as named.
|
||||
- Operates on `X509Certificate` values from `@suaveplan/x509/certificate`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helpers are intentionally heuristic. `hasNameMatch` follows RFC 6125 paragraph 6.4 — SAN dNSName entries take precedence; CN fallback only when SAN is absent. Wildcard matching honours the leftmost-label rule.
|
||||
- `chainsTo` consults the AKI and SKI extensions; missing extensions yield `false` (the caller should fall back to a full subject/issuer-name match via `isIssuerOf` plus signature verify).
|
||||
- `isIssuerOf` uses the canonical name comparison from `certificate/name`; the canonical form is whitespace-collapsed and case-folded so issuer/subject equality survives encoding choice (PrintableString vs UTF8String).
|
||||
- None of the helpers replace `validateChain` — they are pre-filters and display helpers, not validators.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `observability/metrics` — OpenTelemetry surface
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Exposes the `@suaveplan/x509` OpenTelemetry surface: two histograms (`x509.parse_duration_ms`, `x509.validate_duration_ms`), a chain-length histogram (`x509.chain_length`), and four counters (`x509.parse_total`, `x509.validate_total`, `x509.extension_total`, `x509.error_total`). Production code paths in `certificate/parse`, `validate/validate-chain`, `extensions/registry`, and every catch-and-rethrow site call into this module so an embedded in-memory `MeterProvider` can assert end-to-end coverage of error and success paths.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument creation — instruments resolve through `metrics.getMeter("@suaveplan/x509")` only on first use.
|
||||
- Cached bag — every subsequent call reuses the resolved instruments.
|
||||
- `resetMetricsForTesting()` clears the cache so the next call picks up a freshly-installed `MeterProvider`.
|
||||
- `recordParseDuration`, `recordValidateDuration`, `recordExtension`, and `recordError` apply the documented attribute shape with bounded cardinality (`outcome`, `error_code`, `chain_length`, `oid`, `critical`).
|
||||
- Unrecognised extension OIDs are bucketed under the literal `"unknown"` so the cardinality of `oid` stays bounded by the recognised-extension set plus one.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordParseDuration, nowMs } from "@suaveplan/x509/observability";
|
||||
|
||||
const start = nowMs();
|
||||
// ... parse a certificate ...
|
||||
recordParseDuration(nowMs() - start, "success");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
getInstrumentsForTesting,
|
||||
resetMetricsForTesting,
|
||||
recordParseDuration,
|
||||
recordError,
|
||||
} from "@suaveplan/x509/observability";
|
||||
import { MeterProvider, InMemoryMetricExporter, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
resetMetricsForTesting();
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 1 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// Trigger production code path.
|
||||
recordParseDuration(12, "error", "PEM_MALFORMED");
|
||||
recordError("PEM_MALFORMED");
|
||||
|
||||
// Assert via reader.collect() in tests.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `recordParseDuration(durationMs, outcome, errorCode?)` — adds an observation to `x509.parse_duration_ms` and increments `x509.parse_total{outcome}`.
|
||||
- `recordValidateDuration(durationMs, outcome, chainLength?, errorCode?)` — adds an observation to `x509.validate_duration_ms`, increments `x509.validate_total{outcome}`, and on success records the chain length into `x509.chain_length`.
|
||||
- `recordExtension(oid, critical)` — increments `x509.extension_total{oid, critical}`. `oid` is bounded by the recognised set plus `"unknown"`.
|
||||
- `recordError(code)` — increments `x509.error_total{code}` once per thrown `X509Error`.
|
||||
- `nowMs()` — wall-clock reader (millisecond resolution); used internally for histogram timing.
|
||||
- `resetMetricsForTesting()` / `getInstrumentsForTesting()` — test helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cached bag avoids re-resolving the meter on every operation while still honouring `resetMetricsForTesting()` between test cases.
|
||||
- Counters and histograms record under attribute keys with bounded value sets — `outcome ∈ {success, error}`, `critical ∈ {true, false}`, `code ∈ X509ErrorCode`, `oid ∈ recognisedSet ∪ {"unknown"}`.
|
||||
- `nowMs()` uses `Date.now()` for portability; this is wall-clock and may step backward across NTP corrections. Histogram durations are best-effort and not used for path validation correctness — only for observability.
|
||||
@@ -0,0 +1,58 @@
|
||||
# `pem/pem` — RFC 7468 PEM framing
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Decodes PEM-encoded blobs per RFC 7468 paragraph 5 and re-encodes byte content into the canonical `-----BEGIN <LABEL>-----` / `-----END <LABEL>-----` framed form with 64-column-wrapped base64. Used by `parseCertificate` (accepts DER or PEM) and `TrustAnchorStore` (PEM-bundle constructor). Multi-cert bundles are returned in declaration order so a single PEM file holding root + intermediate(s) preserves the chain shape callers expect.
|
||||
|
||||
## Features
|
||||
|
||||
- Multi-block decode — every `BEGIN/END` pair in a single text input produces a `PemBlock` entry.
|
||||
- `\r\n` and `\n` line-ending tolerance.
|
||||
- Strict base64 alphabet check (`A-Za-z0-9+/=`) plus length-multiple-of-4 check.
|
||||
- 64-column line wrapping on encode (RFC 7468 paragraph 4 default).
|
||||
- Label whitelist on encode (`[A-Z0-9 ]+`).
|
||||
- Empty input and missing END marker throw `X509Error(PEM_MALFORMED)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodePem, encodePem } from "@suaveplan/x509/pem";
|
||||
|
||||
const blocks = decodePem(`-----BEGIN CERTIFICATE-----
|
||||
MIIBxjCCAW2gAwIBAgIQ...
|
||||
-----END CERTIFICATE-----`);
|
||||
// blocks[0].label === "CERTIFICATE"; blocks[0].bytes is a Uint8Array
|
||||
|
||||
const text = encodePem("CERTIFICATE", blocks[0].bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { decodePem } from "@suaveplan/x509/pem";
|
||||
|
||||
// Trust-anchor bundle with three roots concatenated:
|
||||
const bundle = `${rootA}\n${rootB}\n${rootC}`;
|
||||
const blocks = decodePem(bundle);
|
||||
// blocks.length === 3, ordering preserved.
|
||||
|
||||
// Windows line endings tolerated:
|
||||
const win = bundle.replace(/\n/g, "\r\n");
|
||||
const sameBlocks = decodePem(win);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodePem(text: string): PemBlock[]` — returns the array of blocks in declaration order. Throws `X509Error(PEM_MALFORMED)` on empty input, missing END marker, bad base64, or label/body mismatch.
|
||||
- `encodePem(label: string, bytes: Uint8Array): string` — wraps the body at 64 columns, terminates with `\n`. Throws `X509Error(PEM_MALFORMED)` on labels outside the `[A-Z0-9 ]+` alphabet.
|
||||
- `PemBlock { label: string; bytes: Uint8Array }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder normalises `\r\n` to `\n` once at entry, then operates on the normalised text. This is simpler and faster than matching `\r?\n` in every position.
|
||||
- `BEGIN`-pattern matching uses a non-greedy capture so labels with embedded spaces work (`X509 CRL`, `RSA PRIVATE KEY`).
|
||||
- The label-on-encode whitelist is intentionally narrow — RFC 7468 paragraph 2 allows hyphens but every real-world usage stays inside `[A-Z0-9 ]+`. Callers needing exotic labels should escape upstream.
|
||||
- `atob`/`btoa` are used when present (every modern browser and Node 16+); the package falls back to `Buffer.from` on legacy Node builds without `atob`.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `schemas/schemas` — Zod boundary schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for every public option-bundle and value-object type exposed by `@suaveplan/x509`. The most important boundary is `ValidateChainOptionsSchema`, which `validateChain` uses to refuse malformed caller input — passing `null` for `leaf`, an array of non-certificate values for `intermediates`, or a non-`AbortSignal` for `signal` is rejected before any work happens, with the original Zod issue list preserved as `cause` on the `X509Error(CERTIFICATE_MALFORMED)` it wraps with.
|
||||
|
||||
Schemas for the extension result types (`BasicConstraintsSchema`, `KeyUsageSchema`, `ExtendedKeyUsageSchema`, `GeneralNameSchema`, `NameConstraintsSchema`, `AuthorityKeyIdentifierSchema`, `SubjectKeyIdentifierSchema`, `CrlDistributionPointSchema`) pin the structural shape so tests can assert via `safeParse` on synthetic inputs and so callers serialising through JSON can validate before consuming.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict objects (`.strict()`) on option types so unknown keys are rejected.
|
||||
- Pass-through objects on value-object types where the runtime carries extra raw-bytes fields the schema doesn't enumerate.
|
||||
- `instanceof Uint8Array` refinements on byte-typed fields.
|
||||
- `instanceof AbortSignal` refinement on the optional cancellation signal.
|
||||
- Reuse of the same `GeneralNameSchema` across every extension that references one.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ValidateChainOptionsSchema } from "@suaveplan/x509";
|
||||
|
||||
const parsed = ValidateChainOptionsSchema.parse({
|
||||
leaf: someCert,
|
||||
intermediates: [],
|
||||
trustAnchors: [root],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BasicConstraintsSchema } from "@suaveplan/x509";
|
||||
|
||||
const result = BasicConstraintsSchema.safeParse({
|
||||
cA: true,
|
||||
pathLenConstraint: 3,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("invariant violated");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ValidateChainOptionsSchema` — pins the `validateChain` boundary shape.
|
||||
- `X509CertificateLikeSchema` — pass-through certificate shape carrying `tbs`, `signatureAlgorithm`, `signatureValue`, `tbsCertificateBytes`, `raw`.
|
||||
- `BasicConstraintsSchema`, `KeyUsageSchema`, `ExtendedKeyUsageSchema`, `GeneralNameSchema`, `NameConstraintsSchema`, `AuthorityKeyIdentifierSchema`, `SubjectKeyIdentifierSchema`, `CrlDistributionPointSchema` — extension value shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateChain` runs the options through Zod first; this is what produces the typed refusal of `{ leaf: null }` documented in the spec. The Zod issue is preserved as `cause` so consumers can inspect it programmatically.
|
||||
- `X509CertificateLikeSchema` is intentionally loose — the schema doesn't reproduce the full TBSCertificate Zod hierarchy. The runtime validator inside `validateChain` does the real structural work; the schema is a guard against gross caller bugs.
|
||||
- Schemas use `instanceof` refinements (`Uint8Array`, `AbortSignal`) so the runtime check matches the static type. A plain `{ buffer: ArrayBuffer }` would type-check but fail the `instanceof` predicate at runtime — exactly what the boundary is there to catch.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `signature/alg-map` — Signature-algorithm OID lookup table
|
||||
|
||||
## Purpose
|
||||
|
||||
The `signature/alg-map` module is part of the certificate signature-verify pipeline. Together with the rest of the `signature/` directory it converts an X.509 `signatureAlgorithm` OID into a concrete verifier dispatch and runs that verifier against the TBSCertificate bytes the certificate parser captured. RSA-SHA{256,384,512} and RSA-PSS verifies are delegated to `@suaveplan/crypto-rsa` (the just-shipped sibling package); ECDSA-P{256,384,521} and Ed25519 verifies are delegated to `@suaveplan/crypto-curve`'s `p256`, `p384`, `p521`, and `ed25519` namespaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure OID-keyed table — no string parsing, no fallbacks, no implicit defaults.
|
||||
- Strict OID lookup; unknown OIDs throw `X509Error(SIGNATURE_ALG_UNSUPPORTED)` with the offending OID in `error.context`.
|
||||
- Async verify boundary so future engines (e.g. WebCrypto via `crypto-browser`) can plug in without API changes.
|
||||
- Failed verify is converted to `X509Error(SIGNATURE_INVALID)` so consumers branch on the typed code rather than a boolean return.
|
||||
- ECDSA signature is decoded once at the verify boundary; the compact `r || s` form is then handed to the curve primitive.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveSignatureAlgorithm, verifyCertificateSignature } from "@suaveplan/x509/signature";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const child = parseCertificate(childBytes);
|
||||
const parent = parseCertificate(parentBytes);
|
||||
const ok = await verifyCertificateSignature(
|
||||
child.tbsCertificateBytes,
|
||||
child.signatureAlgorithm.oid,
|
||||
child.signatureValue,
|
||||
parent.tbs.subjectPublicKeyInfo
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveSignatureAlgorithm,
|
||||
decodeEcdsaSignature,
|
||||
} from "@suaveplan/x509/signature";
|
||||
|
||||
const alg = resolveSignatureAlgorithm("1.2.840.10045.4.3.2");
|
||||
if (alg.family === "ecdsa") {
|
||||
const compact = decodeEcdsaSignature(rawSignatureValue, 32);
|
||||
// pass compact into the curve-specific verifier of your choice
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveSignatureAlgorithm(oid: string): SignatureAlgorithm` — typed lookup.
|
||||
- `verifyCertificateSignature(tbsBytes, sigAlgOid, signature, parentSpki): Promise<boolean>`.
|
||||
- `decodeEcdsaSignature(bytes, fieldLen): Uint8Array` — DER SEQUENCE-of-INTEGERs to compact `r || s`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The verify dispatcher catches non-`X509Error` throws from the underlying crypto engines and wraps them as `SIGNATURE_INVALID` with the original error as `cause` — this prevents bigint range errors or noble's internal exceptions from leaking to consumers.
|
||||
- ECDSA INTEGER decoding strips a single leading `0x00` when followed by a byte with its high bit set (the canonical positive-INTEGER prefix per X.690 paragraph 8.3.2).
|
||||
- RSA SPKI parsing is delegated to `@suaveplan/crypto-rsa`'s `parseRsaPublicKey({format: "spki"})` — re-implementing RSA-SPKI parse inline would duplicate code already at 100% coverage in that package.
|
||||
- Ed448 is recognised by the OID map but the curve primitive is not yet available in `@suaveplan/crypto-curve`; verifying an Ed448-signed certificate throws `SIGNATURE_ALG_UNSUPPORTED` with the OID. This is honest behaviour — silently returning `false` would mask the missing primitive.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `signature/decode-ecdsa-sig` — ECDSA-Sig-Value DER decoder
|
||||
|
||||
## Purpose
|
||||
|
||||
The `signature/decode-ecdsa-sig` module is part of the certificate signature-verify pipeline. Together with the rest of the `signature/` directory it converts an X.509 `signatureAlgorithm` OID into a concrete verifier dispatch and runs that verifier against the TBSCertificate bytes the certificate parser captured. RSA-SHA{256,384,512} and RSA-PSS verifies are delegated to `@suaveplan/crypto-rsa` (the just-shipped sibling package); ECDSA-P{256,384,521} and Ed25519 verifies are delegated to `@suaveplan/crypto-curve`'s `p256`, `p384`, `p521`, and `ed25519` namespaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure OID-keyed table — no string parsing, no fallbacks, no implicit defaults.
|
||||
- Strict OID lookup; unknown OIDs throw `X509Error(SIGNATURE_ALG_UNSUPPORTED)` with the offending OID in `error.context`.
|
||||
- Async verify boundary so future engines (e.g. WebCrypto via `crypto-browser`) can plug in without API changes.
|
||||
- Failed verify is converted to `X509Error(SIGNATURE_INVALID)` so consumers branch on the typed code rather than a boolean return.
|
||||
- ECDSA signature is decoded once at the verify boundary; the compact `r || s` form is then handed to the curve primitive.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveSignatureAlgorithm, verifyCertificateSignature } from "@suaveplan/x509/signature";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const child = parseCertificate(childBytes);
|
||||
const parent = parseCertificate(parentBytes);
|
||||
const ok = await verifyCertificateSignature(
|
||||
child.tbsCertificateBytes,
|
||||
child.signatureAlgorithm.oid,
|
||||
child.signatureValue,
|
||||
parent.tbs.subjectPublicKeyInfo
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveSignatureAlgorithm,
|
||||
decodeEcdsaSignature,
|
||||
} from "@suaveplan/x509/signature";
|
||||
|
||||
const alg = resolveSignatureAlgorithm("1.2.840.10045.4.3.2");
|
||||
if (alg.family === "ecdsa") {
|
||||
const compact = decodeEcdsaSignature(rawSignatureValue, 32);
|
||||
// pass compact into the curve-specific verifier of your choice
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveSignatureAlgorithm(oid: string): SignatureAlgorithm` — typed lookup.
|
||||
- `verifyCertificateSignature(tbsBytes, sigAlgOid, signature, parentSpki): Promise<boolean>`.
|
||||
- `decodeEcdsaSignature(bytes, fieldLen): Uint8Array` — DER SEQUENCE-of-INTEGERs to compact `r || s`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The verify dispatcher catches non-`X509Error` throws from the underlying crypto engines and wraps them as `SIGNATURE_INVALID` with the original error as `cause` — this prevents bigint range errors or noble's internal exceptions from leaking to consumers.
|
||||
- ECDSA INTEGER decoding strips a single leading `0x00` when followed by a byte with its high bit set (the canonical positive-INTEGER prefix per X.690 paragraph 8.3.2).
|
||||
- RSA SPKI parsing is delegated to `@suaveplan/crypto-rsa`'s `parseRsaPublicKey({format: "spki"})` — re-implementing RSA-SPKI parse inline would duplicate code already at 100% coverage in that package.
|
||||
- Ed448 is recognised by the OID map but the curve primitive is not yet available in `@suaveplan/crypto-curve`; verifying an Ed448-signed certificate throws `SIGNATURE_ALG_UNSUPPORTED` with the OID. This is honest behaviour — silently returning `false` would mask the missing primitive.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `signature/verify` — Signature verify dispatch over crypto-rsa and crypto-curve
|
||||
|
||||
## Purpose
|
||||
|
||||
The `signature/verify` module is part of the certificate signature-verify pipeline. Together with the rest of the `signature/` directory it converts an X.509 `signatureAlgorithm` OID into a concrete verifier dispatch and runs that verifier against the TBSCertificate bytes the certificate parser captured. RSA-SHA{256,384,512} and RSA-PSS verifies are delegated to `@suaveplan/crypto-rsa` (the just-shipped sibling package); ECDSA-P{256,384,521} and Ed25519 verifies are delegated to `@suaveplan/crypto-curve`'s `p256`, `p384`, `p521`, and `ed25519` namespaces.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure OID-keyed table — no string parsing, no fallbacks, no implicit defaults.
|
||||
- Strict OID lookup; unknown OIDs throw `X509Error(SIGNATURE_ALG_UNSUPPORTED)` with the offending OID in `error.context`.
|
||||
- Async verify boundary so future engines (e.g. WebCrypto via `crypto-browser`) can plug in without API changes.
|
||||
- Failed verify is converted to `X509Error(SIGNATURE_INVALID)` so consumers branch on the typed code rather than a boolean return.
|
||||
- ECDSA signature is decoded once at the verify boundary; the compact `r || s` form is then handed to the curve primitive.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { resolveSignatureAlgorithm, verifyCertificateSignature } from "@suaveplan/x509/signature";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const child = parseCertificate(childBytes);
|
||||
const parent = parseCertificate(parentBytes);
|
||||
const ok = await verifyCertificateSignature(
|
||||
child.tbsCertificateBytes,
|
||||
child.signatureAlgorithm.oid,
|
||||
child.signatureValue,
|
||||
parent.tbs.subjectPublicKeyInfo
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveSignatureAlgorithm,
|
||||
decodeEcdsaSignature,
|
||||
} from "@suaveplan/x509/signature";
|
||||
|
||||
const alg = resolveSignatureAlgorithm("1.2.840.10045.4.3.2");
|
||||
if (alg.family === "ecdsa") {
|
||||
const compact = decodeEcdsaSignature(rawSignatureValue, 32);
|
||||
// pass compact into the curve-specific verifier of your choice
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `resolveSignatureAlgorithm(oid: string): SignatureAlgorithm` — typed lookup.
|
||||
- `verifyCertificateSignature(tbsBytes, sigAlgOid, signature, parentSpki): Promise<boolean>`.
|
||||
- `decodeEcdsaSignature(bytes, fieldLen): Uint8Array` — DER SEQUENCE-of-INTEGERs to compact `r || s`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The verify dispatcher catches non-`X509Error` throws from the underlying crypto engines and wraps them as `SIGNATURE_INVALID` with the original error as `cause` — this prevents bigint range errors or noble's internal exceptions from leaking to consumers.
|
||||
- ECDSA INTEGER decoding strips a single leading `0x00` when followed by a byte with its high bit set (the canonical positive-INTEGER prefix per X.690 paragraph 8.3.2).
|
||||
- RSA SPKI parsing is delegated to `@suaveplan/crypto-rsa`'s `parseRsaPublicKey({format: "spki"})` — re-implementing RSA-SPKI parse inline would duplicate code already at 100% coverage in that package.
|
||||
- Ed448 is recognised by the OID map but the curve primitive is not yet available in `@suaveplan/crypto-curve`; verifying an Ed448-signed certificate throws `SIGNATURE_ALG_UNSUPPORTED` with the OID. This is honest behaviour — silently returning `false` would mask the missing primitive.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `validate/trust-anchor` — SKI-indexed trust-anchor store
|
||||
|
||||
## Purpose
|
||||
|
||||
`validate/trust-anchor` is the core of the certificate path-validation pipeline. `validateChain` walks the certificate chain leaf-first up to the trust anchor, re-hashing each TBSCertificate, dispatching the signature verify through `@suaveplan/crypto-rsa` (for RSA-PKCS1v1.5 and RSA-PSS) or `@suaveplan/crypto-curve` (for ECDSA-P{256,384,521} and Ed25519), and checking validity, basic constraints, key usage, EKU, name chaining, name constraints, OCSP status (when an `OcspResponderLike` is injected), and critical-extension recognition per RFC 5280 paragraph 6.1.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 12-step algorithm per RFC 5280 paragraph 6.1 with the spec-pinned set of failure codes.
|
||||
- Injectable `Clock` so tests can probe expiry boundaries without time travel.
|
||||
- Injectable `AbortSignal` so callers can interrupt slow chains (e.g. when an OCSP responder is unreachable).
|
||||
- Bounded chain depth (default 16) with caller override.
|
||||
- `TrustAnchorStore` value object with O(1) SKI lookup and PEM-bundle constructor.
|
||||
- Configurable duplicate-SKI policy (`last-wins` default, `first-wins`, or `throw`).
|
||||
- OpenTelemetry surface emitting `x509.validate_duration_ms`, `x509.validate_total`, `x509.chain_length`, and `x509.error_total` on every call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateChain, TrustAnchorStore } from "@suaveplan/x509/validate";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const result = await validateChain({
|
||||
leaf: parseCertificate(leafBytes),
|
||||
intermediates: [parseCertificate(intermediateBytes)],
|
||||
trustAnchors: [parseCertificate(rootBytes)],
|
||||
});
|
||||
console.log(result.trustedPath.length); // 3
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validateChain, TrustAnchorStore } from "@suaveplan/x509/validate";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const store = new TrustAnchorStore(pemBundleString, { onDuplicate: "throw" });
|
||||
const ac = new AbortController();
|
||||
const result = await validateChain({
|
||||
leaf: parseCertificate(leafBytes),
|
||||
intermediates: intermediates.map(parseCertificate),
|
||||
trustAnchors: store.toArray(),
|
||||
clock: () => Date.UTC(2025, 0, 1),
|
||||
signal: ac.signal,
|
||||
maxChainDepth: 8,
|
||||
ekuRequired: "serverAuth",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validateChain(opts: ValidateChainOptions): Promise<ValidateChainResult>` — async; throws `X509Error` on every failure path.
|
||||
- `TrustAnchorStore` — `findBySki`, `findIssuer`, `contains`, `toArray`, `size`.
|
||||
- `DEFAULT_MAX_CHAIN_DEPTH` — `16`.
|
||||
- `OcspResponderLike` — `check(cert, issuer, signal?) => Promise<OcspStatus>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chain is ordered leaf-first by walking from the leaf upward: at each step the next certificate is chosen by canonical-name equality between the current cert's issuer and a candidate's subject. The algorithm terminates when a trust anchor is reached.
|
||||
- The OCSP boundary is interface-only — no built-in responder ships. Implementation lives in `@suaveplan/x509-ocsp` (follow-up).
|
||||
- Policy mapping / policy constraints are out of scope; the validator does not enforce them and rejects them as unknown critical extensions per RFC 5280 paragraph 6.1.4(f).
|
||||
- Self-signed leaf rejection happens before the chain walk; the leaf is accepted only when it is byte-identical to a trust anchor.
|
||||
- Pre-aborted `AbortSignal` short-circuits before any work; aborts between per-cert iterations are honoured.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `validate/validate-chain` — RFC 5280 paragraph 6.1 path-validation algorithm
|
||||
|
||||
## Purpose
|
||||
|
||||
`validate/validate-chain` is the core of the certificate path-validation pipeline. `validateChain` walks the certificate chain leaf-first up to the trust anchor, re-hashing each TBSCertificate, dispatching the signature verify through `@suaveplan/crypto-rsa` (for RSA-PKCS1v1.5 and RSA-PSS) or `@suaveplan/crypto-curve` (for ECDSA-P{256,384,521} and Ed25519), and checking validity, basic constraints, key usage, EKU, name chaining, name constraints, OCSP status (when an `OcspResponderLike` is injected), and critical-extension recognition per RFC 5280 paragraph 6.1.
|
||||
|
||||
## Features
|
||||
|
||||
- Full 12-step algorithm per RFC 5280 paragraph 6.1 with the spec-pinned set of failure codes.
|
||||
- Injectable `Clock` so tests can probe expiry boundaries without time travel.
|
||||
- Injectable `AbortSignal` so callers can interrupt slow chains (e.g. when an OCSP responder is unreachable).
|
||||
- Bounded chain depth (default 16) with caller override.
|
||||
- `TrustAnchorStore` value object with O(1) SKI lookup and PEM-bundle constructor.
|
||||
- Configurable duplicate-SKI policy (`last-wins` default, `first-wins`, or `throw`).
|
||||
- OpenTelemetry surface emitting `x509.validate_duration_ms`, `x509.validate_total`, `x509.chain_length`, and `x509.error_total` on every call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateChain, TrustAnchorStore } from "@suaveplan/x509/validate";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const result = await validateChain({
|
||||
leaf: parseCertificate(leafBytes),
|
||||
intermediates: [parseCertificate(intermediateBytes)],
|
||||
trustAnchors: [parseCertificate(rootBytes)],
|
||||
});
|
||||
console.log(result.trustedPath.length); // 3
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validateChain, TrustAnchorStore } from "@suaveplan/x509/validate";
|
||||
import { parseCertificate } from "@suaveplan/x509/certificate";
|
||||
|
||||
const store = new TrustAnchorStore(pemBundleString, { onDuplicate: "throw" });
|
||||
const ac = new AbortController();
|
||||
const result = await validateChain({
|
||||
leaf: parseCertificate(leafBytes),
|
||||
intermediates: intermediates.map(parseCertificate),
|
||||
trustAnchors: store.toArray(),
|
||||
clock: () => Date.UTC(2025, 0, 1),
|
||||
signal: ac.signal,
|
||||
maxChainDepth: 8,
|
||||
ekuRequired: "serverAuth",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validateChain(opts: ValidateChainOptions): Promise<ValidateChainResult>` — async; throws `X509Error` on every failure path.
|
||||
- `TrustAnchorStore` — `findBySki`, `findIssuer`, `contains`, `toArray`, `size`.
|
||||
- `DEFAULT_MAX_CHAIN_DEPTH` — `16`.
|
||||
- `OcspResponderLike` — `check(cert, issuer, signal?) => Promise<OcspStatus>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chain is ordered leaf-first by walking from the leaf upward: at each step the next certificate is chosen by canonical-name equality between the current cert's issuer and a candidate's subject. The algorithm terminates when a trust anchor is reached.
|
||||
- The OCSP boundary is interface-only — no built-in responder ships. Implementation lives in `@suaveplan/x509-ocsp` (follow-up).
|
||||
- Policy mapping / policy constraints are out of scope; the validator does not enforce them and rejects them as unknown critical extensions per RFC 5280 paragraph 6.1.4(f).
|
||||
- Self-signed leaf rejection happens before the chain walk; the leaf is accepted only when it is byte-identical to a trust anchor.
|
||||
- Pre-aborted `AbortSignal` short-circuits before any work; aborts between per-cert iterations are honoured.
|
||||
Reference in New Issue
Block a user