# pkinative — executable recipes Each recipe below runs on every test run (tests/docs/recipes.test.ts) and its expectations in recipes/index.json are asserted, so these samples cannot rot. They read the public certificates of tests/fixtures/ through recipes/_fixtures.ts. --- ```ts /** * The public certificates the recipes read — the committed fixtures of * tests/fixtures/ (PROVENANCE.md), so every recipe runs on real * certificates without the network. */ import { readFileSync } from 'node:fs'; /** The DER bytes of a fixture, e.g. `fixture('letsencrypt-org-leaf')`. */ export function fixture(name: string): Uint8Array { return new Uint8Array(readFileSync(new URL(`../tests/fixtures/certs/${name}.der`, import.meta.url))); } export const FIXTURE_NAMES = ['isrg-root-x1', 'isrg-root-x2', 'lets-encrypt-e7', 'lets-encrypt-r12', 'letsencrypt-org-leaf', 'rfc8410-x25519'] as const; ``` --- ```ts /** * Recipe: the README quick start — read PEM certificates and print what a * person checks first: who, issued by whom, valid when, for which names, and * the fingerprint. The block between the quick-start markers is the README * code block, character for character (tests/docs/recipes.test.ts). */ import { encodePem } from 'pkinative'; import { fixture } from './_fixtures.ts'; // quick-start:begin import { computeFingerprint, decodePem, formatDistinguishedName, formatFingerprint, getExtension, parseCertificate } from 'pkinative'; export function describeCertificates(pemText: string): string[] { const lines: string[] = []; for (const { bytes } of decodePem(pemText, { label: 'CERTIFICATE' })) { const cert = parseCertificate(bytes); const names = getExtension(cert, 'subjectAltName')?.names ?? []; const iso = (ms: number): string => new Date(ms).toISOString(); lines.push( `subject: ${formatDistinguishedName(cert.subject)}`, `issuer: ${formatDistinguishedName(cert.issuer)}`, `valid: ${iso(cert.validity.notBefore.epochMilliseconds)} → ${iso(cert.validity.notAfter.epochMilliseconds)}`, `names: ${names.map((n) => (n.kind === 'dNSName' ? n.value : n.kind)).join(', ')}`, `sha-256: ${formatFingerprint(computeFingerprint(bytes, 'SHA-256'))}`, ); } return lines; } // quick-start:end export default function run(): Record { const lines = describeCertificates(encodePem('CERTIFICATE', fixture('letsencrypt-org-leaf'))); return { subject: lines[0] ?? '', issuer: lines[1] ?? '', valid: lines[2] ?? '', firstName: (lines[3] ?? '').split(', ')[0] ?? '', fingerprint: lines[4] ?? '', }; } ``` --- ```ts /** * Recipe: the docs/agent-brief.md sample — compose `decodePem` and * `parseCertificate` (there is no `parsePemCertificates`) and read the host * names out of subjectAltName. The block between the agent-brief markers is * the brief's code block, character for character (tests/docs/recipes.test.ts), * so an agent that copies it gets code that runs. */ import { encodePem } from 'pkinative'; import { fixture } from './_fixtures.ts'; // agent-brief:begin import { decodePem, getExtension, parseCertificate } from 'pkinative'; export function hostNames(pemText: string): string[] { const names: string[] = []; for (const { bytes } of decodePem(pemText, { label: 'CERTIFICATE' })) { const cert = parseCertificate(bytes); for (const name of getExtension(cert, 'subjectAltName')?.names ?? []) { if (name.kind === 'dNSName') names.push(name.value); } } return names; } // agent-brief:end export default function run(): Record { const pemText = encodePem('CERTIFICATE', fixture('letsencrypt-org-leaf')); return { names: hostNames(pemText).join(',') }; } ``` --- ```ts /** * Recipe: walk every extension of a certificate — the decoded ones by kind, * the critical flags, the profile diagnostics — then keep one extension raw * and decode it on demand, the way an inspection tool reads certificates it * must not reject. */ import { decodeExtensionValue, getExtension, getOidName, parseCertificate, type PkiDiagnostic } from 'pkinative'; import { fixture } from './_fixtures.ts'; export default function run(): Record { const der = fixture('letsencrypt-org-leaf'); const cert = parseCertificate(der); const kinds = cert.extensions.map((e) => `${e.kind}${e.critical ? '!' : ''}`); const policies = getExtension(cert, 'certificatePolicies')?.policies.map((p) => getOidName(p.policyIdentifier) ?? p.policyIdentifier) ?? []; const crl = getExtension(cert, 'crlDistributionPoints')?.points[0]?.fullName?.[0]; // Tools that must show a certificate even when an extension is malformed // parse with decodeExtensions: false, then decode what they need. const raw = parseCertificate(der, { decodeExtensions: false }); const keyUsage = raw.extensions.find((e) => e.oid === '2.5.29.15'); const decoded = keyUsage === undefined ? undefined : decodeExtensionValue(keyUsage.oid, keyUsage.valueDer, { critical: keyUsage.critical }); // The RFC 8410 example encodes DEFAULT values explicitly: read, and reported. const seen: PkiDiagnostic[] = []; parseCertificate(fixture('rfc8410-x25519'), { onDiagnostic: (d) => { seen.push(d); } }); return { extensions: kinds.join(','), policy: policies.join(','), crl: crl?.kind === 'uniformResourceIdentifier' ? crl.value : '', rawKinds: [...new Set(raw.extensions.map((e) => e.kind))].join(','), onDemand: decoded?.kind === 'keyUsage' ? decoded.usages.join(',') : '', diagnostics: seen.map((d) => `${d.code}@${d.path}`).join(' '), }; } ``` --- ```ts /** * Recipe: read a CA bundle — many PEM certificates in one text — and link * each certificate to its issuer by key identifier (a root is its own). The `label` option makes * a private key slipped into the bundle an error instead of a certificate. * pkinative exports no PEM-to-certificate shortcut: decodePem and * parseCertificate compose, as encoding/pem and crypto/x509 do in Go. */ import { decodePem, encodePem, formatDistinguishedName, getExtension, parseCertificate, PkiError } from 'pkinative'; import { FIXTURE_NAMES, fixture } from './_fixtures.ts'; const hex = (bytes: Uint8Array | undefined): string => [...(bytes ?? [])].map((b) => b.toString(16).padStart(2, '0')).join(''); export default function run(): Record { const bundle = FIXTURE_NAMES.map((name) => encodePem('CERTIFICATE', fixture(name))).join('\n'); const certificates = decodePem(bundle, { label: 'CERTIFICATE' }).map((block) => parseCertificate(block.bytes, { onDiagnostic: () => undefined })); const bySubjectKey = new Map(certificates.map((c) => [hex(getExtension(c, 'subjectKeyIdentifier')?.keyIdentifier), c])); const links = certificates.map((cert) => { // A root carries no authorityKeyIdentifier: its issuer name is its own subject, byte for byte. const selfIssued = hex(cert.issuer.der) === hex(cert.subject.der); const issuer = selfIssued ? cert : bySubjectKey.get(hex(getExtension(cert, 'authorityKeyIdentifier')?.keyIdentifier)); return `${formatDistinguishedName(cert.subject).split(',')[0]} <- ${issuer === undefined ? '?' : formatDistinguishedName(issuer.subject).split(',')[0]}`; }); let refused = ''; try { decodePem(`${bundle}\n-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n`, { label: 'CERTIFICATE' }); } catch (error) { refused = error instanceof PkiError ? error.code : 'unexpected'; } return { count: String(certificates.length), links: links.join('; '), privateKey: refused, }; } ``` --- ```ts /** * Recipe: certificate fingerprints — synchronous everywhere, or through Web * Crypto when the host has it — with the same bytes either way, formatted * the way certificate tools and pinning configurations write them; and * SHAKE256 at the output length the caller names, the digest of an Ed448 * CMS signer (RFC 8419 §3.1), which Web Crypto does not compute. */ import { computeFingerprint, computeFingerprintAsync, formatFingerprint, shake256 } from 'pkinative'; import { fixture } from './_fixtures.ts'; export default async function run(): Promise> { const der = fixture('isrg-root-x1'); const sync = computeFingerprint(der, 'SHA-256'); const viaWebCrypto = await computeFingerprintAsync(der, 'SHA-256'); // 64 octets: what the messageDigest attribute of an Ed448 signer holds for // this content — and the `contentDigest` to pass verifySignedData for a // detached one. const shake = shake256(der, 64); return { sha256: formatFingerprint(sync), pin: formatFingerprint(sync, { separator: '', letterCase: 'lower' }), sameBytes: String(formatFingerprint(viaWebCrypto) === formatFingerprint(sync)), sha1: formatFingerprint(computeFingerprint(der, 'SHA-1')), shake256: formatFingerprint(shake, { separator: '', letterCase: 'lower' }), shake256Prefix: String(formatFingerprint(shake256(der, 32), { separator: '' }) === formatFingerprint(shake.subarray(0, 32), { separator: '' })), }; } ``` --- ```ts /** * Recipe: below the certificate — build a DER value with the encoders, * decode it and read typed fields back, re-encode a real certificate byte * for byte from its decoded tree, and work with OIDs: encode, decode, * validate, name. ENUMERATED and RELATIVE-OID (X.690 §8.4, §8.20) have * their own readers: an INTEGER reader refuses tag 10, and a RELATIVE-OID * has no first-two-arcs packing. */ import { decodeAsn1, decodeOid, encodeAsn1Node, encodeEnumerated, encodeInteger, encodeObjectIdentifier, encodeOid, encodeRelativeOid, encodeSequence, encodeString, encodeTime, getOidName, isValidOid, OID_REGISTRY, readEnumerated, readInteger, readObjectIdentifier, readRelativeOid, readString, readTime, } from 'pkinative'; import { fixture } from './_fixtures.ts'; export default function run(): Record { // SEQUENCE { OBJECT IDENTIFIER, INTEGER, UTF8String, Time, ENUMERATED, RELATIVE-OID }, DER-encoded. // encodeTime picks UTCTime before 2050 and GeneralizedTime after, as RFC 5280 requires. // The ENUMERATED is CRLReason superseded(4); the RELATIVE-OID is X.690 §8.20.2's own example. const der = encodeSequence([ encodeObjectIdentifier('2.5.29.17'), encodeInteger(65537n), encodeString('utf8', 'Zoë'), encodeTime(Date.UTC(2049, 11, 31, 23, 59, 59)), encodeEnumerated(4), encodeRelativeOid('8571.3.2'), ]); const [oidNode, integerNode, textNode, timeNode, enumeratedNode, relativeOidNode] = decodeAsn1(der).children; const oid = readObjectIdentifier(oidNode); const time = readTime(timeNode); // A decoded DER tree re-encodes to the exact input — what a signature covers. const certificate = fixture('isrg-root-x1'); const again = encodeAsn1Node(decodeAsn1(certificate)); const identical = again.length === certificate.length && again.every((b, i) => b === certificate[i]); const sha256WithRsa = '1.2.840.113549.1.1.11'; // getOidName gives the name; OID_REGISTRY is the whole table behind it, // and each entry also names the standard that defines the OID. const entry = OID_REGISTRY.find((e) => e.oid === sha256WithRsa); return { oidStandard: entry?.standard ?? '?', oid: `${oid} ${getOidName(oid) ?? '?'}`, integer: String(readInteger(integerNode)), text: readString(textNode).value, time: `${time.type} ${time.text}`, enumerated: String(readEnumerated(enumeratedNode)), relativeOid: readRelativeOid(relativeOidNode), reencoded: String(identical), oidRoundTrip: String(decodeOid(encodeOid(sha256WithRsa)) === sha256WithRsa), oidName: getOidName(sha256WithRsa) ?? '?', validOid: `${String(isValidOid('1.2.3'))} ${String(isValidOid('3.1'))}`, }; } ``` --- ```ts /** * Recipe: the primitive encoders and readers, and the two SET forms. * * `asn1-and-oids.ts` builds a SEQUENCE; this one covers what is left — the * BOOLEAN, NULL, BIT STRING and OCTET STRING pairs, `encodeTlv` for a tag * no named encoder covers, the SET/SET OF distinction DER cares about, the * two taggings an ASN.1 module writes as `[0]`, and `decodeAsn1Sequence` * for values placed back to back with no container. */ import { decodeAsn1, decodeAsn1Sequence, encodeBitString, encodeBoolean, encodeEnumerated, encodeExplicit, encodeImplicit, encodeInteger, encodeNamedBits, encodeNull, encodeOctetString, encodeSet, encodeSetOf, encodeTlv, readBitString, readBoolean, readNull, readOctetString, readSmallInteger, } from 'pkinative'; const hex = (bytes: Uint8Array): string => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); export default function run(): Record { // Four primitives, back to back with no container: decodeAsn1Sequence // reads a stream of values, where decodeAsn1 insists on exactly one. const stream = new Uint8Array([ ...encodeBoolean(true), ...encodeNull(), ...encodeInteger(65537n), ...encodeOctetString(Uint8Array.of(0xde, 0xad, 0xbe, 0xef)), ]); const [booleanNode, nullNode, integerNode, octetNode] = decodeAsn1Sequence(stream); // DER encodes TRUE as 0xFF and rejects every other non-zero octet, so a // BOOLEAN round-trips through exactly one encoding. const flag = readBoolean(booleanNode); const nothing = readNull(nullNode); // readSmallInteger refuses what a JavaScript number cannot hold exactly; // readInteger returns a bigint and always succeeds. const small = readSmallInteger(integerNode); const octets = readOctetString(octetNode); // A BIT STRING carries its own count of padding bits: 0b1011 is four bits // in one octet, so four of that octet's eight bits are unused. const bits = readBitString(decodeAsn1(encodeBitString(Uint8Array.of(0b1011_0000), 4))); // SET OF is sorted by the encoded bytes (X.690 §11.6); SET keeps the order // the schema fixes. Feeding the same three components to both shows it. const components = [encodeInteger(3n), encodeInteger(1n), encodeInteger(2n)]; const setOf = hex(encodeSetOf(components)); const set = hex(encodeSet(components)); // encodeTlv reaches a tag no named encoder covers: [0] EXPLICIT, the // context-specific constructed tag that wraps an optional field. const explicit = encodeTlv('context', 0, true, encodeBoolean(false)); // The same thing, said once: encodeExplicit wraps a complete TLV in a // constructed context tag, so [0] BOOLEAN FALSE is two headers deep. // encodeImplicit instead REPLACES the value's own tag, keeping its // constructed bit — which is why the two produce different bytes for the // same inner value, and why an ASN.1 module's `IMPLICIT` is never a // wrapper. Get these backwards and the structure decodes as a shape // nobody declared. const asExplicit = hex(encodeExplicit(0, encodeBoolean(false))); const asImplicit = hex(encodeImplicit(0, encodeBoolean(false))); // ENUMERATED is INTEGER's twin with tag 10: a CRL's reasonCode (RFC 5280 // §5.3.1) is one, and writing it as an INTEGER is a structure a CRL // reader will refuse. 1 is keyCompromise. const reason = hex(encodeEnumerated(1)); // A BIT STRING of NAMED bits is not a BIT STRING of bytes: X.690 §11.2.2 // makes DER drop every trailing zero bit, so the encoder takes the bit // numbers and the length falls out. Bits 0 and 5 — digitalSignature and // keyCertSign in a KeyUsage — need six bits, so two of one octet's eight // are used and the padding count is 2. const named = hex(encodeNamedBits([0, 5])); return { stream: `${String(flag)} ${String(nothing)} ${String(small)} ${hex(octets)}`, bitString: `${hex(bits.bytes)}/${String(bits.unusedBits)}`, setOf, set, sorted: String(setOf !== set), explicit: hex(explicit), tagging: `${asExplicit} ${asImplicit} ${String(asExplicit !== asImplicit)}`, enumerated: reason, namedBits: named, }; } ``` --- ```ts /** * Recipe: what hostile input meets — a typed error with a stable code, never * a crash, and resource limits a caller can tighten for its own context. * Branch on `error.code`, never on the message. */ import { decodeAsn1, DEFAULT_PKI_LIMITS, parseCertificate, PkiCertificateError, PkiEncodingError, PkiError, PkiLimitError, readInteger } from 'pkinative'; import { fixture } from './_fixtures.ts'; function codeOf(action: () => unknown): string { try { action(); return 'accepted'; } catch (error) { if (error instanceof PkiLimitError) return `${error.code}:${error.limit}`; if (error instanceof PkiError) return error.code; throw error; } } export default function run(): Record { // 10 000 nested SEQUENCEs: the decoder is iterative, so this is a limit, not a stack overflow. const depth = 10_000; const nested = new Uint8Array(depth * 2 + 2); for (let i = 0; i < depth; i++) nested.set([0x30, 0x80], i * 2); const deep = codeOf(() => decodeAsn1(nested, { encodingRules: 'ber', onDiagnostic: () => undefined })); // A length that runs past the end of the input. const truncated = codeOf(() => decodeAsn1(Uint8Array.of(0x30, 0x84, 0x7f, 0xff, 0xff, 0xff))); // A real certificate, cut in half. const leaf = fixture('letsencrypt-org-leaf'); const halved = codeOf(() => parseCertificate(leaf.subarray(0, leaf.length >> 1))); // A caller that expects small certificates can say so. Every limit it does // not name keeps its DEFAULT_PKI_LIMITS value, so tightening one is safe. const tightened = codeOf(() => parseCertificate(leaf, { limits: { maxExtensions: 4 } })); const defaultExtensions = String(DEFAULT_PKI_LIMITS.maxExtensions); // A structural violation of RFC 5280 is a PkiCertificateError, distinct // from the PkiEncodingError a malformed DER value raises: the bytes below // are a valid ASN.1 value, and not a certificate. let structural = ''; try { parseCertificate(Uint8Array.of(0x30, 0x03, 0x02, 0x01, 0x00)); } catch (error) { if (error instanceof PkiCertificateError) structural = error.code; } let offset = ''; try { readInteger(decodeAsn1(Uint8Array.of(0x02, 0x02, 0x00, 0x01))); } catch (error) { if (error instanceof PkiEncodingError) offset = `${error.code}@${String(error.offset)}`; } return { deep, truncated, halved, tightened, defaultExtensions, structural, nonMinimalInteger: offset }; } ``` --- ```ts /** * Recipe: ask whether an issuer's key actually signed a certificate, and * read the three answers apart. * * `true` and `false` are both answers. A throw is not: it means the * question could not be put — no Web Crypto here, an algorithm pkinative * does not map, a key the host will not import. Branch on `PkiCryptoError` * to tell "the signature does not stand up" from "I could not check". * * A `true` is one link, not a chain. It says nothing about expiry, trust, * revocation, or whether the issuer was entitled to sign — that is * RFC 5280 §6, and it arrives in 0.5. */ import { canVerify, formatDistinguishedName, parseCertificate, PkiCryptoError, verifyCertificateSignature, verifySelfSignature } from 'pkinative'; import type { VerifyCertificateSignatureOptions } from 'pkinative'; import { fixture } from './_fixtures.ts'; const cn = (cert: { subject: Parameters[0] }): string => formatDistinguishedName(cert.subject).split(',')[0] ?? ''; /** * The three answers, as one value. `signed` is the verdict when there is * one; `code` names the reason there is none. Keeping them apart in the * type is what stops "could not check" from being read as "not signed". */ async function verify(ask: () => Promise): Promise<{ signed: boolean; code: string | undefined }> { try { return { signed: await ask(), code: undefined }; } catch (error) { if (error instanceof PkiCryptoError) return { signed: false, code: error.code }; throw error; } } export default async function run(): Promise> { const quiet = { onDiagnostic: () => undefined }; const rootRsa = parseCertificate(fixture('isrg-root-x1'), quiet); const rootEc = parseCertificate(fixture('isrg-root-x2'), quiet); const intermediateRsa = parseCertificate(fixture('lets-encrypt-r12'), quiet); const intermediateEc = parseCertificate(fixture('lets-encrypt-e7'), quiet); // Nothing below works without a Web Crypto host. Ask first rather than // catching an exception out of a report you are half way through. if (!canVerify()) return { available: 'no' }; // A root is a certificate that signed itself. That is a fact about the // bytes, not a reason to trust it: every attacker's root has it too. const selfRsa = await verifySelfSignature(rootRsa); const selfEc = await verifySelfSignature(rootEc); // One link of a chain: did this issuer's key sign this certificate? const rsaLink = await verifyCertificateSignature(intermediateRsa, rootRsa); const ecLink = await verifyCertificateSignature(intermediateEc, rootEc); // The wrong root is a plain `false`, not an error — which is what lets // a path builder walk candidate issuers without a try/catch per step. const wrongRoot = await verifyCertificateSignature(intermediateRsa, rootEc); // The RFC 8410 §10.2 certificate is self-issued by name and its subject // key is X25519 — key agreement, not signature. So it is decided, and // decided "no": a plain `false`, with nothing thrown. const x25519 = await verify(() => verifySelfSignature(parseCertificate(fixture('rfc8410-x25519'), quiet))); // The same helper around every call is the shape worth copying. With // these six public certificates nothing throws, which is the point of // reporting it: `couldNotCheck` stays empty on input a modern runtime // can handle, and names the code on input it cannot. const couldNotCheck = x25519.code ?? 'none'; // RFC 5280 §4.1.1.2 requires the two signatureAlgorithm fields to be // equal, and only the inner one is signed. A tool that wants to see a // mismatch rather than be protected from it can say so. const lenient: VerifyCertificateSignatureOptions = { requireAlgorithmMatch: false }; const stillTrue = await verifyCertificateSignature(intermediateRsa, rootRsa, lenient); // A SHA-1 signature is refused rather than answered, and `allowSha1` is the // only way past it. Neither boolean would be true: the arithmetic may check // out, and a chosen-prefix collision has been practical since 2017, so the // signature does not bind the bytes it covers. `PkiCryptoError` is exactly // the family for "this question cannot be put", and the path validator turns // it into PKI_REASON_SIGNATURE_NOT_CHECKED rather than a silent pass. // // Turn it on to examine a historical artefact, never to authenticate with // one. None of these six fixtures uses SHA-1, so the honest thing this // recipe can show is the option's shape and its default. const archival: VerifyCertificateSignatureOptions = { allowSha1: true }; const sha1IsOptIn = archival.allowSha1 === true && new Set([undefined, false]).has(lenient.allowSha1); return { sha1IsOptIn: String(sha1IsOptIn), available: 'yes', selfSigned: `${cn(rootRsa)}=${String(selfRsa)} ${cn(rootEc)}=${String(selfEc)}`, links: `${cn(intermediateRsa)}<-${cn(rootRsa)}=${String(rsaLink)} ${cn(intermediateEc)}<-${cn(rootEc)}=${String(ecLink)}`, wrongRoot: String(wrongRoot), x25519SelfSignature: String(x25519.signed), couldNotCheck, lenient: String(stillTrue), }; } ``` --- ```ts /** * Recipe: issue a two-certificate hierarchy — a self-signed root and a leaf * it signs — and a certification request, with keys the caller holds. * * pkinative generates no key and exports none: `crypto.subtle.generateKey` * and `crypto.subtle.exportKey` are the caller's calls, here in this file, * and what reaches the library is a SubjectPublicKeyInfo in DER plus a * private `CryptoKey` it only ever hands back to `crypto.subtle.sign`. That * is why `generateKey` and `exportKey` are refused inside `src/` in every * version — the promise costs you two lines. * * Every certificate built here is read back by pkinative's own parser with * **no diagnostic**: a builder whose output its own reader complains about * has written something someone else's reader will refuse. */ import { computeKeyIdentifier, decodeAsn1, readBitString, canSign, createCertificate, createCertificationRequest, encodeAlgorithmIdentifier, encodeAttribute, encodeAuthorityKeyIdentifier, encodeBasicConstraints, encodeDistinguishedName, encodeExtendedKeyUsage, encodeExtension, encodeExtensions, encodeKeyUsage, encodeNameAttribute, encodeSubjectAltName, encodeSubjectKeyIdentifier, encodeSubjectPublicKeyInfo, encodeValidity, formatDistinguishedName, getExtension, KEY_USAGE_BITS, parseCertificate, encodeSignatureAlgorithm, verifyCertificateSignature, verifySelfSignature, type GeneralNameDescription, type SigningKey, type Asn1Node, } from 'pkinative'; const DAY = 86_400_000; const NOW = Date.UTC(2026, 0, 1); const CN = '2.5.4.3'; const hex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); /** The caller's key pair, and the SPKI pkinative is given instead of the key. */ async function keyPair(): Promise<{ signer: SigningKey; spki: Uint8Array }> { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); return { signer: { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }, // One line, and the reason exportKey can stay refused inside pkinative. spki: new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)), }; } export default async function run(): Promise> { if (!canSign()) return { available: 'no' }; const ca = await keyPair(); const leaf = await keyPair(); const quiet = { onDiagnostic: () => undefined }; // RFC 5280 §4.2.1.2 method 1: the SHA-1 of the public key's BIT STRING // content — not of the SubjectPublicKeyInfo, which is the mistake that // produces an identifier matching nothing and an OCSP request every // responder answers 'unknown' to. The two lines that get from an exported // SPKI to those bytes are here rather than hidden, for the same reason // createCertificate takes SPKI DER instead of a CryptoKey. const caKeyBits = readBitString(decodeAsn1(ca.spki).children[1] as Asn1Node); const caKeyId = computeKeyIdentifier(caKeyBits.bytes); // The leaf names its own key too: RFC 5280 §4.2.1.2 asks every end-entity // certificate for one (PKI_DIAG_SKI_MISSING_END_ENTITY reports it absent). const leafKeyId = computeKeyIdentifier(readBitString(decodeAsn1(leaf.spki).children[1] as Asn1Node).bytes); // An iPAddress takes BYTES, in network byte order, not text — the same // rule as `issuerDer`, applied to a value that must be exact. '2001:db8::1' // and '2001:0db8:0000:…:0001' are two spellings of one address, and a // certificate that names the wrong host because a `::` expanded // differently is not a bug anyone finds quickly. const names: GeneralNameDescription[] = [ { kind: 'dNSName', value: 'host.example' }, { kind: 'uniformResourceIdentifier', value: 'https://host.example/' }, { kind: 'iPAddress', value: Uint8Array.of(192, 0, 2, 1) }, ]; const rootDer = await createCertificate({ serialNumber: 0x0123456789abcdefn, subject: [ [{ type: '2.5.4.6', value: 'US', stringType: 'printable' }], [{ type: CN, value: 'pkinative example root' }], ], notBefore: NOW, notAfter: NOW + 3650 * DAY, subjectPublicKey: ca.spki, extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: true, pathLenConstraint: 0 }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['keyCertSign', 'cRLSign']) }, { oid: '2.5.29.14', value: encodeSubjectKeyIdentifier(caKeyId) }, ], }, ca.signer); const root = parseCertificate(rootDer, quiet); // The leaf's issuer is the CA's OWN bytes, not a name re-described. A // re-encoded name does not always reproduce the issuer's encoding, and a // chain whose names differ by one octet is a chain nothing will build. const leafDer = await createCertificate({ serialNumber: 2n, issuerDer: root.subject.der, subject: [[{ type: CN, value: 'host.example' }]], notBefore: NOW, notAfter: NOW + 90 * DAY, subjectPublicKey: leaf.spki, extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: false }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature']) }, { oid: '2.5.29.37', value: encodeExtendedKeyUsage(['1.3.6.1.5.5.7.3.1']) }, { oid: '2.5.29.17', value: encodeSubjectAltName(names) }, { oid: '2.5.29.35', value: encodeAuthorityKeyIdentifier(caKeyId) }, { oid: '2.5.29.14', value: encodeSubjectKeyIdentifier(leafKeyId) }, ], }, ca.signer); const cert = parseCertificate(leafDer, quiet); // A PKCS#10 request: proof that the requester holds the private half of // the key it carries, so it is signed by the leaf's key, not the CA's. const csr = await createCertificationRequest({ subject: [[{ type: CN, value: 'host.example' }]], subjectPublicKey: leaf.spki, extensions: [{ oid: '2.5.29.17', value: encodeSubjectAltName([{ kind: 'dNSName', value: 'host.example' }]) }], }, leaf.signer); // The structural encoders are public, for a structure this library does // not model yet. These are deterministic, so they can be asserted. const pieces = [ hex(encodeAlgorithmIdentifier('1.2.840.113549.1.1.11')), hex(encodeNameAttribute({ type: CN, value: 'a' })), hex(encodeDistinguishedName([[{ type: CN, value: 'a' }]])), hex(encodeValidity(NOW, NOW).subarray(0, 2)), hex(encodeExtension({ oid: '2.5.29.19', value: encodeBasicConstraints({ cA: false }) })), hex(encodeExtensions([{ oid: '2.5.29.19', value: encodeBasicConstraints({ cA: false }) }])), hex(encodeAttribute('1.2.3', [encodeBasicConstraints({ cA: false })])), hex(encodeSubjectPublicKeyInfo('1.3.101.112', new Uint8Array(4)).subarray(0, 2)), hex(encodeSignatureAlgorithm(ca.signer)), ].join(' '); return { available: 'yes', root: `${formatDistinguishedName(root.subject)} v${String(root.version)} self=${String(await verifySelfSignature(root))} diag=${String(root.diagnostics.length)}`, leaf: `${formatDistinguishedName(cert.subject)} signedByRoot=${String(await verifyCertificateSignature(cert, root))} diag=${String(cert.diagnostics.length)}`, leafExtensions: cert.extensions.map((e) => e.kind).join(','), leafUsage: getExtension(cert, 'keyUsage')?.usages.join(',') ?? '', leafNames: getExtension(cert, 'subjectAltName')?.names.map((n) => n.kind).join(',') ?? '', keyUsageBits: `${String(KEY_USAGE_BITS.get('keyCertSign'))},${String(KEY_USAGE_BITS.get('cRLSign'))}`, csrIsDer: String(csr[0] === 0x30), pieces, }; } ``` --- ```ts /** * Recipe: the one call — chain, host, purpose and revocation, together. * * Every other recipe here asks one question. This one asks all of them, because * that is what a relying party actually needs and because composing them * correctly is not obvious: the signatures have to be verified **before** * anything is decided and in parallel, the purpose has to go **into** the path * search rather than after it, and the host name is a question RFC 5280 §6 * never asks at all. * * If you are reaching for `validateCertificatePath` and wondering what else you * need, the answer is this function. */ import { KEY_PURPOSES, parseCertificate, verifyCertificateChain, type Certificate, type VerifyCertificateChainReport, } from 'pkinative'; import { fixture } from './_fixtures.js'; const quiet = { onDiagnostic: (): undefined => undefined }; const load = (name: string): Certificate => parseCertificate(fixture(name), quiet); const root = load('isrg-root-x1'); const intermediate = load('lets-encrypt-r12'); /** Inside R12's window (2024-03-13 … 2027-03-12) and the root's. */ const at = Date.UTC(2026, 9, 1); /** `ok` when nothing is wrong, the reason codes otherwise. */ const summarise = (report: VerifyCertificateChainReport): string => report.reasons.map((reason) => reason.code).join(',') || `ok, ${String(report.path.length)} certificates`; export default async function run(): Promise> { // R12 really was signed by ISRG Root X1, by a key nobody here holds — so // this is a signature verified rather than asserted. const real = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at }); // The same chain against nothing trusted. A verdict, not an exception: // "I do not trust this" is an answer a caller has to be able to read. const untrusted = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [], at }); // …and after it expires. Note that `at` defaults to now, so a caller who // omits it is asking about today, which is almost always what they mean. const late = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at: Date.UTC(2040, 0, 1) }); // The host name is the question §6 never asks. R12 is a CA and names no // host, so asking about one is a mismatch — and the chain itself is fine, // which is exactly the shape that gets a valid certificate accepted for // somebody else when nobody asks. const wrongHost = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, serverName: { kind: 'dns', value: 'bank.example' }, }); // The purpose is the other question §6 never asks. R12 permits serverAuth // and clientAuth; it was never issued for signing code. const forServers = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, purposes: [KEY_PURPOSES.serverAuth] }); const forCode = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, purposes: [KEY_PURPOSES.codeSigning] }); // Revocation is soft-fail by default: with no list supplied, nothing is // claimed. `requireRevocation: true` turns silence into an answer, which is // the right setting wherever you can actually obtain the lists — and // "unknown" is never "not revoked", because an absence of evidence is not // evidence of absence. const noList = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at }); const listRequired = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, requireRevocation: true }); // Bad bytes are reported, never thrown. PKI_REASON_INPUT_MALFORMED carries // in `errorCode` the PkiErrorCode that *would* have been thrown, which is // how a report promises never to throw for bad input without copying the // whole encoding vocabulary into a second registry. const badCrl = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, crls: [Uint8Array.of(0x30, 0x80, 0x00)], }); const malformed = badCrl.reasons.find((reason) => reason.code === 'PKI_REASON_INPUT_MALFORMED'); // An OCSP response is matched on ALL THREE CertID fields and its signer is // authorised the RFC 6960 §4.2.2.2 way: the issuing CA signed it, or the CA // issued a certificate carrying id-kp-OCSPSigning and that certificate did. // The certificates a response ATTACHES are a convenience for reaching that // delegate, never a claim of authority — a client that trusted the attached // bag would let the responder nominate itself. These bytes are not a // response at all, which is reported rather than thrown. const badOcsp = await verifyCertificateChain({ leaf: intermediate, trustAnchors: [root], at, ocspResponses: [Uint8Array.of(0x30, 0x80, 0x00)], }); return { real: summarise(real), // How many signatures the call had to compute. A number far above the // path length is what a bag full of plausible issuers looks like, which // is what a caller passing a whole trust store will see. signatureVerifications: String(real.signatureVerifications), untrusted: summarise(untrusted), expired: summarise(late), wrongHost: summarise(wrongHost), forServers: summarise(forServers), forCode: summarise(forCode), noList: summarise(noList), listRequired: summarise(listRequired), badCrlReported: `${malformed?.code ?? 'none'} errorCode=${malformed?.errorCode ?? 'none'}`, badOcspReported: badOcsp.reasons.map((reason) => reason.code).join(','), neverThrows: 'true', }; } ``` --- ```ts /** * Recipe: validate a certification path (RFC 5280 §6), and read the report. * * The shape to notice is that `validateCertificatePath` takes **signature * verdicts you computed beforehand**, never a key. Three things follow, and * all three are the point: §6 stays synchronous, it stays pure, and the * signatures can be checked in parallel — which is what a verifier should do * anyway. * * It also **never throws** for a validation issue. Every negative answer is a * `PkiReason` in the report, and several come back together when several * apply: a caller fixing one problem per round trip is a caller the report * failed. */ import { buildCertificatePath, parseCertificate, validateCertificatePath, verifyCertificateSignature, type Certificate, type BuildCertificatePathReport, type ValidateCertificatePathReport, type SignatureResult, } from 'pkinative'; import { fixture } from './_fixtures.js'; const quiet = { onDiagnostic: (): undefined => undefined }; const load = (name: string): Certificate => parseCertificate(fixture(name), quiet); /** Check every link in parallel, then hand the verdicts to §6. */ async function verdicts(chain: readonly Certificate[]): Promise { return Promise.all(chain.slice(0, -1).map(async (subject, index) => { const issuer = chain[index + 1] as Certificate; try { return { certificate: subject, verdict: await verifyCertificateSignature(subject, issuer) ? 'valid' as const : 'invalid' as const }; } catch (error) { // A runtime that cannot check says nothing about the signature. // 'not-checked' keeps that distinction all the way into the report. const { code, message } = error as { code: string; message: string }; return { certificate: subject, verdict: 'not-checked' as const, errorCode: code, detail: message }; } })); } const summarise = (report: ValidateCertificatePathReport | BuildCertificatePathReport): string => report.valid ? `valid, ${String(report.path.length)} certificates` : report.reasons.map((r) => r.code).join(','); export default async function run(): Promise> { const root = load('isrg-root-x1'); const intermediate = load('lets-encrypt-r12'); // Inside R12's window (2024-03-13 … 2027-03-12) and the root's. const at = Date.UTC(2026, 9, 1); // The real hierarchy: R12 is issued by ISRG Root X1. const chain = [intermediate, root]; const trusted = await verdicts(chain); const good = validateCertificatePath({ path: chain, trustAnchors: [root], at, signatures: trusted }); // A chain that stops one short of its root is still anchored when its last // certificate names a trusted subject — most servers send it this way, and // §6.1.1 (a) takes the anchor as a separate input for exactly that reason. // The report's `path` nonetheless ends with the anchor: §6.1.2 initialises // the state FROM it, so its name constraints, its basicConstraints and its // keyUsage all bind what it issued, and a path that omitted it would be // claiming those were never applied. const short = validateCertificatePath({ path: [intermediate], trustAnchors: [root], at, signatures: trusted }); // No anchor at all is an answer, not an exception. const untrusted = validateCertificatePath({ path: chain, trustAnchors: [], at, signatures: trusted }); // Expired, and asked for a decade too late. Note that the report carries // both the time reason and the anchor reason, not just the first. const late = validateCertificatePath({ path: chain, trustAnchors: [root], at: Date.UTC(2040, 0, 1), signatures: trusted }); // Forget to supply a verdict and you get NOT_CHECKED, never a pass. A // validator that read silence as success would pass whenever a caller // forgot to verify anything. const unverified = validateCertificatePath({ path: chain, trustAnchors: [root], at }); // A leaf is not a CA, so it may not issue: this is the check whose absence // was the 2008 Basic Constraints attack. const leaf = load('letsencrypt-org-leaf'); const forged = validateCertificatePath({ path: [root, leaf], trustAnchors: [leaf], at, signatures: [{ certificate: root, verdict: 'valid' }] }); // A caller with an unordered bag rather than a chain uses the builder, // which searches with backtracking. Cross-signing means one subject name can // have several plausible issuers, so taking the first match is not enough — // and `maxPathsExplored` rather than `maxChainLength` is the bound, because // the search is exponential in the candidate set. const bag = [root, intermediate, leaf]; const built = buildCertificatePath({ leaf: intermediate, candidates: bag, trustAnchors: [root], at, signatures: trusted }); const unbuildable = buildCertificatePath({ leaf, candidates: bag, trustAnchors: [root], at, signatures: trusted }); return { built: `${summarise(built)} explored=${String(built.explored)}`, // The committed leaf is issued by CN=YE2, which is not in the bag, so // no path reaches an anchor — and the report says how far it got. unbuildable: unbuildable.reasons.map((r) => r.code).sort().join(','), // `explored` is worth logging: a number near `maxPathsExplored` is a bag // of certificates designed to be expensive, not a hierarchy. Reaching // that bound needs a deeper hierarchy than these three fixtures make, // and tests/path/path-build.test.ts builds one. unbuildableExplored: String(unbuildable.explored), chain: summarise(good), anchorOutsideChain: summarise(short), noAnchor: summarise(untrusted), expired: summarise(late), unverified: summarise(unverified), leafCannotIssue: forged.reasons.some((r) => r.code === 'PKI_REASON_NOT_A_CA') ? 'refused' : 'ACCEPTED', neverThrows: 'true', }; } ``` --- ```ts /** * Recipe: does this certificate name the host you connected to? (RFC 6125) * * A validated path says a CA vouched for a certificate. It says nothing about * which host the certificate is **for**, and the gap between those two is where * a perfectly valid certificate for `attacker.example` gets accepted as one for * `bank.example`. `validateCertificatePath` will never close it — RFC 5280 §6 * has no notion of the name you asked for — so both calls are needed, and a * caller doing only one of them has either a certificate from nobody in * particular or a certificate for somebody else. * * Every line below that refuses is a shape some implementation has accepted. */ import { checkServerName, matchDnsName, parseCertificate, type Certificate, type CheckServerNameOptions, type ServerIdentity } from 'pkinative'; import { fixture } from './_fixtures.js'; const leaf: Certificate = parseCertificate(fixture('letsencrypt-org-leaf'), { onDiagnostic: (): undefined => undefined }); /** `''` when the certificate is for that identity, the reason code otherwise. */ const check = (identity: ServerIdentity, options?: CheckServerNameOptions): string => checkServerName(leaf, identity, options).map((reason) => reason.code).join(',') || 'match'; const host = (value: string): ServerIdentity => ({ kind: 'dns', value }); export default function run(): Record { return { // The fixture names ten hosts; any one of them matches. named: check(host('www.letsencrypt.org')), // Case is not part of a host name, and one trailing dot is the same // absolute name rather than a different one. folded: check(host('WWW.LetsEncrypt.ORG.')), // ── The refusals, which are the reason this function exists ── // A suffix is not a match. `letsencrypt.org.evil.test` is a domain the // attacker owns, and reading the certificate's name as a suffix of the // host you asked for is the oldest bypass there is. suffix: check(host('letsencrypt.org.evil.test')), // …and neither is a prefix: `notletsencrypt.org` is somebody else. prefix: check(host('notletsencrypt.org')), // An embedded NUL truncates the name in a C parser and not in a JS one, // which is the entire mechanism of CVE-2009-2408. nulByte: check(host('www.letsencrypt.org\u0000.evil.test')), // A DNS name is never an address, whatever it looks like: a certificate // naming "192.0.2.1" as a dNSName is not a certificate for that host. addressIsNotAName: check({ kind: 'ip', value: Uint8Array.of(192, 0, 2, 1) }), // ── Wildcards ── // One whole leftmost label, and nothing else. Partial wildcards // (`f*.example.com`), a wildcard that is not leftmost, and a wildcard // matching two labels are all refused; `*.example.com` does not match // `example.com`. `*.com` is refused outright, because the correct test // is a public suffix list and pkinative will not carry one stale. wildcardOneLabel: String(matchDnsName('*.bank.example', 'www.bank.example')), wildcardNotTheParent: String(matchDnsName('*.bank.example', 'bank.example')), wildcardNotTwoLabels: String(matchDnsName('*.bank.example', 'a.b.bank.example')), wildcardNotPartial: String(matchDnsName('w*.bank.example', 'www.bank.example')), wildcardNotRegistry: String(matchDnsName('*.example', 'bank.example')), // An internal PKI that issues no wildcards should accept none, which is // what `allowWildcards: false` is for. It is the one option here whose // default is permissive, because the public web runs on wildcards. wildcardsOff: String(matchDnsName('*.bank.example', 'www.bank.example', { allowWildcards: false })), exactStillMatchesWithWildcardsOff: check(host('www.letsencrypt.org'), { allowWildcards: false }), // ── The commonName fallback rescues nothing here ── // A certificate carrying any dNSName or iPAddress makes its SAN // authoritative, so the commonName is never consulted — even when the // caller sets `allowCommonNameFallback: true`, and even when the CN // would have matched. This fixture's CN is `letsencrypt.org`, which its // SAN also lists, so the recipe cannot exhibit a CN-only certificate; // tests/path/path-server-name.test.ts does, on a synthetic one. // // The option is off by default because CA/Browser Forum BR 7.1.4.2.2 // has forbidden CN-only certificates since 2017 and browsers stopped // reading CN years before that. It exists for an internal PKI nobody // has reissued, and turning it on is accepting a certificate no modern // relying party would. fallbackRescuesNothing: checkServerName(leaf, host('api.letsencrypt.org'), { allowCommonNameFallback: true }) .map((reason) => reason.code).join(',') || 'match', }; } ``` --- ```ts /** * Recipe: may this chain be used for *this*? (RFC 5280 §4.2.1.12) * * A validated path says a CA vouched for a certificate, and `checkServerName` * says the certificate names the host you asked for. Neither says the * certificate is **for** what you are about to do with it — and a certificate * issued for signing e-mail, accepted to authenticate a TLS server, is a * certificate used outside the purpose its subject asked for and its issuer * granted. * * §6 never reads `extKeyUsage`, so this is a separate call, and which purpose * you need is an argument rather than a guess: it is a fact about your * protocol, not about the chain. * * The real Let's Encrypt hierarchy demonstrates the whole rule on its own: the * leaf says `serverAuth`, R12 says `clientAuth` **and** `serverAuth`, and ISRG * Root X1 says nothing at all. */ import { ANY_EXTENDED_KEY_USAGE, checkExtendedKeyUsage, getExtension, KEY_PURPOSES, parseCertificate, type Certificate, type CheckExtendedKeyUsageOptions, } from 'pkinative'; import { fixture } from './_fixtures.js'; const quiet = { onDiagnostic: (): undefined => undefined }; const load = (name: string): Certificate => parseCertificate(fixture(name), quiet); const leaf = load('letsencrypt-org-leaf'); const intermediate = load('lets-encrypt-r12'); const root = load('isrg-root-x1'); /** Leaf first, as `validateCertificatePath` and `buildCertificatePath` return it. */ const path = [leaf, intermediate, root]; const purposes = (certificate: Certificate): string => getExtension(certificate, 'extendedKeyUsage')?.purposes.join('+') ?? '(none)'; /** `'ok'` when every certificate permits the purpose, the refused paths otherwise. */ const check = (purpose: string, options?: CheckExtendedKeyUsageOptions): string => checkExtendedKeyUsage(path, purpose, options).map((reason) => reason.path).join(' ') || 'ok'; export default function run(): Record { return { // What each certificate actually says. stated: `leaf=${purposes(leaf)} ica=${purposes(intermediate)} root=${purposes(root)}`, // The chain exists to authenticate a server, and it does. serverAuth: check(KEY_PURPOSES.serverAuth), // …and it may NOT be used to authenticate a client, even though R12 // permits that: the restriction that bites is the leaf's own. clientAuth: check(KEY_PURPOSES.clientAuth), // A purpose nobody in the chain names is refused at every certificate // that names any purpose at all. The root names none, and an absent // extKeyUsage restricts nothing (§4.2.1.12), so it is not among them. codeSigning: check(KEY_PURPOSES.codeSigning), // ── The rule that is not in RFC 5280 ── // A CA's own extKeyUsage restricts what it may issue for. No sentence // of RFC 5280 says so; every Web PKI validator does it, and the // CA/Browser Forum relies on it to constrain technically-constrained // sub-CAs — without it, a sub-CA restricted to emailProtection issues a // serverAuth certificate and the chain validates, which is the whole // point of restricting it. It is on by default, and `restrictIssuers: // false` is the literal-RFC reading, for an internal PKI that puts a // purpose on a CA as documentation rather than as a constraint. issuersRestricted: check(KEY_PURPOSES.codeSigning), issuersIgnored: check(KEY_PURPOSES.codeSigning, { restrictIssuers: false }), // An absent extKeyUsage means unrestricted, which is why the root does // not appear above. `requireExplicitPurpose` is the stricter Web PKI reading — // a server certificate that does not say serverAuth is not a server // certificate — and it applies to the end entity only: requiring every // CA to enumerate the purposes of everything it may ever issue is not a // reading anyone holds. rootIsSilentNotRestrictive: checkExtendedKeyUsage([root], KEY_PURPOSES.codeSigning).length === 0 ? 'unrestricted' : 'REFUSED', // `ANY_EXTENDED_KEY_USAGE` (2.5.29.37.0) is the one OID that means every // purpose, as §4.2.1.12 defines it. Some relying parties refuse it on an // end-entity certificate; that is a policy pkinative does not take for // them, and taking it is one comparison on `purposes`. anyMeansEvery: ANY_EXTENDED_KEY_USAGE, leafDoesNotClaimAny: String(getExtension(leaf, 'extendedKeyUsage')?.purposes.includes(ANY_EXTENDED_KEY_USAGE) === true), explicitRequired: checkExtendedKeyUsage([root], KEY_PURPOSES.codeSigning, { requireExplicitPurpose: true }) .map((reason) => reason.code).join(',') || 'ok', }; } ``` --- ```ts /** * Recipe: read a CRL and ask it about one certificate. * * The shape to notice is what is **not** here: there is no array of revoked * entries. A CRL entry costs three ASN.1 nodes, so decoding the list into a * tree hits `maxNodes` at roughly 65 000 entries, while real CRLs run to * millions. The envelope is decoded and the list is *walked*, one TLV header at * a time, in constant memory. `findRevocation` is the question; `entryCount` is * the size, counted by walking. * * The CRL is built here rather than downloaded, because a recipe that needs the * network is a recipe that fails on a plane. It is assembled from raw DER, the * way a CA would emit it, so nothing in this file depends on pkinative being * able to write a CRL — which it cannot yet. */ import { checkRevocation, createCertificate, encodeBasicConstraints, encodeEnumerated, encodeExtension, encodeExtensions, encodeSequence, encodeTime, encodeTlv, findRevocation, parseCertificate, parseCertificateList, type Certificate, type CertificateList, type CrlReason, type CheckRevocationInput, type RevokedCertificate, } from 'pkinative'; const DAY = 86_400_000; const THIS_UPDATE = Date.UTC(2026, 0, 1); /** `AlgorithmIdentifier { sha256WithRSAEncryption, NULL }`, by hand. */ const algorithm = encodeSequence([ encodeTlv('universal', 6, false, Uint8Array.of(0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b)), encodeTlv('universal', 5, false, new Uint8Array(0)), ]); /** `Name` with one `CN` attribute. */ const issuerName = encodeSequence([ encodeTlv('universal', 17, true, encodeSequence([ encodeTlv('universal', 6, false, Uint8Array.of(0x55, 0x04, 0x03)), encodeTlv('universal', 12, false, new TextEncoder().encode('pkinative example CA')), ])), ]); const serial = (...bytes: readonly number[]): Uint8Array => encodeTlv('universal', 2, false, Uint8Array.from(bytes)); /** One `revokedCertificates` entry, optionally carrying a `cRLReason`. */ function entry(bytes: readonly number[], revokedAt: number, reason?: number): Uint8Array { const extensions = reason === undefined ? [] // cRLReason is 2.5.29.21, and its value is an ENUMERATED — an INTEGER // there is a common encoder bug, and the reason encodeEnumerated exists. : [encodeExtensions([{ oid: '2.5.29.21', value: encodeEnumerated(reason) }])]; return encodeSequence([serial(...bytes), encodeTime(revokedAt, 'UTCTime'), ...extensions]); } /** A complete `CertificateList`, signature bytes included but not computed. */ function buildCrl(entries: readonly Uint8Array[], crlNumber: number): Uint8Array { const tbs = encodeSequence([ encodeTlv('universal', 2, false, Uint8Array.of(0x01)), // v2 algorithm, issuerName, encodeTime(THIS_UPDATE, 'UTCTime'), encodeTime(THIS_UPDATE + 30 * DAY, 'UTCTime'), encodeSequence(entries), // crlExtensions [0] EXPLICIT, carrying cRLNumber (2.5.29.20). encodeTlv('context', 0, true, encodeExtensions([ { oid: '2.5.29.20', value: encodeTlv('universal', 2, false, Uint8Array.of(crlNumber)) }, ])), ]); // The signature is not computed: this recipe is about reading a list, and // `parseCertificateList` never verifies one — that is the caller's step, // the same separation §6 makes for certificates. return encodeSequence([tbs, algorithm, encodeTlv('universal', 3, false, Uint8Array.of(0x00, 0xde, 0xad))]); } const describe = (crl: CertificateList): string => `v${String(crl.version)} entries=${String(crl.entryCount)} crlNumber=${String(crl.crlNumber)} delta=${String(crl.isDelta)} diag=${String(crl.diagnostics.length)}`; const describeEntry = (found: RevokedCertificate | undefined): string => found === undefined ? 'not listed' : `${found.serialNumber.hex} on ${new Date(found.revocationDate.epochMilliseconds).toISOString().slice(0, 10)} reason=${String(found.reason)}`; /** A certificate issued by the same CA name, so the list is entitled to answer. */ async function certificate(serial: bigint): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const spki = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)); const der = await createCertificate({ serialNumber: serial, issuerDer: issuerName, subject: [[{ type: '2.5.4.3', value: 'host.example' }]], notBefore: THIS_UPDATE, notAfter: THIS_UPDATE + 90 * DAY, subjectPublicKey: spki, extensions: [{ oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: false }) }], }, { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }); return parseCertificate(der, { onDiagnostic: () => undefined }); } export default async function run(): Promise> { const der = buildCrl([ entry([0x01], THIS_UPDATE - DAY), entry([0x02], THIS_UPDATE - 2 * DAY, 1), // keyCompromise entry([0x00, 0xff], THIS_UPDATE - 3 * DAY, 5), // cessationOfOperation ], 7); const crl = parseCertificateList(der, { onDiagnostic: () => undefined }); // Serials are compared by their CONTENT OCTETS, never by the bigint: two // serials that differ only in a leading zero octet are two serials to a CA, // and comparing the value would make them one — a revocation missed. const leadingZero = findRevocation(der, Uint8Array.of(0x00, 0xff)); const withoutIt = findRevocation(der, Uint8Array.of(0xff)); // A single extension that names a reason nobody assigned. RFC 5280 §5.3.1 // skips 7, so the entry is found and its reason stays undefined rather than // being invented. const unassigned = parseCertificateList(buildCrl([entry([0x09], THIS_UPDATE, 7)], 8)); const oddEntry = findRevocation(buildCrl([entry([0x09], THIS_UPDATE, 7)], 8), Uint8Array.of(0x09)); const reasons: CrlReason[] = ['keyCompromise', 'cessationOfOperation']; // `findRevocation` says whether a serial is listed. It does NOT say whether // the list was entitled to answer, or whether it is current. That is // `checkRevocation`, which takes a signature verdict rather than a key — // which is what keeps it synchronous and free of Web Crypto. const listed = await certificate(2n); const absent = await certificate(0x42n); const ask = (subject: Certificate, overrides: Partial = {}): string => { const found = checkRevocation({ certificate: subject, crl, crlDer: der, at: THIS_UPDATE + DAY, signatureVerified: true, onDiagnostic: () => undefined, ...overrides, }); return found.length === 0 ? 'clean' : found.map((r) => r.code).sort().join(','); }; // An expired list, judged a year later. Note that `staleTolerance` is a // number the caller writes: soft-fail is a decision, not a default. const late = THIS_UPDATE + 400 * DAY; return { envelope: describe(crl), issuer: crl.issuer.rdns.length === 1 ? 'one RDN' : 'unexpected', noReason: describeEntry(findRevocation(der, Uint8Array.of(0x01))), withReason: describeEntry(findRevocation(der, Uint8Array.of(0x02))), leadingZeroMatters: `${describeEntry(leadingZero)} | ${describeEntry(withoutIt)}`, notListed: describeEntry(findRevocation(der, Uint8Array.of(0x42))), unassignedReason: `${String(unassigned.entryCount)} entry, reason=${String(oddEntry?.reason)}`, reasonsSeen: reasons.join(','), decisionClean: ask(absent), decisionRevoked: ask(listed), // Silence is not a clean bill of health: an unsigned list is something // anyone can publish, so it is UNKNOWN rather than "not revoked". decisionUnsigned: ask(absent, { signatureVerified: undefined }), decisionStale: ask(absent, { at: late }), decisionStaleTolerated: ask(absent, { at: late, staleTolerance: 500 * DAY }), // One extension is enough to show the shape a caller reaches for when // building a structure pkinative does not model. oneExtension: [...encodeExtension({ oid: '2.5.29.21', value: encodeEnumerated(1) })].map((b) => b.toString(16).padStart(2, '0')).join(''), }; } ``` --- ```ts /** * Recipe: build an OCSP request, and read a response without losing the three * states RFC 6960 gives you. * * Two things this file is really about. * * On the way out: a `CertID` identifies the issuer by **hashes of its encoded * name and of its public key bits** — not by its name, and not by a hash of the * whole SubjectPublicKeyInfo. Hashing the SPKI is the single most common OCSP * client bug, and it produces a request the responder answers `unknown` to, * which a careless client then reports as "not revoked". This recipe shows both * hashes computed the right way, and asserts they differ from the wrong way. * * On the way back: `good`, `revoked` and `unknown` are three states. * * No network. A responder is simulated by assembling a response from raw DER, * the way one would emit it — so this runs on a plane, and nothing here depends * on pkinative being able to write an OCSP response, which it cannot. */ import { checkOcspStatus, OCSP_NONCE_OID, computeFingerprint, createCertificate, createOcspRequest, decodeAsn1, encodeBasicConstraints, encodeOcspCertId, encodeOctetString, encodeSequence, encodeTime, encodeTlv, parseCertificate, parseOcspResponse, verifyOcspSignature, type Certificate, type OcspBasicResponse, type CheckOcspStatusInput, type OcspHashAlgorithm, type OcspResponse, type OcspSingleResponse, } from 'pkinative'; import { fixture } from './_fixtures.js'; const QUIET = { onDiagnostic: (): undefined => undefined }; const PRODUCED_AT = Date.UTC(2026, 5, 1); const hex = (bytes: Uint8Array): string => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); const ROOT: Certificate = parseCertificate(fixture('isrg-root-x1'), QUIET); const R12: Certificate = parseCertificate(fixture('lets-encrypt-r12'), QUIET); /** `AlgorithmIdentifier { id-Ed25519 }` — no parameters, as RFC 8410 requires. */ const ALG_ED25519 = encodeSequence([encodeTlv('universal', 6, false, Uint8Array.of(0x2b, 0x65, 0x70))]); /** `id-pkix-ocsp-basic`, the only `responseType` RFC 6960 defines. */ const OID_BASIC = encodeTlv('universal', 6, false, Uint8Array.of(0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x01)); /** A `CertStatus`: `[0] IMPLICIT NULL` for good, `[2] IMPLICIT` for unknown. */ const GOOD = encodeTlv('context', 0, false, new Uint8Array(0)); const UNKNOWN = encodeTlv('context', 2, false, new Uint8Array(0)); /** * `[1] IMPLICIT RevokedInfo`. The tag **replaces** the SEQUENCE's rather than * wrapping it, so the fields sit directly inside — wrapping them in a SEQUENCE * is the mistake IMPLICIT tagging invites. */ const revoked = (at: number, reason: number): Uint8Array => encodeTlv('context', 1, true, concat( encodeTime(at, 'GeneralizedTime'), encodeTlv('context', 0, true, encodeTlv('universal', 10, false, Uint8Array.of(reason))), )); function concat(...parts: readonly Uint8Array[]): Uint8Array { const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let at = 0; for (const part of parts) { out.set(part, at); at += part.length; } return out; } /** * One `SingleResponse` about `R12` under `ROOT`, current for a week. The * `nextUpdate` is what keeps the answer fresh after its `thisUpdate`: without * one, RFC 6960 §4.2.2.1 says newer information is available all the time, * and `checkOcspStatus` calls the answer stale as soon as it is older. */ const single = (status: Uint8Array, id?: Uint8Array): Uint8Array => encodeSequence([ id ?? encodeOcspCertId(R12, ROOT), status, encodeTime(PRODUCED_AT, 'GeneralizedTime'), encodeTlv('context', 0, true, encodeTime(PRODUCED_AT + 7 * 86_400_000, 'GeneralizedTime')), ]); /** How the caller signs: bound once, so nothing here needs a key type. */ type Sign = (data: Uint8Array) => Promise; /** A signed `OCSPResponse` carrying the given answers. */ async function respond(statuses: readonly Uint8Array[], sign: Sign, keyHash: Uint8Array, id?: Uint8Array): Promise { const tbs = encodeSequence([ // responderID byKey [2] EXPLICIT KeyHash, which is an OCTET STRING. encodeTlv('context', 2, true, encodeOctetString(keyHash)), encodeTime(PRODUCED_AT, 'GeneralizedTime'), encodeSequence(statuses.map((status) => single(status, id))), ]); const signature = await sign(tbs); const basic = encodeSequence([tbs, ALG_ED25519, encodeTlv('universal', 3, false, concat(Uint8Array.of(0x00), signature))]); return encodeSequence([ encodeTlv('universal', 10, false, Uint8Array.of(0x00)), // successful encodeTlv('context', 0, true, encodeSequence([OID_BASIC, encodeOctetString(basic)])), ]); } /** A responder that declines: a status with no body, which the protocol requires. */ const declines = (code: number): Uint8Array => encodeSequence([encodeTlv('universal', 10, false, Uint8Array.of(code))]); const describeStatus = (single: OcspSingleResponse): string => single.status.kind === 'revoked' ? `revoked on ${new Date(single.status.revocationTime.epochMilliseconds).toISOString().slice(0, 10)} reason=${String(single.status.reason)}` : single.status.kind; const describe = (response: OcspResponse): string => { const basic: OcspBasicResponse | undefined = response.basicResponse; return basic === undefined ? `${response.status}, no body` : `${response.status}: ${basic.responses.map(describeStatus).join(' | ')}`; }; export default async function run(): Promise> { // ── The request ── const nonce = Uint8Array.of(0xde, 0xad, 0xbe, 0xef, 1, 2, 3, 4); const request = createOcspRequest(R12, ROOT, { nonce }); const tbsRequest = decodeAsn1(request, QUIET).children[0]; const certId = decodeAsn1(encodeOcspCertId(R12, ROOT), QUIET); const nameHash = certId.children[1]?.content ?? new Uint8Array(0); const keyHash = certId.children[2]?.content ?? new Uint8Array(0); // SHA-256 is offered, but SHA-1 is the default on purpose: RFC 6960 §4.3 // makes it mandatory for responders, and a SHA-256 CertID comes back // `unknown` from many of them — which a client must not read as "good". const wider: OcspHashAlgorithm = 'SHA-256'; const sha256Id = decodeAsn1(encodeOcspCertId(R12, ROOT, wider), QUIET); // ── The responses ── // `generateKey` is typed as returning `CryptoKey | CryptoKeyPair` for an // algorithm the standard library's unions do not list, and Ed25519 is one. // Narrowing with `in` is what a consumer writing this has to do too — and // it is a real runtime check rather than a cast that hides a wrong guess. const generated = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); if (!('privateKey' in generated)) throw new Error('Ed25519 returns a key pair'); const sign: Sign = async (data) => new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, generated.privateKey, data)); const responderSpki = new Uint8Array(await crypto.subtle.exportKey('spki', generated.publicKey)); const good = parseOcspResponse(await respond([GOOD], sign, responderSpki.subarray(-32)), QUIET); const mixed = parseOcspResponse(await respond([GOOD, revoked(Date.UTC(2026, 4, 1), 1), UNKNOWN], sign, responderSpki.subarray(-32)), QUIET); const tryLater = parseOcspResponse(declines(3), QUIET); // A responder's signature says the bytes came from that key. Which key is // entitled to answer for this CA is a separate question, and not one this // library answers: `basicResponse.certificates` are certificates the // responder ATTACHED, and trusting them because they arrived would let the // responder nominate its own authority. const responder = parseCertificate(await createCertificate({ serialNumber: 1n, subject: [[{ type: '2.5.4.3', value: 'Example OCSP Responder' }]], notBefore: PRODUCED_AT - 86_400_000, notAfter: PRODUCED_AT + 86_400_000, subjectPublicKey: responderSpki, extensions: [{ oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: false }) }], }, { key: generated.privateKey, algorithm: { name: 'Ed25519' } }), QUIET); const signedByResponder = good.basicResponse === undefined ? 'no body' : `${String(await verifyOcspSignature(good.basicResponse, responder))} / wrongKey=${String(await verifyOcspSignature(good.basicResponse, ROOT))}`; // `parseOcspResponse` reads. It does not decide. RFC 6960 §3.2 makes a // client responsible for four things, and `checkOcspStatus` reports each: // the answer is about the certificate asked about, the signature verified, // the signer is authorised, and the answer is fresh. The middle two arrive // as verdicts the caller computed — which is what keeps the decision // synchronous and keeps policy out of this library. const expected = { issuerNameHash: computeFingerprint(ROOT.subject.der, 'SHA-1'), issuerKeyHash: computeFingerprint(ROOT.subjectPublicKeyInfo.publicKey.bytes, 'SHA-1'), serialNumber: R12.serialNumber.bytes, }; const decide = (der: Uint8Array, overrides: Partial = {}): string => { const found = checkOcspStatus({ response: parseOcspResponse(der, QUIET), expected, at: PRODUCED_AT + 3600_000, signatureVerified: true, responderAuthorized: true, ...overrides, }); return found.length === 0 ? 'clean' : found.map((r) => r.code).sort().join(','); }; const goodDer = await respond([GOOD], sign, responderSpki.subarray(-32)); // An answer about another serial: a response a client trusting // `responses[0]` would accept as its own. const substituted = await respond([GOOD], sign, responderSpki.subarray(-32), encodeSequence([ encodeSequence([ encodeTlv('universal', 6, false, Uint8Array.of(0x2b, 0x0e, 0x03, 0x02, 0x1a)), encodeTlv('universal', 5, false, new Uint8Array(0)), ]), encodeOctetString(expected.issuerNameHash), encodeOctetString(expected.issuerKeyHash), encodeTlv('universal', 2, false, Uint8Array.of(0x99)), ])); return { requestFields: String(tbsRequest?.children.length), // The nonce extension's OID, exported so a caller can find the echo // themselves rather than hard-coding it. nonceOid: OCSP_NONCE_OID, decisionClean: decide(goodDer), decisionUnsigned: decide(goodDer, { signatureVerified: undefined }), decisionUnauthorised: decide(goodDer, { responderAuthorized: false }), decisionSubstituted: decide(substituted), decisionDeclined: decide(declines(3)), nonceEchoed: hex(nonce), nameHashBytes: String(nameHash.length), keyHashBytes: String(keyHash.length), // The bug this recipe exists to prevent: `issuerKeyHash` is over the // key BITS, so it must equal SHA-1 of `publicKey.bytes` and must NOT // equal SHA-1 of the whole SubjectPublicKeyInfo. Both halves are // asserted, because getting the first right by accident while the // second also matched would mean nothing. keyHashIsOverKeyBits: String(hex(keyHash) === hex(computeFingerprint(ROOT.subjectPublicKeyInfo.publicKey.bytes, 'SHA-1'))), keyHashIsNotSpkiHash: String(hex(keyHash) !== hex(computeFingerprint(ROOT.subjectPublicKeyInfo.der, 'SHA-1'))), nameHashIsOverEncodedName: String(hex(nameHash) === hex(computeFingerprint(ROOT.subject.der, 'SHA-1'))), sha256HashBytes: String(sha256Id.children[1]?.content.length), good: describe(good), threeStates: describe(mixed), declined: describe(tryLater), signatureChecked: signedByResponder, responderNamed: responder.subject.rdns.length === 1 ? 'one RDN' : 'unexpected', }; } ``` --- ```ts /** * Recipe: sign a message as an RFC 5652 CMS SignedData, verify it the whole * way, and read the verdict apart. * * `createSignedData` writes the `.p7s`; `verifySignedData` answers the one * question a PDF validator, an S/MIME client and a code-signing check all * ask — *is this what its signer signed, and do I trust the signer?* — and * answers it as a report, never as an exception. Two booleans in that report * are worth keeping apart: `intact` needs no trust store (the content hashes * to what was committed, the signature verifies, the certificate is the one * the signer named), and `valid` adds the chain. `intact` without `valid` * means *unaltered, but not by anybody you trust*. * * The keys are the caller's, generated here with Web Crypto; pkinative * generates none. */ import { addUnsignedAttribute, canSign, createCertificate, createSignedData, encodeAttribute, encodeBasicConstraints, encodeKeyUsage, encodeString, getOidName, parseCertificate, parseSignedData, PkiCmsError, PkiError, verifySignedData, verifySignerInfoSignature, type Certificate, type CreateSignedDataInput, type ParseSignedDataOptions, type SignedData, type SignerInfo, type SigningKey, type VerifySignedDataInput, type VerifySignedDataReport, } from 'pkinative'; const NOW = Date.UTC(2026, 0, 15); const DAY = 86_400_000; const CN = '2.5.4.3'; const quiet = { onDiagnostic: () => undefined }; const codes = (report: VerifySignedDataReport): string => report.reasons.map((reason) => reason.code).join(' ') || 'none'; /** A key pair the caller holds, and the SPKI pkinative is given instead of the public key. */ async function keyPair(): Promise<{ signer: SigningKey; spki: Uint8Array }> { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); return { signer: { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }, spki: new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)), }; } /** A root, and a signer certificate it issued — the smallest hierarchy a verdict can trust. */ async function hierarchy(): Promise<{ root: Certificate; signer: Certificate; signerKey: SigningKey }> { const ca = await keyPair(); const leaf = await keyPair(); const root = parseCertificate(await createCertificate({ serialNumber: 1n, subject: [[{ type: CN, value: 'Example Root' }]], notBefore: NOW - 30 * DAY, notAfter: NOW + 3650 * DAY, subjectPublicKey: ca.spki, extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: true }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['keyCertSign', 'cRLSign']) }, ], }, ca.signer), quiet); const signer = parseCertificate(await createCertificate({ serialNumber: 2n, issuerDer: root.subject.der, subject: [[{ type: CN, value: 'Example Signer' }]], notBefore: NOW - DAY, notAfter: NOW + 365 * DAY, subjectPublicKey: leaf.spki, extensions: [{ oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature', 'nonRepudiation']) }], }, ca.signer), quiet); return { root, signer, signerKey: leaf.signer }; } export default async function run(): Promise> { if (!canSign()) return { available: 'no' }; const { root, signer, signerKey } = await hierarchy(); const message = new TextEncoder().encode('Pay 100 EUR to account 42.'); // ── Sign ── // Detached, as a PDF signature and most S/MIME are: the .p7s carries the // signature, not what it signs. Signed attributes are always written — // contentType, messageDigest, SigningCertificateV2 and // CMSAlgorithmProtection by default, signingTime when you give one. const input: CreateSignedDataInput = { content: message, detached: true, certificate: signer, signingTime: NOW }; const p7s = await createSignedData(input, signerKey); // Attached: the content travels inside, and the verifier needs nothing else. const p7m = await createSignedData({ content: message, certificate: signer }, signerKey); // ── Verify ── // `trustAnchors` is required, as for a chain. `at` is the instant the // chain is judged at — now by default. const ask: VerifySignedDataInput = { signedData: p7s, content: message, trustAnchors: [root], at: NOW }; const good = await verifySignedData(ask); const attached = await verifySignedData({ signedData: p7m, trustAnchors: [root], at: NOW }); // Absent content is never read as empty content: a detached signature // verified without its content is a reason, not a pass. const missing = await verifySignedData({ ...ask, content: undefined }); // One changed byte: the signature still verifies over the attributes, and // the content no longer hashes to the messageDigest they commit to. const tampered = await verifySignedData({ ...ask, content: new TextEncoder().encode('Pay 900 EUR to account 42.') }); // Nobody to trust: unaltered, but not by anybody you know. const untrusted = await verifySignedData({ ...ask, trustAnchors: [] }); // The signer's certificate judged a year and a day later, when it has expired. const late = await verifySignedData({ ...ask, at: NOW + 366 * DAY }); // ── Inspect ── // parseSignedData reads without judging. `content` is undefined for a // detached message; the certificates stay DER, because the bag is a claim // by whoever assembled it and parsing it all up front would spend work on // certificates nobody asks about. const parsed: SignedData = parseSignedData(p7s); const signerInfo: SignerInfo | undefined = parsed.signerInfos[0]; if (signerInfo === undefined) throw new Error('a message createSignedData wrote has one signer'); const attributes = (signerInfo.signedAttributes ?? []).map((attribute) => getOidName(attribute.oid) ?? attribute.oid).sort().join(','); // ── One signature, at the low level ── // verifySignerInfoSignature answers only "did this key sign these // attributes?". It reads no messageDigest, no sid and no chain — so it is // `true` for the tampered message above too. Use it to build a verifier, // never as the verdict on a message. const byKey = `${String(await verifySignerInfoSignature(signerInfo, signer))},${String(await verifySignerInfoSignature(signerInfo, root))}`; // ── Profiles that demand more ── // A signer that omitted SigningCertificateV2 and CMSAlgorithmProtection is // valid by default — RFC 5652 requires neither — and invalid for a caller // enforcing CAdES/PAdES (requireSigningCertificate) or RFC 6211 // (requireAlgorithmProtection). const bare = await createSignedData({ ...input, signingCertificateV2: false, algorithmProtection: false }, signerKey); const lenient = await verifySignedData({ ...ask, signedData: bare }); const strict = await verifySignedData({ ...ask, signedData: bare, requireSigningCertificate: true, requireAlgorithmProtection: true }); // ── An unsigned attribute, added after signing ── // Nothing covers the unsigned attributes, so adding one leaves every // signed octet — and the verdict — unchanged. addTimeStampToken is this // call with the RFC 3161 attribute written for you (see timestamp.ts). const annotated = addUnsignedAttribute(p7s, 0, encodeAttribute('1.3.6.1.4.1.99999.1', [encodeString('utf8', 'archived by the example')])); const stillGood = await verifySignedData({ ...ask, signedData: annotated }); // ── The three vocabularies ── // A PDF /Contents is a zero-padded placeholder: the padding is trailing // data, refused unless you say it is expected. const padded = new Uint8Array(p7s.length + 64); padded.set(p7s); const pdfContents: ParseSignedDataOptions = { allowTrailingData: true }; const fromPdf = parseSignedData(padded, pdfContents).signerInfos.length; // Thrown: parseSignedData is a primitive, so bytes that are not a // SignedData are a PkiCmsError with a stable code and a path — here, a // certificate handed over where a .p7s was expected. let thrown = 'none'; try { parseSignedData(root.der); } catch (error) { if (!(error instanceof PkiCmsError)) throw error; thrown = `${error.code}@${error.path ?? ''}`; } // Returned: verifySignedData is a composition, so the same bytes are a // reason that carries the code that would have been thrown. const malformed = await verifySignedData({ signedData: root.der, trustAnchors: [root] }); const wrapped = malformed.reasons.map((reason) => `${reason.code}(${reason.errorCode ?? ''})`).join(' '); // Thrown, still: the call itself is wrong — content given for a message // that carries its own is two answers to which bytes were signed. let misuse = 'none'; try { await verifySignedData({ signedData: p7m, content: message, trustAnchors: [root] }); } catch (error) { if (!(error instanceof PkiError)) throw error; misuse = error.code; } return { available: 'yes', detached: `valid=${String(good.valid)} intact=${String(good.signers[0]?.intact)} signatureVerifications=${String(good.signatureVerifications)}`, attached: `valid=${String(attached.valid)} content=${new TextDecoder().decode(attached.signedData?.content)}`, missing: codes(missing), tampered: `intact=${String(tampered.signers[0]?.intact)} ${codes(tampered)}`, untrusted: `intact=${String(untrusted.signers[0]?.intact)} valid=${String(untrusted.valid)}`, untrustedWhy: untrusted.reasons.some((reason) => reason.code === 'PKI_REASON_NO_TRUST_ANCHOR') ? 'PKI_REASON_NO_TRUST_ANCHOR' : codes(untrusted), late: `intact=${String(late.signers[0]?.intact)} ${codes(late)}`, claimedTime: good.signers[0]?.signingTime?.text ?? '', parsed: `v${String(parsed.version)} ${getOidName(parsed.contentType) ?? parsed.contentType} detached=${String(parsed.content === undefined)} certificates=${String(parsed.certificates.length)} sid=${signerInfo.sid.kind}`, attributes, byKey, lenient: `valid=${String(lenient.valid)}`, strict: strict.reasons.map((reason) => `${reason.code}@${reason.path.slice(reason.path.lastIndexOf('.') + 1)}`).sort().join(' '), annotated: `valid=${String(stillGood.valid)} unsigned=${(parseSignedData(annotated).signerInfos[0]?.unsignedAttributes ?? []).map((a) => a.oid).join(',')}`, fromPdf: String(fromPdf), thrown, wrapped, misuse, }; } ``` --- ```ts /** * Recipe: sign with a key pkinative never sees — an HSM, a smart card, a * cloud KMS, a remote signing service — through an `ExternalSigner`. * * A `SigningKey` hands pkinative an opaque `CryptoKey` it passes straight to * `crypto.subtle.sign`. An `ExternalSigner` hands it a function instead, so * the key does not even have to live in this process. pkinative computes the * exact bytes to sign, calls `produceSignature(bytes)`, and expects back what * `crypto.subtle.sign` would return: **raw `r ‖ s` for ECDSA**, the plain * octets for RSA and EdDSA. Many remote APIs return an ECDSA signature as DER * instead; converting it is the caller's job, because guessing which form * arrived is how a signature gets encoded twice — and pkinative refuses the * DER form outright rather than guess. * * The second half is the PDF case: the signer hashes the document's * `/ByteRange` itself and hands over `contentDigest`, never the document. */ import { canSign, createCertificate, createSignedData, decodeAsn1, encodeBasicConstraints, encodeKeyUsage, encodeSubjectKeyIdentifier, parseCertificate, parseSignedData, PkiError, verifySignedData, type ExternalSigner, type Signer, } from 'pkinative'; const NOW = Date.UTC(2026, 0, 15); const DAY = 86_400_000; const CN = '2.5.4.3'; const ALGORITHM = { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } as const; const quiet = { onDiagnostic: () => undefined }; /** * A stand-in for a remote signing service. The private key is captured in * this closure and never leaves it; the only thing the rest of the program * holds is `signDer`, which — like AWS KMS or a PKCS #11 wrapper configured * for it — answers ECDSA in DER. */ async function remoteSigningService(): Promise<{ spki: Uint8Array; signDer: (bytes: Uint8Array) => Promise }> { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const signDer = async (bytes: Uint8Array): Promise => { const raw = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, bytes)); const integer = (half: Uint8Array): number[] => { let start = 0; while (start < half.length - 1 && half[start] === 0) start++; const body = Array.from(half.subarray(start)); return [0x02, ...((body[0] ?? 0) & 0x80 ? [body.length + 1, 0] : [body.length]), ...body]; }; const body = [...integer(raw.subarray(0, 32)), ...integer(raw.subarray(32))]; return Uint8Array.of(0x30, body.length, ...body); }; return { spki: new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)), signDer }; } /** DER `Ecdsa-Sig-Value` → the fixed-width `r ‖ s` Web Crypto speaks, for a curve of `size`-octet coordinates. */ function derToRaw(der: Uint8Array, size: number): Uint8Array { const raw = new Uint8Array(2 * size); for (const [i, integer] of decodeAsn1(der).children.entries()) { const bytes = integer.content.subarray(Math.max(0, integer.content.length - size)); raw.set(bytes, (i + 1) * size - bytes.length); } return raw; } export default async function run(): Promise> { if (!canSign()) return { available: 'no' }; // The CA is ordinary; only the document signer's key is remote. const caPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const caKey: Signer = { key: caPair.privateKey, algorithm: ALGORITHM }; const root = parseCertificate(await createCertificate({ serialNumber: 1n, subject: [[{ type: CN, value: 'Example Root' }]], notBefore: NOW - 30 * DAY, notAfter: NOW + 3650 * DAY, subjectPublicKey: new Uint8Array(await crypto.subtle.exportKey('spki', caPair.publicKey)), extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: true }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['keyCertSign', 'cRLSign']) }, ], }, caKey), quiet); const service = await remoteSigningService(); const signer = parseCertificate(await createCertificate({ serialNumber: 2n, issuerDer: root.subject.der, subject: [[{ type: CN, value: 'Example Document Signer' }]], notBefore: NOW - DAY, notAfter: NOW + 365 * DAY, subjectPublicKey: service.spki, extensions: [ { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature', 'nonRepudiation']) }, { oid: '2.5.29.14', value: encodeSubjectKeyIdentifier(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8)) }, ], }, caKey), quiet); // The adapter: exactly the bytes pkinative gives, and r ‖ s back. const external: ExternalSigner = { algorithm: ALGORITHM, produceSignature: async (bytes) => derToRaw(await service.signDer(bytes), 32), }; // The PDF case. The /ByteRange is everything but the /Contents hole; the // signer hashes it and never holds the document whole. const pdf = new TextEncoder().encode(`%PDF-1.7 ... /Contents <${'0'.repeat(64)}> ... %%EOF`); const hole = { start: pdf.indexOf(0x3c), end: pdf.indexOf(0x3e) + 1 }; // '<' … '>' const byteRange = new Uint8Array(pdf.length - (hole.end - hole.start)); byteRange.set(pdf.subarray(0, hole.start)); byteRange.set(pdf.subarray(hole.end), hole.start); const contentDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', byteRange)); // contentDigest implies detached. sid 'subjectKeyIdentifier' names the key // rather than the certificate (a version 3 SignerInfo); the signer is then // bound to its certificate by SigningCertificateV2, written by default. const signature = await createSignedData({ contentDigest, certificate: signer, sid: 'subjectKeyIdentifier' }, external); const signerInfo = parseSignedData(signature).signerInfos[0]; // Verify the way a PDF validator does: with the digest of the /ByteRange. const report = await verifySignedData({ signedData: signature, contentDigest, trustAnchors: [root], at: NOW }); // What the adapter is for. Hand pkinative the DER directly and it refuses // to guess: a PkiError naming the fix, not a signature encoded twice. const careless: ExternalSigner = { algorithm: ALGORITHM, produceSignature: service.signDer }; let refused = 'none'; try { await createSignedData({ contentDigest, certificate: signer }, careless); } catch (error) { if (!(error instanceof PkiError)) throw error; refused = error.code; } return { available: 'yes', sid: `${signerInfo?.sid.kind ?? ''} v${String(signerInfo?.version)}`, detached: String(parseSignedData(signature).content === undefined), valid: `valid=${String(report.valid)} intact=${String(report.signers[0]?.intact)}`, signedBy: String(report.signers[0]?.certificate?.serialNumber.value), refused, }; } ``` --- ```ts /** * Recipe: prove when a signature existed — ask a timestamp authority * (RFC 3161), check its answer, attach it to the signature, and judge the * signer at the time the token proves instead of today. * * A token says that **a hash existed at a time**, and only once three things * are established: the hash is the one you care about, a TSA signed it, and * that TSA was entitled to. `verifyTimeStampToken` makes every check of * RFC 3161 §2.4.2 in one call and never throws for a verdict. * * There is no network here, so the recipe plays the TSA itself: a TSTInfo * built with the public encoders and signed with `createSignedData`, which is * all a timestamp token is — a SignedData over a TSTInfo. In production the * request is POSTed to the TSA and its answer read with * `parseTimeStampResponse`, exactly as below. */ import { addTimeStampToken, canSign, createCertificate, createSignedData, createTimeStampRequest, encodeAlgorithmIdentifier, encodeBasicConstraints, encodeExtendedKeyUsage, encodeInteger, encodeKeyUsage, encodeObjectIdentifier, encodeOctetString, encodeSequence, encodeTime, KEY_PURPOSES, parseCertificate, parseSignedData, parseTimeStampResponse, parseTimeStampToken, parseTstInfo, PkiCmsError, verifySignedData, verifyTimeStampToken, type Certificate, type CreateTimeStampRequestOptions, type SigningKey, type TimeStampResponse, type TimeStampToken, type TstInfo, type VerifyTimeStampTokenInput, type VerifyTimeStampTokenReport, } from 'pkinative'; const NOW = Date.UTC(2026, 0, 15); const DAY = 86_400_000; const CN = '2.5.4.3'; const SHA256 = '2.16.840.1.101.3.4.2.1'; const ID_CT_TSTINFO = '1.2.840.113549.1.9.16.1.4'; const quiet = { onDiagnostic: () => undefined }; const codes = (report: { reasons: readonly { code: string; path: string }[] }): string => report.reasons.map((reason) => `${reason.code}@${reason.path}`).join(' ') || 'none'; async function keyPair(): Promise<{ signer: SigningKey; spki: Uint8Array }> { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); return { signer: { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }, spki: new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)), }; } /** * The TSA's side, played locally: RFC 3161 §2.4.2's TSTInfo — version, * policy, the imprint it was asked to stamp, a serial, genTime, an accuracy * of one second, and the request's nonce echoed — signed as a SignedData * whose content type is id-ct-TSTInfo, wrapped in a granted TimeStampResp. */ async function answer(imprint: Uint8Array, nonce: bigint, genTime: number, tsa: Certificate, key: SigningKey): Promise { const tstInfo = encodeSequence([ encodeInteger(1n), encodeObjectIdentifier('1.3.6.1.4.1.99999.2'), encodeSequence([encodeAlgorithmIdentifier(SHA256), encodeOctetString(imprint)]), encodeInteger(20260115n), encodeTime(genTime, 'GeneralizedTime'), encodeSequence([encodeInteger(1n)]), encodeInteger(nonce), ]); // A token must carry SigningCertificateV2 (RFC 5816), which createSignedData writes by default. const token = await createSignedData({ content: tstInfo, contentType: ID_CT_TSTINFO, certificate: tsa }, key); return encodeSequence([encodeSequence([encodeInteger(0n)]), token]); // PKIStatusInfo { granted }, token } export default async function run(): Promise> { if (!canSign()) return { available: 'no' }; // ── A root, a document signer whose certificate lives one day, and a TSA ── const ca = await keyPair(); const root = parseCertificate(await createCertificate({ serialNumber: 1n, subject: [[{ type: CN, value: 'Example Root' }]], notBefore: NOW - 30 * DAY, notAfter: NOW + 3650 * DAY, subjectPublicKey: ca.spki, extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: true }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['keyCertSign', 'cRLSign']) }, ], }, ca.signer), quiet); const issue = async (serial: bigint, name: string, spki: Uint8Array, lifetime: number, extension: { oid: string; critical: boolean; value: Uint8Array }): Promise => parseCertificate(await createCertificate({ serialNumber: serial, issuerDer: root.subject.der, subject: [[{ type: CN, value: name }]], notBefore: NOW - DAY, notAfter: NOW + lifetime, subjectPublicKey: spki, extensions: [extension], }, ca.signer), quiet); const signerKey = await keyPair(); const signer = await issue(2n, 'Example Signer', signerKey.spki, DAY, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature']) }); // RFC 3161 §2.3: the TSA's extKeyUsage is present, critical, and names timeStamping alone. const tsaKey = await keyPair(); const tsa = await issue(3n, 'Example TSA', tsaKey.spki, 30 * DAY, { oid: '2.5.29.37', critical: true, value: encodeExtendedKeyUsage([KEY_PURPOSES.timeStamping]) }); // ── Sign, then ask for a signature timestamp ── const document = new TextEncoder().encode('Contract, version 3.'); const p7s = await createSignedData({ content: document, detached: true, certificate: signer }, signerKey.signer); // What is stamped is the hash of the signature VALUE (RFC 3161 Appendix A) // — not the document, not the signed attributes. const signature = parseSignedData(p7s).signerInfos[0]?.signature ?? new Uint8Array(0); const imprint = new Uint8Array(await crypto.subtle.digest('SHA-256', signature)); // The nonce is what makes a replayed answer detectable; pkinative // generates none. certReq (true by default) asks the TSA to embed its // certificate, so the verifier does not have to find it. const nonce = new DataView(crypto.getRandomValues(new Uint8Array(8)).buffer).getBigUint64(0); const options: CreateTimeStampRequestOptions = { hashAlgorithm: 'SHA-256', nonce, certReq: true }; const request = createTimeStampRequest(imprint, options); // In production: POST `request` as application/timestamp-query and read the body. const responseDer = await answer(imprint, nonce, NOW, tsa, tsaKey.signer); // ── Read the answer ── const response: TimeStampResponse = parseTimeStampResponse(responseDer); if (response.status !== 'granted' && response.status !== 'grantedWithMods') { return { available: 'yes', declined: `${response.status} ${response.failInfo.join(',')}` }; } const tokenDer = response.tokenDer ?? new Uint8Array(0); const token: TimeStampToken = parseTimeStampToken(tokenDer); const info: TstInfo = parseTstInfo(token.tstInfo.der); // ── Judge the token ── // `request` is the strongest way to say what was stamped — the only one // that catches a replayed response. `data` and `imprint` are the others. const ask: VerifyTimeStampTokenInput = { token: tokenDer, request, trustAnchors: [root], at: NOW }; const stamp: VerifyTimeStampTokenReport = await verifyTimeStampToken(ask); // Yesterday's answer, replayed against a new request with a new nonce. const replayed = await verifyTimeStampToken({ ...ask, request: createTimeStampRequest(imprint, { nonce: nonce ^ 1n }) }); // A token over something else: the document instead of the signature value. const wrongThing = await verifyTimeStampToken({ token: tokenDer, data: document, trustAnchors: [root], at: NOW }); // ── Attach it: CAdES-T, PAdES B-T ── // Surgery on the encoding: every signed octet is copied unchanged. const stamped = addTimeStampToken(p7s, 0, tokenDer); const later = NOW + 10 * DAY; // the signer's certificate expired nine days ago const base = { signedData: stamped, content: document, trustAnchors: [root] }; // Judged today, the signer's certificate has expired. const today = await verifySignedData({ ...base, at: later }); // atTimeStamp judges the signer's chain at the time its verified token // proves — genTime plus the declared accuracy — which is the long-term // question: was the certificate good when this was signed? const whenSigned = await verifySignedData({ ...base, at: later, atTimeStamp: true }); // The TSA's own chain is still judged at `at`, never at the genTime its // token asserts: otherwise a TSA key compromised after its certificate // expired could backdate tokens that would then be believed. Once the TSA // certificate has expired too, only an archive timestamp (PAdES B-LTA) // can carry the proof further. const afterTsa = await verifySignedData({ ...base, at: NOW + 40 * DAY, atTimeStamp: true }); // ── A PkiCmsError: the wrong kind of SignedData ── // The signature itself is a SignedData over id-data, not over a TSTInfo. let notAToken = 'none'; try { parseTimeStampToken(p7s); } catch (error) { if (!(error instanceof PkiCmsError)) throw error; notAToken = error.code; } return { available: 'yes', status: response.status, tstInfo: `policy=${info.policy} serial=${String(info.serialNumber.value)} genTime=${info.genTime.text} accuracy=${String(info.accuracy?.seconds)}s nonceEchoed=${String(info.nonce === nonce)}`, tokenSignedBy: token.signedData.contentType === ID_CT_TSTINFO ? 'id-ct-TSTInfo' : token.signedData.contentType, valid: `valid=${String(stamp.valid)} window=${String((stamp.earliest ?? 0) - NOW)}..${String((stamp.latest ?? 0) - NOW)}ms tsa=${String(stamp.tsaCertificate?.serialNumber.value)}`, replayed: codes(replayed), wrongThing: codes(wrongThing), timeStamps: String(parseSignedData(stamped).signerInfos[0]?.timeStampTokens.length), today: codes(today), whenSigned: `valid=${String(whenSigned.valid)} stampValid=${String(whenSigned.signers[0]?.timeStamps[0]?.valid)}`, afterTsa: codes(afterTsa), afterTsaIntact: String(afterTsa.signers[0]?.intact), notAToken, }; } ``` --- ```ts /** * Recipe: read a PKCS#8 private key — plain or password-protected — and sign * with it, without its bits ever passing through pkinative. * * `parsePrivateKeyInfo` and `parseEncryptedPrivateKeyInfo` describe a key * file before anything is imported or any password typed: its type and * curve, or the scheme it is encrypted with. `importPrivateKey` and * `decryptPrivateKey` turn it into a `SigningKey` — a `CryptoKey` with * `extractable: false` and the single usage `sign` — ready for * `createCertificate`, `createCertificationRequest` or `createSignedData`. * * Two rules to know before the code. **An encrypted key is unwrapped by the * host straight into a handle**, so its plaintext never exists in JavaScript, * and that is why `decryptPrivateKey` requires `algorithm`: the host must be * told what the key is before it decrypts it. **Only PBES2 with PBKDF2 and * AES-CBC is opened** — what OpenSSL 1.1 and later write by default; any * other scheme is named and refused with the conversion to run. * * The key files are written here by `node:crypto`, standing in for * `openssl genpkey` and `openssl pkcs8 -topk8`. */ import { generateKeyPairSync } from 'node:crypto'; import { canDecrypt, canSign, createCertificate, decodePem, decryptPrivateKey, encodeAlgorithmIdentifier, encodeInteger, encodeOctetString, encodePem, encodeSequence, importPrivateKey, parseCertificate, parseEncryptedPrivateKeyInfo, parsePrivateKeyInfo, PkiCryptoError, PkiError, PkiKeyError, verifySelfSignature, type DecryptPrivateKeyOptions, type EncryptedPrivateKeyInfo, type ImportPrivateKeyOptions, type PrivateKeyInfo, type SigningKey, } from 'pkinative'; const PASSPHRASE = 'correct horse battery staple'; const NOW = Date.UTC(2026, 0, 15); /** What the host says of a key: pkinative types the handle opaquely, and the runtime's CryptoKey answers. */ const handle = (signer: SigningKey): string => { const key = signer.key as unknown as { readonly extractable: boolean; readonly usages: readonly string[] }; return `extractable=${String(key.extractable)} usages=${key.usages.join(',')}`; }; /** The code a call fails with, or `ok`. */ async function failure(call: () => Promise): Promise { try { await call(); return 'ok'; } catch (error) { if (!(error instanceof PkiError)) throw error; return `${error.constructor.name}:${error.code}`; } } export default async function run(): Promise> { if (!canSign() || !canDecrypt()) return { available: 'no' }; const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }); const spki = new Uint8Array(ec.publicKey.export({ type: 'spki', format: 'der' })); // ── A plain key: described, then imported ── // A `PRIVATE KEY` PEM block is a PKCS#8 PrivateKeyInfo in DER. const pemText = encodePem('PRIVATE KEY', new Uint8Array(ec.privateKey.export({ type: 'pkcs8', format: 'der' }))); const [block] = decodePem(pemText, { label: 'PRIVATE KEY' }); const info: PrivateKeyInfo = parsePrivateKeyInfo(block!.bytes); // An EC key names its curve, so the algorithm is inferred: ECDSA on // P-256 with SHA-256, the digest that curve is customarily paired with. const imported: SigningKey = await importPrivateKey(block!.bytes); // ── An RSA key does not say how it signs ── // PKCS#1 v1.5 or PSS, over any digest: a guess would be a signature a // relying party refuses, so the caller names it. const rsa = new Uint8Array(generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'der' })); const rsaUnnamed = await failure(() => importPrivateKey(rsa)); const rsaOptions: ImportPrivateKeyOptions = { algorithm: { name: 'RSA-PSS', hash: 'SHA-256' } }; const rsaKey = await importPrivateKey(rsa, rsaOptions); // A named algorithm must fit the key: its family, and for ECDSA its curve. const misnamed = await failure(() => importPrivateKey(block!.bytes, { algorithm: { name: 'ECDSA', hash: 'SHA-384', namedCurve: 'P-384' } })); // ── An encrypted key: its scheme is readable before any password ── // `openssl pkcs8 -topk8 -v2 aes-256-cbc` writes this. const encrypted = new Uint8Array(ec.privateKey.export({ type: 'pkcs8', format: 'der', cipher: 'aes-256-cbc', passphrase: PASSPHRASE })); const described: EncryptedPrivateKeyInfo = parseEncryptedPrivateKeyInfo(encrypted); const options: DecryptPrivateKeyOptions = { password: PASSPHRASE, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }, }; const decrypted = await decryptPrivateKey(encrypted, options); // AES-CBC carries no authentication tag: a wrong password, altered bytes // and a key that is not the algorithm named are one error, not three. const wrongPassword = await failure(() => decryptPrivateKey(encrypted, { ...options, password: 'Correct horse battery staple' })); const wrongAlgorithm = await failure(() => decryptPrivateKey(encrypted, { ...options, algorithm: { name: 'Ed25519' } })); // ── The decrypted key signs ── const certificate = parseCertificate(await createCertificate({ serialNumber: 1n, subject: [[{ type: '2.5.4.3', value: 'Example Signer' }]], notBefore: NOW, notAfter: NOW + 365 * 86_400_000, subjectPublicKey: spki, }, decrypted)); // ── A scheme pkinative will not open: named, never guessed at ── // PBES2 with 3DES instead of AES (what `-v2 des3` writes) parses, says // what it is, and is refused only when asked to decrypt. const des3 = new Uint8Array(ec.privateKey.export({ type: 'pkcs8', format: 'der', cipher: 'des-ede3-cbc', passphrase: PASSPHRASE })); const des3Scheme = parseEncryptedPrivateKeyInfo(des3).encryption; // An RFC 7292 Appendix C scheme, as a PKCS#12 shrouds its keys before OpenSSL 3.0 — built by hand, with random bytes for ciphertext. const legacy = encodeSequence([ encodeAlgorithmIdentifier('1.2.840.113549.1.12.1.3', encodeSequence([encodeOctetString(new Uint8Array(8)), encodeInteger(2048)])), encodeOctetString(crypto.getRandomValues(new Uint8Array(48))), ]); let refusal = ''; try { await decryptPrivateKey(legacy, options); } catch (error) { if (!(error instanceof PkiKeyError)) throw error; // The message names the scheme and the OpenSSL command that converts the file. refusal = `${error.code} path=${error.path ?? ''} names=${String(error.message.includes('pbeWithSHAAnd3-KeyTripleDES-CBC'))} convert=${String(error.message.includes('openssl pkcs8 -topk8 -v2 aes-256-cbc'))}`; } // A wrong password is a PkiCryptoError, never a PkiKeyError: the file was // readable, the decryption did not succeed. let cryptoError = ''; try { await decryptPrivateKey(encrypted, { ...options, password: '' }); } catch (error) { if (error instanceof PkiCryptoError) cryptoError = `${error.code} algorithm=${error.algorithm ?? ''}`; } return { available: 'yes', info: `v${String(info.version)} ${info.kind} ${String(info.curve)} attributes=${String(info.attributes.length)} publicKey=${String(info.publicKey !== undefined)}`, imported: `${imported.algorithm.name} ${'hash' in imported.algorithm ? imported.algorithm.hash : ''} ${handle(imported)}`, rsaUnnamed, rsa: `${rsaKey.algorithm.name} ${'hash' in rsaKey.algorithm ? rsaKey.algorithm.hash : ''}`, misnamed, scheme: described.encryption.scheme, pbes2: `iterations=${String(described.encryption.pbes2?.iterations)} prf=${String(described.encryption.pbes2?.prf)} keyBits=${String(described.encryption.pbes2?.keyBits)}`, decrypted: `${decrypted.algorithm.name} ${handle(decrypted)}`, wrongPassword, wrongAlgorithm, signed: `${String(await verifySelfSignature(certificate))}`, des3: `${des3Scheme.scheme} pbes2=${String(des3Scheme.pbes2 !== undefined)} ${await failure(() => decryptPrivateKey(des3, options))}`, refusal, cryptoError, }; } ``` --- ```ts /** * Recipe: open a PKCS#12 file (`.p12`, `.pfx`) — in one call, then with the * primitives underneath — and read the files pkinative describes but will * not open. * * `openPkcs12` is the call most code wants: a password in, the signing key * and its certificate out, and a report that says what could not be opened * instead of an exception about the first thing. Three rules shape it. * **A key never exists in plaintext here**: Web Crypto unwraps it straight * into a non-extractable `CryptoKey`. **Its algorithm comes from its * certificate**, because the host must be told what a key is before it * decrypts it. **Integrity fails closed**: most files carry a MAC keyed with * RFC 7292 Appendix B, which pkinative never computes, and such a file is * read but not `valid` unless you say `allowUnverifiedIntegrity`. * * The file is written here, by a small local writer, the way OpenSSL 3.4+ * writes one with `-pbmac1_pbkdf2`: the certificates in a PBES2-encrypted * SafeContents, the key in a PBES2-shrouded bag, an RFC 9579 PBMAC1 MAC over * the lot. Every cipher and MAC in the writer is the host's Web Crypto in * the caller's code; pkinative only reads. */ import { canDecrypt, canSign, createCertificate, createSignedData, decryptPrivateKey, encodeAlgorithmIdentifier, encodeAttribute, encodeBasicConstraints, encodeExplicit, encodeImplicit, encodeInteger, encodeKeyUsage, encodeNull, encodeObjectIdentifier, encodeOctetString, encodeSequence, encodeSet, encodeString, formatDistinguishedName, openSafeContents, parseCertificate, parsePkcs12, parseSignedData, PkiKeyError, openPkcs12, verifyPkcs12Mac, verifySignerInfoSignature, type Pkcs12, type PkiDiagnostic, type PkiKeyErrorCode, type OpenPkcs12Options, type OpenPkcs12Report, type SafeBag, type SafeContentsInfo, type SigningKey, } from 'pkinative'; const PASSWORD = 'correct horse battery staple'; const NOW = Date.UTC(2026, 0, 15); const DAY = 86_400_000; const OID = { data: '1.2.840.113549.1.7.1', encryptedData: '1.2.840.113549.1.7.6', pbes2: '1.2.840.113549.1.5.13', pbkdf2: '1.2.840.113549.1.5.12', pbmac1: '1.2.840.113549.1.5.14', hmacWithSHA256: '1.2.840.113549.2.9', aes256Cbc: '2.16.840.1.101.3.4.1.42', sha256: '2.16.840.1.101.3.4.2.1', shroudedKeyBag: '1.2.840.113549.1.12.10.1.2', certBag: '1.2.840.113549.1.12.10.1.3', x509Certificate: '1.2.840.113549.1.9.22.1', friendlyName: '1.2.840.113549.1.9.20', localKeyId: '1.2.840.113549.1.9.21', // Two RFC 7292 Appendix C schemes — what OpenSSL wrote by default before 3.0. pbeWithSHAAnd3KeyTripleDES: '1.2.840.113549.1.12.1.3', pbeWithSHAAnd40BitRC2: '1.2.840.113549.1.12.1.6', }; /** What the host says of a key: pkinative types the handle opaquely, and the runtime's CryptoKey answers. */ const handle = (signer: SigningKey): string => { const key = signer.key as unknown as { readonly extractable: boolean; readonly usages: readonly string[] }; return `extractable=${String(key.extractable)} usages=${key.usages.join(',')}`; }; const codes = (report: OpenPkcs12Report): string => report.reasons.map((reason) => `${reason.code}@${reason.path}`).join(' ') || 'none'; // ── A small PKCS#12 writer (RFC 7292, RFC 8018, RFC 9579) ── const seq = (...children: Uint8Array[]): Uint8Array => encodeSequence(children); const oid = encodeObjectIdentifier; const contentInfo = (type: string, content: Uint8Array): Uint8Array => seq(oid(type), encodeExplicit(0, content)); const random = (length: number): Uint8Array => crypto.getRandomValues(new Uint8Array(length)); const passwordKey = () => crypto.subtle.importKey('raw', new TextEncoder().encode(PASSWORD), 'PBKDF2', false, ['deriveKey']); /** PBES2 with PBKDF2-HMAC-SHA-256 and AES-256-CBC: the AlgorithmIdentifier and the ciphertext. */ async function pbes2(plaintext: Uint8Array, iterations: number): Promise<{ algorithm: Uint8Array; ciphertext: Uint8Array }> { const salt = random(16); const iv = random(16); const key = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, await passwordKey(), { name: 'AES-CBC', length: 256 }, false, ['encrypt']); const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, plaintext)); const algorithm = encodeAlgorithmIdentifier(OID.pbes2, seq( encodeAlgorithmIdentifier(OID.pbkdf2, seq(encodeOctetString(salt), encodeInteger(iterations), encodeAlgorithmIdentifier(OID.hmacWithSHA256, encodeNull()))), encodeAlgorithmIdentifier(OID.aes256Cbc, encodeOctetString(iv)), )); return { algorithm, ciphertext }; } /** An RFC 7292 Appendix C AlgorithmIdentifier — salt and iterations — over random bytes nothing here could encrypt. */ const legacyScheme = (schemeOid: string): { algorithm: Uint8Array; ciphertext: Uint8Array } => ({ algorithm: encodeAlgorithmIdentifier(schemeOid, seq(encodeOctetString(random(8)), encodeInteger(2048))), ciphertext: random(64) }); /** RFC 9579 PBMAC1: HMAC-SHA-256 over the AuthenticatedSafe, keyed with PBKDF2. */ async function pbmac1(authSafe: Uint8Array, iterations: number): Promise { const salt = random(16); const key = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, await passwordKey(), { name: 'HMAC', hash: 'SHA-256', length: 256 }, false, ['sign']); const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, authSafe)); const algorithm = encodeAlgorithmIdentifier(OID.pbmac1, seq( encodeAlgorithmIdentifier(OID.pbkdf2, seq(encodeOctetString(salt), encodeInteger(iterations), encodeInteger(32), encodeAlgorithmIdentifier(OID.hmacWithSHA256, encodeNull()))), encodeAlgorithmIdentifier(OID.hmacWithSHA256, encodeNull()), )); // Under PBMAC1, RFC 9579 has the verifier ignore macSalt and iterations. return seq(seq(algorithm, encodeOctetString(mac)), encodeOctetString(new TextEncoder().encode('NOT USED')), encodeInteger(iterations)); } /** A MacData shaped like the RFC 7292 Appendix B MAC OpenSSL writes by default. pkinative never computes one, so it cannot tell these random octets from a genuine MAC. */ const appendixBMac = (): Uint8Array => seq(seq(encodeAlgorithmIdentifier(OID.sha256, encodeNull()), encodeOctetString(random(32))), encodeOctetString(random(8)), encodeInteger(2048)); interface Material { readonly pkcs8: Uint8Array; readonly certificate: Uint8Array } /** * A PFX: the certificate in an encrypted SafeContents, the key in a shrouded * bag, the two tied by `localKeyId`. `scheme` chooses how both are encrypted; * `mac` how the whole is authenticated. */ async function writePfx(material: Material, options: { scheme: 'pbes2' | 'legacy'; mac: 'pbmac1' | 'appendix-b'; iterations?: number }): Promise { const iterations = options.iterations ?? 2048; // bagAttributes is a SET OF, written here in the order DER sorts it: the shorter encoding first. const attributes = encodeSet([ encodeAttribute(OID.localKeyId, [encodeOctetString(Uint8Array.of(1))]), encodeAttribute(OID.friendlyName, [encodeString('bmp', 'signing key')]), ]); const certBag = seq(oid(OID.certBag), encodeExplicit(0, seq(oid(OID.x509Certificate), encodeExplicit(0, encodeOctetString(material.certificate)))), attributes); const certs = options.scheme === 'pbes2' ? await pbes2(seq(certBag), iterations) : legacyScheme(OID.pbeWithSHAAnd40BitRC2); const key = options.scheme === 'pbes2' ? await pbes2(material.pkcs8, iterations) : legacyScheme(OID.pbeWithSHAAnd3KeyTripleDES); const keyBag = seq(oid(OID.shroudedKeyBag), encodeExplicit(0, seq(key.algorithm, encodeOctetString(key.ciphertext))), attributes); const authSafe = seq( contentInfo(OID.encryptedData, seq(encodeInteger(0), seq(oid(OID.data), certs.algorithm, encodeImplicit(0, encodeOctetString(certs.ciphertext))))), contentInfo(OID.data, encodeOctetString(seq(keyBag))), ); const mac = options.mac === 'pbmac1' ? await pbmac1(authSafe, iterations) : appendixBMac(); return seq(encodeInteger(3), contentInfo(OID.data, encodeOctetString(authSafe)), mac); } /** The caller's key and a self-signed certificate for it — generated and exported by the caller, never by pkinative. */ async function material(): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const signer: SigningKey = { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }; const certificate = await createCertificate({ serialNumber: 1n, subject: [[{ type: '2.5.4.3', value: 'Example Signer' }]], notBefore: NOW, notAfter: NOW + 365 * DAY, subjectPublicKey: new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)), extensions: [ { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: false }) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature', 'nonRepudiation']) }, ], }, signer); return { pkcs8: new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)), certificate }; } export default async function run(): Promise> { // Without Web Crypto nothing in a PKCS#12 can be opened; openPkcs12 would throw PKI_CRYPTO_UNAVAILABLE. if (!canDecrypt() || !canSign()) return { available: 'no' }; const keys = await material(); const modern = await writePfx(keys, { scheme: 'pbes2', mac: 'pbmac1' }); // ── One call ── const options: OpenPkcs12Options = { password: PASSWORD }; const report = await openPkcs12(modern, options); const [entry] = report.keys; // The key is ready to sign, and cannot give its bits back to anyone. const message = new TextEncoder().encode('Pay 100 EUR to account 42.'); const p7s = await createSignedData({ content: message, certificate: entry!.certificate! }, entry!.signingKey!); const signerInfo = parseSignedData(p7s).signerInfos[0]!; // A wrong password fails the MAC, and nothing is decrypted after it. const wrong = await openPkcs12(modern, { password: 'Correct horse battery staple' }); // ── A MAC nobody here can check ── // The shape of most .p12 files in circulation. The contents are read and // reported; `valid` stays false until you waive the check, knowingly. const unverifiable = await writePfx(keys, { scheme: 'pbes2', mac: 'appendix-b' }); const closed = await openPkcs12(unverifiable, { password: PASSWORD }); const waived = await openPkcs12(unverifiable, { password: PASSWORD, allowUnverifiedIntegrity: true }); // With no MAC to fail first, a wrong password surfaces where it bites: // the encrypted certificates will not decrypt, and the key, whose // certificate was among them, has no algorithm to be unwrapped as. const guessed = await openPkcs12(unverifiable, { password: 'Correct horse battery staple', allowUnverifiedIntegrity: true }); // ── The primitives underneath ── // parsePkcs12 needs no password: it says what is encrypted, with what, // and how the file is authenticated, before anyone types one. const p12: Pkcs12 = parsePkcs12(modern); const layout = p12.contents.map((c: SafeContentsInfo) => `${c.path}:${c.encrypted ? c.encryption!.scheme : `plain(${String(c.bags.length)})`}`).join(' '); const intact = p12.mac?.kind === 'pbmac1' && await verifyPkcs12Mac(p12, PASSWORD); const bags: SafeBag[] = []; for (const contents of p12.contents) bags.push(...await openSafeContents(contents, PASSWORD)); const certBag = bags.find((bag) => bag.certificateDer !== undefined)!; const keyBag = bags.find((bag) => bag.encryptedKey !== undefined)!; // The primitive decrypts a key with the algorithm you name: here, the // one its certificate — found through the shared localKeyId — carries. const primitiveKey = await decryptPrivateKey(keyBag.encryptedKey!.der, { password: PASSWORD, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' }, }); // A PBKDF2 count below RFC 8018's 1 000 still opens, with a diagnostic. const seen: PkiDiagnostic[] = []; parsePkcs12(await writePfx(keys, { scheme: 'pbes2', mac: 'pbmac1', iterations: 100 }), { onDiagnostic: (d) => { seen.push(d); } }); // ── A legacy file: described, refused by name, never guessed at ── const legacy = await writePfx(keys, { scheme: 'legacy', mac: 'appendix-b' }); const described = parsePkcs12(legacy); const legacyKey = described.contents[1]!.bags[0]!.encryptedKey!; let refused: PkiKeyErrorCode | undefined; let refusedMessage = ''; try { await openSafeContents(described.contents[0]!, PASSWORD); } catch (error) { if (!(error instanceof PkiKeyError)) throw error; refused = error.code; refusedMessage = error.message; } let macRefused: PkiKeyErrorCode | undefined; try { await verifyPkcs12Mac(described, PASSWORD); } catch (error) { if (!(error instanceof PkiKeyError)) throw error; macRefused = error.code; } const legacyReport = await openPkcs12(legacy, { password: PASSWORD, allowUnverifiedIntegrity: true }); return { available: 'yes', valid: `valid=${String(report.valid)} integrity=${report.integrity} keys=${String(report.keys.length)} certificates=${String(report.certificates.length)}`, key: `${entry!.path} ${entry!.friendlyName ?? ''} ${entry!.signingKey!.algorithm.name} ${handle(entry!.signingKey!)}`, signs: String(await verifySignerInfoSignature(signerInfo, entry!.certificate!)), wrongPassword: `integrity=${wrong.integrity} keys=${String(wrong.keys.length)} ${codes(wrong)}`, unverified: `valid=${String(closed.valid)} integrity=${closed.integrity} keys=${String(closed.keys.filter((k) => k.signingKey).length)} ${codes(closed)}`, waived: `valid=${String(waived.valid)} integrity=${waived.integrity}`, waivedWrongPassword: codes(guessed), layout, mac: `${p12.mac?.kind ?? 'none'} intact=${String(intact)} diagnostics=${String(p12.diagnostics.length)}`, bags: bags.map((bag) => `${bag.kind}@${bag.path}`).join(' '), certificate: `${formatDistinguishedName(parseCertificate(certBag.certificateDer!).subject)} sameLocalKeyId=${String(certBag.localKeyId?.join() === keyBag.localKeyId?.join())}`, primitive: `${primitiveKey.algorithm.name} ${handle(primitiveKey)}`, lowIterations: `${seen[0]?.code ?? 'none'} at ${seen.map((d) => d.path?.split('.')[0]).join(',')}`, legacyScheme: `${described.contents[0]!.encryption!.scheme} / ${legacyKey.encryption.scheme} pbes2=${String(legacyKey.encryption.pbes2 !== undefined)} mac=${described.mac?.kind ?? 'none'}`, refused: String(refused), refusedNames: String(refusedMessage.includes('pbeWithSHAAnd40BitRC2-CBC')), macRefused: String(macRefused), legacyReport: `valid=${String(legacyReport.valid)} ${codes(legacyReport)}`, }; } ``` --- ```ts /** * Recipe: read a PKCS#10 certification request and judge its proof of * possession — what a CA does before it looks at anything else. * * A request is signed with the private half of the very key it carries, so * `verifyCertificationRequest` needs no trust anchor: the signature over * `certificationRequestInfo` either verifies under `subjectPKInfo` or it does * not. Like every report in pkinative it resolves — bytes that are not a * request, a false signature and a host that cannot put the question are * reasons, not exceptions — and a `valid` report says the requester holds * the key, nothing about whether the name or the extensions should be * granted. That decision stays the CA's. * * The request is built here with `createCertificationRequest`, by a key the * caller generates and pkinative never sees; what it writes, its own reader * reads back with no diagnostic. */ import { canSign, canVerify, createCertificationRequest, encodeKeyUsage, encodeSubjectAltName, formatDistinguishedName, parseCertificationRequest, verifyCertificationRequest, } from 'pkinative'; import type { CertificationRequest, CsrAttribute, VerifyCertificationRequestOptions, VerifyCertificationRequestReport } from 'pkinative'; /** The one-line summary of a report: the verdict, then each reason at its path. */ const verdict = (report: VerifyCertificationRequestReport): string => [`valid=${String(report.valid)}`, ...report.reasons.map((r) => `${r.code}@${r.path}`)].join(' '); export default async function run(): Promise> { if (!canSign() || !canVerify()) return { available: 'no' }; // The requester's key. pkinative generates none: this is the caller's call. const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); const spki = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)); const der = await createCertificationRequest({ subject: [[{ type: '2.5.4.6', value: 'US', stringType: 'printable' }], [{ type: '2.5.4.3', value: 'host.example' }]], subjectPublicKey: spki, extensions: [ { oid: '2.5.29.17', value: encodeSubjectAltName([{ kind: 'dNSName', value: 'host.example' }, { kind: 'dNSName', value: 'www.host.example' }]) }, { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature']) }, ], }, { key: pair.privateKey, algorithm: { name: 'ECDSA', hash: 'SHA-256', namedCurve: 'P-256' } }); // Read it: the subject, the key and the extensions it asks for, which // travel inside the PKCS#9 extensionRequest attribute and are decoded with // the certificate's own decoders. Zero diagnostics is the assertion. const diagnostics: string[] = []; const request: CertificationRequest = parseCertificationRequest(der, { onDiagnostic: (d) => { diagnostics.push(d.code); } }); const attributes: readonly CsrAttribute[] = request.attributes; const requested = (request.extensions ?? []).map((e) => `${e.kind}${e.critical ? '!' : ''}`).join(','); const names = request.extensions?.flatMap((e) => (e.kind === 'subjectAltName' ? e.names.map((n) => (n.kind === 'dNSName' ? n.value : n.kind)) : [])) ?? []; // Judge it: the proof of possession, with the key inside the request. const options: VerifyCertificationRequestOptions = { allowSha1: false }; const sound = await verifyCertificationRequest(der, options); // One octet of the subject changed after signing: the structure still // parses, the signature no longer covers it. const tampered = der.slice(); const at = tampered.findIndex((_, i) => tampered[i] === 0x68 && tampered[i + 1] === 0x6f && tampered[i + 2] === 0x73 && tampered[i + 3] === 0x74); // "host" tampered[at] = 0x6a; // 'j' const altered = await verifyCertificationRequest(tampered); // Bytes that are not a request at all: a reason carrying the code the // parser would have thrown, never an exception. const garbage = await verifyCertificationRequest(Uint8Array.of(0x30, 0x03, 0x02, 0x01, 0x01)); return { available: 'yes', subject: formatDistinguishedName(request.subject), key: `${request.subjectPublicKeyInfo.kind} version=${String(request.version)} diagnostics=${String(diagnostics.length)}`, attributes: attributes.map((a) => `${a.oid}(${String(a.values.length)})`).join(' '), requested, names: names.join(','), sound: `${verdict(sound)} verifications=${String(sound.signatureVerifications)}`, altered: `${verdict(altered)} subject=${altered.request === undefined ? '?' : formatDistinguishedName(altered.request.subject)}`, garbage: `${verdict(garbage)} errorCode=${garbage.reasons[0]?.errorCode ?? '?'} request=${String(garbage.request)}`, }; } ```