Home โ€บ Guides โ€บ Use cases

Use cases

The jobs pkinative does today, with the code that does them and the guarantee behind each. Four need no signature at all; the rest check and write signatures, validate chains, ask about revocation, sign, verify and timestamp CMS messages, and open the PKCS#8 keys and PKCS#12 files that sign them.

A reading library is not half a PKI library. Most of what breaks in production PKI breaks before anyone reaches a signature: a certificate expired and nobody was watching, a parser accepted bytes it should have refused, a pinned key was pinned to the wrong thing. Those are the first four cases below.

Architecture: untrusted bytes reach decodeAsn1, an iterative strict-DER decoder, which either throws a PkiEncodingError or a PkiLimitError with a stable code, or produces a value tree. The tree reaches parseCertificate, which either throws a PkiCertificateError or returns a Certificate of zero-copy views together with any diagnostics. A green band states the guarantee: three exits, never a fourth, and depth is a limit rather than a stack overflow. A slate band states the limits: branch on error.code, never on the message, and a parsed certificate is read, not yet trusted.

Three exits, never a fourth. The decoder is iterative, so depth is a limit and not a stack overflow; every refusal carries a code you branch on and a message that names the remedy. What a refusal never is, is silence.

Inventory a certificate estate #

The job: read every certificate an organisation has on disk, and report what expires when. It needs no network, no signature and no trust store โ€” only correct parsing of every field, including the ones that are usually skipped.

Architecture: untrusted input, a PEM bundle read from disk or DER from an upload, a queue or a peer, enters pkinative. decodePem with the CERTIFICATE label splits the bundle and refuses a private key hidden in it; parseCertificate reads every field, each loop bounded by a named, CWE-tagged limit from DEFAULT_PKI_LIMITS that the caller may tighten. Out come report rows, or a typed refusal: a PkiLimitError naming its limit, configured and observed values, or a PkiError code. Bands: no crash, no hang, no read past the buffer; no network and no trust decision.

import { decodePem, formatDistinguishedName, getExtension, parseCertificate } from 'pkinative';

const DAY = 86_400_000;

export function expiring(bundle: string, within: number): string[] {
    const soon: string[] = [];
    for (const block of decodePem(bundle, { label: 'CERTIFICATE' })) {
        const cert = parseCertificate(block.bytes, { onDiagnostic: () => undefined });
        const days = Math.floor((cert.validity.notAfter.epochMilliseconds - Date.now()) / DAY);
        if (days > within) continue;
        const names = getExtension(cert, 'subjectAltName')?.names ?? [];
        soon.push(`${String(days)}d  ${formatDistinguishedName(cert.subject)}  ${names.length} SAN`);
    }
    return soon;
}

What makes this pkinative's job rather than openssl x509's: a bundle is one string, a malformed block in the middle is a typed refusal rather than a partially-written report, and the result is data โ€” not text you then have to parse back. decodePem with a label also refuses a private key hidden in the bundle, which is how a key ends up in a log.

The distinguished name is rendered by RFC 4514, the one rendering two tools can compare. Times are epochMilliseconds, so there is no locale and no time zone to disagree about.

Ingest hostile bytes #

The job: accept certificates from somewhere you do not control โ€” an upload form, a queue, a peer โ€” and never crash, never hang, never read past the buffer.

import { DEFAULT_PKI_LIMITS, parseCertificate, PkiError, PkiLimitError } from 'pkinative';

export function ingest(der: Uint8Array): { ok: true; subject: string } | { ok: false; code: string } {
    try {
        // Every limit not named here keeps its DEFAULT_PKI_LIMITS value.
        const cert = parseCertificate(der, { limits: { maxInputBytes: 64 * 1024, maxExtensions: 32 } });
        return { ok: true, subject: cert.subject.der.length.toString() };
    } catch (error) {
        if (error instanceof PkiLimitError) return { ok: false, code: `${error.code}:${error.limit}` };
        if (error instanceof PkiError) return { ok: false, code: error.code };
        throw error;
    }
}

Every loop over input bytes consults a named, CWE-tagged bound from src/core/pki-limits.ts, and the caller can tighten any of them for its own context. DEFAULT_PKI_LIMITS is what you get when you name nothing.

Branch on error.code, never on the message: the codes are a registry (docs/data/errors.json), each one recording when it is raised, its remedy, the clause it comes from and its CWE. Messages are prose and may be reworded; codes are the contract. The errors guide is the full list.

PkiLimitError additionally carries limit, configured and observed, so a rejection tells an operator which bound to raise and by how much โ€” rather than leaving them to guess.

Pin a public key, not a certificate #

The job: remember that this peer is this peer, across a certificate renewal. Pinning the whole certificate breaks every ninety days; pinning the subject public key does not, because a renewal normally keeps the key.

Architecture: a Certificate value from parseCertificate exposes der, the whole encoding, tbsDer and signatureValue. Inside tbsCertificate sit the serial number, issuer, validity, subject, subjectPublicKeyInfo and extensions. Three uses read three slices: a key pin hashes subjectPublicKeyInfo.der, a fingerprint hashes the whole certificate der, and verifyCertificateSignature checks signatureValue over tbsDer with the issuer key. A green band states that every slice is a zero-copy view of the input. A slate band states the limit: neither a fingerprint nor a signature says a chain is trusted.

import { computeFingerprint, formatFingerprint, parseCertificate } from 'pkinative';

/** The SHA-256 of the SubjectPublicKeyInfo โ€” what HPKP and Certificate Transparency pin. */
export function keyPin(der: Uint8Array): string {
    const cert = parseCertificate(der, { onDiagnostic: () => undefined });
    return formatFingerprint(computeFingerprint(cert.subjectPublicKeyInfo.der, 'SHA-256'), { separator: '', letterCase: 'lower' });
}

The detail that decides whether a pin is right: what exactly you hash. Hashing the certificate pins the certificate. Hashing the public key bits pins something two different algorithms could collide on. The thing to hash is the full SubjectPublicKeyInfo โ€” algorithm identifier and key together โ€” and pkinative hands it to you as der, a zero-copy view of the original input rather than a re-encoding, so the bytes you hash are the bytes that arrived.

Gate a DER round trip #

The job: prove that a tool in your pipeline did not quietly rewrite a certificate. Re-encoding a decoded value and comparing it to the input is a total check โ€” every octet, including the ones nothing in your code reads.

import { decodeAsn1, encodeAsn1Node } from 'pkinative';

/** True when `der` is canonical DER that survives a decode and re-encode unchanged. */
export function roundTrips(der: Uint8Array): boolean {
    const again = encodeAsn1Node(decodeAsn1(der));
    return again.length === der.length && again.every((b, i) => b === der[i]);
}

This is the check conformance level L2 runs over all 30 361 unique x509-limbo certificates on every release, so it is not a suggestion โ€” it is a property the library is held to. A certificate that fails it was not canonical DER to begin with, which is itself the finding.

Strict DER is the default, and that is what makes the check meaningful: a non-minimal length, a constructed string, an indefinite length or a BOOLEAN that is not 0x00 or 0xFF is refused rather than normalised. BER is available with { encodingRules: 'ber' } when you must read what another tool wrote, and the differences it tolerated are reported as diagnostics rather than hidden.

The job: confirm that the key in one certificate really signed another. It is one link, and the honest name for it is a link โ€” not a chain.

import { canVerify, parseCertificate, PkiCryptoError, verifyCertificateSignature } from 'pkinative';

export async function signedBy(leafDer: Uint8Array, issuerDer: Uint8Array): Promise<'yes' | 'no' | string> {
    if (!canVerify()) return 'PKI_CRYPTO_UNAVAILABLE';
    const quiet = { onDiagnostic: () => undefined };
    try {
        return await verifyCertificateSignature(parseCertificate(leafDer, quiet), parseCertificate(issuerDer, quiet)) ? 'yes' : 'no';
    } catch (error) {
        // Not a verdict: pkinative could not decide. Retrying elsewhere may
        // give a different answer, and treating it as a rejection would
        // blame the certificate for the runtime's limits.
        if (error instanceof PkiCryptoError) return error.code;
        throw error;
    }
}

Three answers, and the third is the one people get wrong. true and false are verdicts; a PkiCryptoError is not. A signature whose bytes are malformed, whose issuer key is of the wrong family, or whose two signatureAlgorithm fields disagree is false โ€” fail closed, so a caller who forgets the catch gets "not verified" instead of an exception that some layer above may swallow into a success path.

Verification runs in Web Crypto, never in TypeScript: the key is imported from the SubjectPublicKeyInfo with extractable: false and the single usage verify, and pkinative never sees its bits. That is the whole of src/crypto/webcrypto.ts, a few hundred lines, and it is the only file in the library that may name a key operation at all.

Issue a certificate, without ever holding the key #

The job: mint a certificate or a PKCS#10 request from data you have, signed by a key your application already manages. A key that arrives as a PEM file or a .p12 becomes a SigningKey through importPrivateKey, decryptPrivateKey or openPkcs12 โ€” see Private keys and PKCS#12.

Architecture: a CertificateDescription, with validity in whole seconds, subjectPublicKey as SPKI DER and issuerDer as the issuer's own bytes, and a Signer, either a non-extractable SigningKey from importPrivateKey, decryptPrivateKey or openPkcs12 or an ExternalSigner for an HSM or KMS, flow into createCertificate. It encodes tbsCertificate and makes one signing call. Out comes certificate or PKCS#10 request DER, or a refusal such as PKI_ASN1_VALUE_OUT_OF_RANGE. Bands: the key is never bytes pkinative holds; the one exportKey line that extracts the SPKI is yours.

import { createCertificate, encodeBasicConstraints, encodeKeyUsage, type CertificateDescription, type SigningKey } from 'pkinative';

export async function issue(spki: Uint8Array, signer: SigningKey): Promise<Uint8Array> {
    // 128 random bits, forced into 0x40โ€“0x7f at the top: positive, and never
    // a redundant leading octet DER would refuse. A counter is not a serial.
    const serial = crypto.getRandomValues(new Uint8Array(16));
    serial[0] = (serial[0]! & 0x7f) | 0x40;

    // RFC 5280 ยง4.1.2.5 writes validity in whole seconds: a raw Date.now() carries milliseconds
    // the encoder refuses (PKI_ASN1_VALUE_OUT_OF_RANGE), so round first.
    const now = Math.floor(Date.now() / 1000) * 1000;
    const description: CertificateDescription = {
        serialNumber: serial,
        subject: [[{ type: '2.5.4.3', value: 'host.example' }]],
        notBefore: now,
        notAfter: now + 90 * 86_400_000,
        subjectPublicKey: spki,          // SubjectPublicKeyInfo DER, not a CryptoKey
        extensions: [
            { oid: '2.5.29.19', critical: true, value: encodeBasicConstraints({ cA: false }) },
            { oid: '2.5.29.15', critical: true, value: encodeKeyUsage(['digitalSignature']) },
        ],
    };
    return createCertificate(description, signer);
}

Validity instants are whole seconds. notBefore and notAfter are epochMilliseconds, but a certificate's UTCTime and GeneralizedTime carry no fraction (RFC 5280 ยง4.1.2.5), so createCertificate refuses a millisecond remainder with PKI_ASN1_VALUE_OUT_OF_RANGE rather than rounding silently โ€” Date.now() has one in 999 cases out of 1 000; Date.UTC(2026, 0, 1) or the rounding above does not.

subjectPublicKey is DER, and that is the point. pkinative cannot take your CryptoKey and pull the public half out of it, because exportKey is refused inside src/ in every version โ€” the same rule that refuses generateKey. So the one line that extracts it is yours, in your code, where you can see it:

const spki = new Uint8Array(await crypto.subtle.exportKey('spki', publicKey));

An API that took a CryptoKey would be one line shorter and would make the library's central security claim unverifiable. This is the trade, stated rather than hidden.

The description types are ordinary data, so a certificate can be built in one place and signed in another: CertificateDescription, CertificationRequestDescription, NameDescription, NameAttribute, ExtensionDescription and PkiBuildOptions โ€” the last carrying the same limits every other entry point takes, because an extension list assembled from attacker-supplied data is still a loop over untrusted input.

Pass the issuer's own bytes, not its name. issuerDer: root.subject.der copies the issuing certificate's encoding octet for octet. Re-describing that name and letting it round-trip through the string-type chooser will not reproduce a TeletexString, and a chain whose two names differ by one octet is a chain nothing will build. The full worked example, root and leaf and request, is recipes/create-certificate.ts.

Read a request before you sign it #

The other side of the same job: a PKCS#10 request arrives and a CA has to decide what it says, and whether whoever wrote it holds the key inside. parseCertificationRequest reads it with the certificate's own readers, codes and bounds โ€” the subject, the key, the attributes, and the extensions the extensionRequest attribute asks for โ€” and verifyCertificationRequest checks the proof of possession with the key the request carries, through the same Web Crypto door as every other signature:

import { parseCertificationRequest, verifyCertificationRequest } from 'pkinative';

const request = parseCertificationRequest(csrDer);
const report = await verifyCertificationRequest(request);
if (!report.valid) throw new Error(report.reasons.map((r) => `${r.code} at ${r.path}`).join('; '));

// What the requester asked for โ€” yours to grant or not; the CA decides the extensions.
console.log(request.subjectPublicKeyInfo.kind, request.extensions?.length ?? 0, request.challengePassword !== undefined);

A CertificationRequest is read like a certificate: a version other than 0 is PKI_X509_VERSION_INVALID, a repeated attribute or extension PKI_X509_EXTENSION_DUPLICATE, an empty extensionRequest PKI_X509_EXTENSIONS_EMPTY, and maxExtensions bounds the attributes as it bounds the extensions. The VerifyCertificationRequestReport never throws for bad bytes: malformed input is PKI_REASON_INPUT_MALFORMED, a key and algorithm that disagree (RFC 2986 ยง4.2) PKI_REASON_SIGNATURE_INVALID at certificationRequest.signatureAlgorithm, and a host that cannot check the algorithm PKI_REASON_SIGNATURE_NOT_CHECKED. A valid request proves possession of a key and nothing about who sent it; what to certify, and under which extensions, stays the CA's decision. recipes/csr.ts writes one and reads it back.

Validate a chain, and get told everything that is wrong with it #

The job: decide whether a chain of certificates leads to something you trust.

import { validateCertificatePath, verifyCertificateSignature, type Certificate } from 'pkinative';

// Check every link first: the validator takes verdicts, not keys, so the order is yours. The
// last certificate's issuer is one of your anchors when the chain stops at the intermediate,
// which is how most servers send it โ€” that link needs its verdict too.
const sameBytes = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((octet, j) => octet === b[j]);
const issuerOf = (subject: Certificate, i: number): Certificate | undefined =>
    chain[i + 1] ?? roots.find((root) => sameBytes(root.subject.der, subject.issuer.der));
const signatures = (await Promise.all(chain.map(async (subject, i) => {
    const issuer = issuerOf(subject, i);
    if (issuer === undefined) return [];   // nobody to check against: the validator says NOT_CHECKED, never "valid"
    return [{ certificate: subject, verdict: await verifyCertificateSignature(subject, issuer) ? 'valid' as const : 'invalid' as const }];
}))).flat();

const report = validateCertificatePath({ path: chain, trustAnchors: roots, at: Date.now(), signatures });
if (!report.valid) for (const reason of report.reasons) console.log(reason.code, reason.path, reason.message);

When you have a bag rather than a chain #

A TLS handshake hands over an unordered certificate_list, and cross-signing means one subject name can have several plausible issuers โ€” Let's Encrypt's own hierarchy is the everyday case. buildCertificatePath searches instead of walking:

import { buildCertificatePath } from 'pkinative';

const report = buildCertificatePath({
    leaf, candidates: whateverTheServerSent, trustAnchors: yourRoots, at: Date.now(), signatures,
});
console.log(report.valid, report.explored);

Its input is a BuildCertificatePathInput and its answer a BuildCertificatePathReport โ€” ยง6's report plus explored, the number of candidates tried. maxPathsExplored (default 1 000) is the bound that matters here, not maxChainLength: path building is exponential in the candidate set and only linear in the chain length, so a bag of thirty certificates that all name each other as issuer is a few kilobytes of input and an unbounded amount of work. A number near the bound in explored is a bag designed to be expensive, not a hierarchy.

Signature verdicts still arrive precomputed, which is the awkward part of building rather than validating: verify the pairs you have and pass them all โ€” or let verifyCertificateChain do it, which asks for a link's verdict only when the search is about to rely on it, so a bag of same-name decoys costs a verification per link tried rather than per plausible pair. A pair with no verdict is PKI_REASON_SIGNATURE_NOT_CHECKED and the path is not taken โ€” it fails closed. When no path is accepted you get the reasons from the attempt that got furthest, because "no path found" without saying why is a report nobody can act on.

Choosing which anchors to trust is yours, and so is fetching anything not already in the bag: a path builder that reached the network would be one an attacker can point at a host of their choosing.

It takes signature verdicts, not keys, and three things follow. validateCertificatePath is synchronous โ€” putting subtle.verify inside would make the whole state machine asynchronous, unfuzzable without a host, and would mix an I/O-shaped failure with a logical one. It is pure โ€” it reads data and returns a verdict, reaching nothing. And the signatures are checked before the walk, by whoever calls it.

It never throws for a validation issue. An empty chain, no trust anchor, no signature verdicts: each is an answer, in the report. Only misusing the API โ€” an unknown key in limits โ€” throws. That is the third vocabulary doing its job: primitives return and throw, compositions report.

It reports every reason, not the first. A certificate can be expired and issued by something that is not a CA, and a caller fixing one per round trip is a caller the report failed.

Two behaviours worth knowing before you wire it up:

The input is a ValidateCertificatePathInput and the answer a ValidateCertificatePathReport; maxChainLength (default 10) bounds the walk and maxPolicyNodes (default 4 096) bounds the policy tree, which is the part of ยง6 that actually explodes combinatorially; PKI_REASON_LIMIT_EXCEEDED names whichever one stopped the search. recipes/validate-path.ts runs all of this on the real Let's Encrypt hierarchy.

Certificate policies are off by default, and that is the right default. requireExplicitPolicy is what turns policy processing into a verdict: without it, a path that establishes no policy is still valid, because ยง6.1.5 (a) says the question was never asked. Turning it on by default would reject most of the public web. When you do need it, initialPolicySet is your user-initial-policy-set, and PKI_REASON_NO_VALID_POLICY is the answer when nothing survives.

What it refuses rather than ignores. RFC 5280 ยง6.1.3 (f) requires a verifier to refuse a critical extension it does not process, and the set the path validator processes is the exact boundary of what a chain may rely on: basicConstraints, keyUsage, subjectAltName, nameConstraints, certificatePolicies, policyMappings, policyConstraints, inhibitAnyPolicy and extKeyUsage. Anything else marked critical comes back PKI_REASON_UNKNOWN_CRITICAL_EXTENSION โ€” cRLDistributionPoints included: ยง6 never processes it, and checkRevocation reads it from the certificate, not from the path. That is the correct answer rather than a placeholder: a validator that ignored a constraint it had not implemented would answer "valid" for a chain the issuing CA forbade, which is the shape of a CVE rather than a missing feature.

Just tell me whether to accept this certificate #

The job: you have a certificate a server presented, a trust store, and a host name. You want one answer.

Architecture: the leaf and its candidates, your policy (trustAnchors, serverName, purposes, at) and the CRLs and OCSP responses you fetched flow into verifyCertificateChain, which runs in order: build the path with the purpose in hand, verifying a link only when the search relies on it; check the host name; check revocation by CRL and OCSP. Out comes one report, valid only when reasons is empty, every reason with code, path and message. Bands: bad input is a reason, never a throw, and a link with no verdict is never assumed valid; nothing is fetched, and revocation is soft-fail by default.

import { verifyCertificateChain, KEY_PURPOSES } from 'pkinative';

const report = await verifyCertificateChain({
    leaf,
    candidates: whateverTheServerSent,
    trustAnchors: yourRoots,
    serverName: { kind: 'dns', value: 'bank.example' },
    purposes: [KEY_PURPOSES.serverAuth],
});
if (!report.valid) for (const reason of report.reasons) console.log(reason.code, reason.path, reason.message);

Every layer below this one answers exactly one question and is deliberately blind to the others โ€” ยง6 has no notion of the host you connected to, checkServerName knows nothing about trust, revocation takes a signature verdict it did not compute. That separation is what keeps each of them synchronous, pure, fuzzable and small, and it leaves somebody with the job of putting them in the right order. If that somebody is every caller, every caller gets it slightly wrong. So it is here, once: the signatures verified as the path search needs them โ€” each link once, the moment the search is about to rely on it, and never for a pair no path tries โ€” the path built with the purpose already in hand, then the host name, then revocation. VerifyCertificateChainInput is the whole surface and VerifyCertificateChainReport adds signatureVerifications โ€” the number of signatures computed, worth logging, because a number far above the path length is what a bag full of plausible issuers looks like. Its onDiagnostic receives what the certificates, lists and responses say about themselves โ€” the RFC 6960 responderID and nocheck concerns among them โ€” without any of it moving the verdict.

Three defaults worth knowing. at is now, so omitting it asks about today. Revocation is soft-fail: with no list or response supplied nothing is claimed, and requireRevocation: true turns silence into PKI_REASON_REVOCATION_UNKNOWN โ€” the right setting wherever you can actually obtain them, and off by default because a library that refuses every chain for which the caller happened not to download one is a library callers route around. SHA-1 signatures are not evidence and come back PKI_REASON_SIGNATURE_NOT_CHECKED unless allowSha1 says otherwise. When the question is whether a certificate was good at some past instant โ€” when a document was signed โ€” do not move at to a time somebody merely claims: a verified timestamp is what proves that instant.

Revocation takes crls, ocspResponses, or both โ€” DER as you downloaded it or as a TLS server stapled it, and pkinative fetches neither. A CRL is used when it names the CA that issued the certificate, and when that CA asserts cRLSign: a CA constrained to signing certificates may not revoke them (RFC 5280 ยง4.2.1.3). An OCSP response is matched on all three CertID fields โ€” an answer about somebody else's serial is PKI_REASON_REVOCATION_MISMATCH, not a status โ€” and its signer is authorised the way RFC 6960 ยง4.2.2.2 sets out: either the issuing CA signed it, or the CA issued a certificate carrying id-kp-OCSPSigning and that certificate signed it. The certificates a response attaches are a convenience for reaching that delegate, never a claim of authority; each is checked to have been issued by this CA, to carry the purpose and to be in date before its signature counts for anything. ocspNonce and requireOcspNonce are there for replay protection, off by default because most public responders omit the echo so answers stay cacheable.

And this is the one place in src/ that turns a PkiError into a reason. The rule the library runs on is primitives return and throw, compositions report, exactly one layer converts โ€” so a malformed CRL is PKI_REASON_INPUT_MALFORMED, carrying in errorCode the code that would have been thrown. That is how a report promises never to throw for bad input without copying the encoding vocabulary into a second registry that would then have to be frozen too. What it does not do is fetch: not a CRL, not an OCSP response, not a missing intermediate. A verifier that reached the network would be one an attacker can point at a host of their choosing.

The sections below are the same questions asked one at a time, for callers who need only one of them โ€” or who want to know what this call is doing on their behalf.

Two questions a validated chain does not answer #

The job: you validated the path and it came back clean. You are not done.

RFC 5280 ยง6 asks whether a chain of certificates is internally sound and reaches something you trust. It has no notion of the host you connected to and it never reads extKeyUsage. So a chain can be perfectly valid and still be a certificate for somebody else, or a certificate for something else โ€” and both of those are how a valid certificate gets accepted where it should not be.

import { buildCertificatePath, checkServerName, checkExtendedKeyUsage, KEY_PURPOSES } from 'pkinative';

const report = buildCertificatePath({ leaf, candidates, trustAnchors, at: Date.now(), signatures });
const problems = [
    ...report.reasons,
    ...checkServerName(leaf, { kind: 'dns', value: 'bank.example' }),
    ...checkExtendedKeyUsage(report.path, KEY_PURPOSES.serverAuth),
];
if (problems.length > 0) for (const reason of problems) console.log(reason.code, reason.path, reason.message);

checkServerName is RFC 6125, written the strict way, because every relaxation has been somebody's bypass. subjectAltName wins absolutely: if the certificate carries any dNSName or iPAddress, the commonName is never consulted, and allowCommonNameFallback is off by default โ€” CA/Browser Forum BR 7.1.4.2.2 has forbidden CN-only certificates since 2017. Turned on, it also needs path, the validated path: RFC 5280 ยง6 constrains the subjectAltName and the subject, never a CN read as a host, so the fallback holds that CN to the dNSName name constraints of the CAs on the path โ€” PKI_REASON_NAME_NOT_PERMITTED or PKI_REASON_NAME_EXCLUDED, as OpenSSL refuses it โ€” and without path it refuses. A wildcard is one whole leftmost label and nothing else, it needs at least three labels, and *.example.com does not match example.com. An address is compared as octets, never as text (ServerIdentity takes Uint8Array for that reason), and the two forms never cross: 1.2.3.4 written as a dNSName is a whole class of bypass. A dns reference that spells an address (192.0.2.1, [2001:db8::1], its canonical spelling only) is read as one and compared with iPAddress entries alone, as RFC 2818 ยง3.1 asks; any other text ending in a numeric label (3221225985, 0xc0.0.2.1) matches nothing, and neither does a reference carrying *. allowWildcards: false is the right setting for an internal PKI that issues none. The same matcher is exported as matchDnsName(presented, reference, options?), for a name you hold outside a certificate; its MatchDnsNameOptions take the same allowWildcards, on by default. No public suffix list is embedded โ€” that is data which changes weekly, and a parser carrying a stale copy is worse than one that says it does not know.

When you build rather than validate, put the purpose into the search. buildCertificatePath takes purposes, and passing it is not an optimisation โ€” a builder that picks a path without knowing what the path is for will confidently return one that checkExtendedKeyUsage then condemns while an acceptable path existed. A real bag of cross-signed intermediates, some restricted to emailProtection and some not, is exactly that shape. Whatever can make a path unacceptable belongs inside the search, for the same reason name constraints are inside ยง6 rather than after it โ€” and validateCertificatePath is untouched by this, because ยง6 has no notion of purpose and searching is not ยง6.

const report = buildCertificatePath({
    leaf, candidates, trustAnchors, at: Date.now(), signatures,
    purposes: [KEY_PURPOSES.serverAuth],
});

checkExtendedKeyUsage is RFC 5280 ยง4.2.1.12, and it takes the purpose as an argument because which purpose you need is a fact about your protocol, not about the chain. One rule in it is not in the RFC: a CA's own extKeyUsage restricts what it may issue for. Every Web PKI validator enforces it and the CA/Browser Forum relies on it to constrain 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. So restrictIssuers 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. An absent extension means unrestricted, which is why almost no public root ever appears in a refusal; requireExplicitPurpose: true is the stricter reading and applies to the end entity alone. recipes/check-server-name.ts and recipes/check-purpose.ts run both on real certificates.

Ask a revocation list about one certificate #

The job: a CA published a CRL, and you want to know whether this certificate is on it.

import { findRevocation, parseCertificateList } from 'pkinative';

const crl = parseCertificateList(crlDer);
console.log(crl.issuer, crl.thisUpdate, crl.nextUpdate, crl.entryCount);

const entry = findRevocation(crlDer, certificate.serialNumber.bytes);
if (entry !== undefined) console.log('revoked', entry.reason, new Date(entry.revocationDate.epochMilliseconds));

There is no array of entries, on purpose. A CRL entry costs three ASN.1 nodes, so decoding the list into a tree hits maxNodes (200 000) at roughly 65 000 entries โ€” while maxInputBytes would allow millions, and real CRLs sit in between. So the envelope is decoded and the list is walked, one TLV header at a time, in constant memory, bounded by maxRevokedCertificates (default 1 000 000). Exposing an array would either cap the library below real-world sizes or allocate hundreds of megabytes to answer one yes-or-no. entryCount is there when you genuinely want the size, and it is counted by walking.

Serials are compared by their content octets, never by value. Two serials that differ only in a leading zero octet are two different serials to a CA, and comparing the bigint would make them one โ€” a revocation silently missed. Pass certificate.serialNumber.bytes.

findRevocation walks from the start each time, so checking many certificates against one CRL is O(nยทm). The alternative is a map keyed by serial, and that is your decision: you know how many serials you have and how much memory you will spend on them. What this library must not do is build that map behind your back for a single lookup.

crl.crlNumber and crl.isDelta are read from cRLNumber and deltaCRLIndicator; a delta CRL is not a full one, and treating it as complete would report every certificate absent from it as unrevoked. crl.issuingDistributionPoint is an IssuingDistributionPoint โ€” the ยง5.2.5 scope, what the list says it is about โ€” and the section below is what acts on it.

The envelope is a CertificateList, an entry is a RevokedCertificate carrying its reason as a CrlReason and its invalidityDate, and maxRevokedCertificates is the bound on the walk.

The decision, and the four answers it keeps apart #

findRevocation says whether a serial is on a list. It does not say whether that list was entitled to answer, or whether it is current. checkRevocation does:

import { checkRevocation, parseCertificateList, verifyCrlSignature } from 'pkinative';

const crl = parseCertificateList(crlDer);
const signatureVerified = await verifyCrlSignature(crl, caCertificate);
const reasons = checkRevocation({ certificate, crl, crlDer, at: Date.now(), signatureVerified });

It takes a signatureVerified boolean rather than a key, which is what keeps it synchronous and free of Web Crypto โ€” the same separation ยง6 makes. Its input is a CheckRevocationInput, and it returns PkiReasons:

Answer Meaning
[] Not revoked, by a list entitled to say so and current enough to believe
PKI_REASON_REVOKED Listed by a list entitled to say so โ€” this certificate's issuer's, covering it, its signature verified โ€” and the message carries the date, because a signature made before it may still be good
PKI_REASON_REVOCATION_STALE No nextUpdate, or it has passed beyond your staleTolerance
PKI_REASON_REVOCATION_WRONG_ISSUER The list names another CA, compared by encoded name
PKI_REASON_REVOCATION_OUT_OF_SCOPE The right CA's wrong list โ€” its issuingDistributionPoint excludes this certificate
PKI_REASON_REVOCATION_PARTIAL The list declares onlySomeReasons, or the certificate's distribution point names reasons, so its silence rules out only those (their intersection, ยง6.3.3 (d))
PKI_REASON_REVOCATION_UNKNOWN No evidence either way

UNKNOWN is not [], and that distinction is the point. A missing or unsigned list is an absence of evidence. Reporting it as "not revoked" would make the soft-fail decision on your behalf, invisibly. If you want soft-fail, you write it โ€” staleTolerance is the same idea for a lapsed list: a number you chose, not a default that chose for you.

A list with no nextUpdate at all is stale, not current. RFC 5280 ยง5.1.2.5 makes the field optional and tells CAs to include it; nothing asserts such a list is still good.

REVOKED is evidence, never a rumour. RFC 5280 ยง6.3.3 consults a list only once its issuer and scope are this certificate's and its signature is valid, and serial numbers are unique per issuer, so a listing on another CA's list, an out-of-scope list or a list with a critical extension nothing here processes is not reported as a revocation: the reason that disqualifies the list already fails the answer. A list whose only fault is an unverified signature is still this CA's list about this certificate, so what it names is carried as a claim in a PKI_REASON_REVOCATION_UNKNOWN, dated. A stale list that may speak still proves a revocation โ€” time does not withdraw one โ€” and says it is stale besides. checkOcspStatus follows RFC 6960 ยง3.2 the same way: a revoked answer is REVOKED only when the signature verified and the signer is authorised.

What a list is about (RFC 5280 ยง5.2.5) #

A CRL is evidence of absence: your serial is not on it. That is worth exactly what the list's declared scope says it is worth, which is why OUT_OF_SCOPE is its own answer and not a flavour of WRONG_ISSUER. A CA that publishes one list for its end-entity certificates and another for its sub-CAs marks both with issuingDistributionPoint; read the first about a sub-CA and you get a clean bill of health for a CA that may well have been revoked on the second. RFC 5280 requires the extension to be critical for that reason alone.

checkRevocation therefore decides, before anything else, whether the list may answer at all: the kind of certificate it covers, the distribution point it was published at against the one your certificate names in cRLDistributionPoints โ€” including a point named relative to the CRL issuer, composed rather than refused โ€” and, when the certificate delegates to a cRLIssuer, whether the list itself asserts indirectCRL. Without that last agreement any CA named in any cRLIssuer field could answer for certificates it never issued.

A list carrying a critical extension pkinative cannot process is refused by ยง6.3.3, in the same words ยง6.1.3 (f) uses for a certificate โ€” you get PKI_REASON_UNKNOWN_CRITICAL_EXTENSION.

Delta CRLs (RFC 5280 ยง5.2.4) #

A delta lists what changed since a complete list. Read alone it reports every certificate absent from it โ€” nearly all of them โ€” as unrevoked, so passing one as crl is refused. Pass it as delta instead, beside the base it applies over:

const reasons = checkRevocation({
    certificate, crl: base, crlDer: baseDer, at: Date.now(), signatureVerified,
    delta: { crl: newer, crlDer: newerDer, signatureVerified: deltaVerified },
});

The delta answers first, and the base only where the delta is silent. That order is the rule, which is why the pair goes into one call rather than being merged afterwards โ€” and it is the only place the removeFromCRL entry reason can mean what it means, since it appears on a delta to withdraw a revocation the base still records.

The two are paired only when the base's cRLNumber is at least the delta's baseCrlNumber โ€” otherwise everything revoked in between is invisible to both โ€” and below the delta's own, otherwise the "delta" is the older document. Both must state a cRLNumber, both must name the same issuer, and both must cover the certificate. A delta whose signature nobody vouched for is not applied at all: one removeFromCRL entry on a forged delta would withdraw any revocation on the base, which is strictly easier than forging the base itself.

verifyCertificateChain does the pairing for you โ€” hand it every list you hold, in any order.

Who may sign a list #

signatureVerified asks whether a key entitled to sign it did, and entitlement is more than a name. verifyCertificateChain requires cRLSign in the signer's keyUsage (ยง4.2.1.3); it honours the authorityKeyIdentifier a list names, which is how a CA holding several keys under one name says which of them revokes; and for a key the CA delegated the job to, it requires that certificate to be in date and not itself revoked. That last rule is what makes withdrawing a compromised CRL-signing key mean anything โ€” without it, whoever holds that key goes on publishing "nothing is revoked" until the certificate expires.

PKI_REASON_REVOCATION_PARTIAL is the one answer that adds up. A CA may publish a keyCompromise list it can reissue in minutes and a second list for everything else; between them they have answered completely. No single list can see that, so checkRevocation reports what each one ruled out and verifyCertificateChain โ€” which holds them all โ€” does the addition (ยง6.3.3's reasons_mask) and drops the reason once the union is complete.

On an indirect list, pass issuerDer โ€” the one field of FindRevocationOptions beyond the ordinary parse options โ€” to findRevocation. A serial is not an identity there: the list holds entries for several CAs, each named by the running certificateIssuer state of ยง5.3.3, and two CAs issue the same serial all the time. checkRevocation passes it for you.

Ask a responder instead of downloading a list #

The job: OCSP. One question, one answer, no megabyte of CRL.

import { createOcspRequest, parseOcspResponse, verifyOcspSignature } from 'pkinative';

// Inside your async handler: pkinative sends nothing, the fetch is yours.
const body = createOcspRequest(certificate, issuer, { nonce: crypto.getRandomValues(new Uint8Array(16)) });
const bytes = new Uint8Array(await (await fetch(url, {
    method: 'POST', headers: { 'content-type': 'application/ocsp-request' }, body: body.slice(),
})).arrayBuffer());

const response = parseOcspResponse(bytes);
if (response.status !== 'successful') return `the responder declined: ${response.status}`;
const basic = response.basicResponse!;
if (!await verifyOcspSignature(basic, responderCertificate)) return 'not from that responder';

for (const single of basic.responses) console.log(single.status.kind);   // 'good' | 'revoked' | 'unknown'

body.slice() is for the type checker, not the runtime. Since TypeScript 5.7 a Uint8Array is generic over its buffer, and the DOM types that ship with TypeScript 5.9 accept only a Uint8Array<ArrayBuffer> as a fetch body or a crypto.subtle input. pkinative's outputs are typed plain Uint8Array, whose buffer could be a SharedArrayBuffer as far as the checker knows, so under lib: ["DOM"] passing one directly fails to compile, and body.slice() returns a copy typed on an ArrayBuffer. The timestamp loop below does the same before crypto.subtle.digest.

Three states, never two. RFC 6960 ยง2.2 gives good, revoked and unknown, and unknown is the responder saying it does not know about this certificate. Reducing that to a boolean turns "I have never heard of this serial" into a clean bill of health โ€” the OCSP form of the same mistake PKI_REASON_REVOCATION_UNKNOWN names for CRLs. The six non-successful statuses are the responder declining to answer at all, and each is a reason to look elsewhere rather than a statement about any certificate. A parseOcspResponse result with a non-successful status carries no basicResponse, because the protocol carries no body there.

issuerKeyHash is over the public key bits, not the SubjectPublicKeyInfo. That is the single most common OCSP client bug: hashing the SPKI produces a request a responder answers unknown to, which a careless client then reports as not revoked. encodeOcspCertId is exported so you can recompute the same three values when matching a response back to your question.

parseOcspResponse returns an OcspResponse whose basicResponse is an OcspBasicResponse. CreateOcspRequestOptions carries hashAlgorithm โ€” an OcspHashAlgorithm, SHA-1 by default, because RFC 6960 ยง4.3 makes it mandatory for responders and a SHA-256 CertID is answered unknown by many of them โ€” and nonce, which binds the response to your request. Supply your own random bytes: pkinative generates none, and without a nonce a responder may serve a cached answer that an attacker can replay. maxOcspSingleResponses bounds how many SingleResponse entries are read.

The decision, and the substitution it catches #

parseOcspResponse reads. checkOcspStatus decides, and reports RFC 6960 ยง3.2's four client responsibilities:

import { checkOcspStatus, computeFingerprint } from 'pkinative';

const reasons = checkOcspStatus({
    response,
    expected: {
        issuerNameHash: computeFingerprint(issuer.subject.der, 'SHA-1'),
        issuerKeyHash: computeFingerprint(issuer.subjectPublicKeyInfo.publicKey.bytes, 'SHA-1'),
        serialNumber: certificate.serialNumber.bytes,
    },
    at: Date.now(),
    signatureVerified,        // you computed it
    responderAuthorized,      // your policy decided it
    signer: responderCertificate, // the certificate that verified: responderID and id-pkix-ocsp-nocheck are checked against it (RFC 6960 ยง4.2)
    nonce,
});

PKI_REASON_REVOCATION_MISMATCH is its own code, not a flavour of UNKNOWN, because the two call for different actions. UNKNOWN means ask again; a mismatch means this answer is not yours โ€” a confused responder, a cache serving somebody else's response, or an attacker substituting one. Retrying a mismatch against the same responder is the wrong move, and a caller that could not tell them apart would do it. The answer is located by matching all three CertID fields, never by taking responses[0]: a response may carry several, and taking the first is how a client reads somebody else's status as its own.

A nonce that comes back different is always a mismatch. One that does not come back at all is reported only when requireNonce asks โ€” the CA/Browser Forum discourages nonces so responses stay cacheable, and most public responders omit the echo, so which matters more is your choice rather than a default. Note that the nonce sits inside two OCTET STRINGs; comparing at the wrong layer is a check that passes on everything, and OCSP_NONCE_OID is exported so you can find the echo yourself.

A missing nextUpdate is stale by default, as it is on a CRL. RFC 6960 ยง4.2.2.1 says its absence means newer information is available all the time โ€” so the answer vouches for its thisUpdate and promises nothing after it, and read as current forever a good minted years ago would still clear a certificate today. staleTolerance is then measured from thisUpdate, which is how you accept a response you fetched a moment ago; a revoked answer is never stale, because a revocation, once published, stands. Two answers about your certificate in one response that disagree on its status are PKI_REASON_REVOCATION_UNKNOWN: the order of the SEQUENCE does not get to decide. futureTolerance (a minute by default) refuses a thisUpdate well ahead of now: clocks disagree by seconds, not hours.

Which certificate may answer for a CA is not a question this library answers. basicResponse.certificates are certificates the responder attached; trusting them because they arrived would let the responder nominate its own authority, which is exactly what RFC 6960 ยง4.2.2.2 exists to constrain. The request is unsigned, too โ€” RFC 6960 ยง4.1.2 makes that optional, almost no responder requires it, and signing would mean this library holding a key.

Sign a message, and verify one the whole way #

The job: a PDF signature, an S/MIME message, a .p7s beside a release artefact. All three are an RFC 5652 CMS SignedData, and all three ask the same question โ€” is this what its signer signed, and do I trust the signer?

Architecture: content or a contentDigest and a Signer flow into createSignedData, which writes one SignerInfo with signed attributes. The hash of the signature value goes into createTimeStampRequest; your own fetch carries it to a TSA, parseTimeStampResponse reads the answer, and addTimeStampToken attaches the token without changing a signed octet. verifySignedData with trustAnchors and atTimeStamp yields a SignerReport that keeps intact and valid apart. Bands: intact is not valid, and signingTime is never used; pkinative fetches nothing, makes no nonce, and judges the TSA at at.

import { createSignedData, verifySignedData } from 'pkinative';

const p7s = await createSignedData({ content: message, detached: true, certificate: signerCertificate }, signingKey);

const report = await verifySignedData({ signedData: p7s, content: message, trustAnchors: roots });
if (!report.valid) for (const reason of report.reasons) console.log(reason.code, reason.path, reason.message);

Writing. createSignedData takes a CreateSignedDataInput and a Signer, and returns the DER ContentInfo. It writes one SignerInfo, always with signed attributes โ€” every modern profile requires them, and they are what makes a signature over a digest possible at all: contentType and messageDigest, then signingCertificateV2 (RFC 5035) and CMSAlgorithmProtection (RFC 6211) unless you turn them off, and signingTime only when you pass one, because the PAdES baseline forbids it and S/MIME expects it. The attribute set is built in DER order before it is signed and never re-sorted afterwards. sid: 'subjectKeyIdentifier' names the key instead of the certificate, certificates and crls embed the chain and its revocation evidence verbatim, and signedAttributes/unsignedAttributes take further Attribute encodings from encodeAttribute. Mind the readers downstream: issuerAndSerialNumber, the default, is the interoperable sid โ€” libksba 1.6.7 (gpgsm 2.4.9) and pyca's pkcs7.load_der_pkcs7_certificates cannot parse a subjectKeyIdentifier signer, and gpgsm 2.4.9 refuses an Ed25519 SignerInfo โ€” on OpenSSL's output as on pkinative's, as the interoperability matrix of the conformance guide records.

Detached or attached. With content alone the content travels inside the message; detached: true leaves it out, which is how a PDF signature and most S/MIME are made. contentDigest is the PDF case proper: you hash the /ByteRange with the signer's digest yourself and never hand over the document; it implies detached. The verifier mirrors this โ€” pass content or contentDigest for a detached message, and nothing for an attached one. Absent content is never treated as empty content: a detached message verified without it is PKI_REASON_CMS_CONTENT_MISSING, and passing content for a message that carries its own throws PKI_API_MISUSE, because there would be two answers to which bytes were signed. A PDF /Contents is a zero-padded placeholder; allowTrailingData: true accepts that padding and nowhere else should it be on.

intact is not valid. Each signer's SignerReport carries both. intact is everything that needs no trust store: the attributes say what they must, the digest, signature and CMSAlgorithmProtection algorithms agree, the content hashes to the committed messageDigest โ€” computed by pkinative, never taken from the signer โ€” the signature verifies, and the key's certificate is the one the signer committed to. valid adds any timestamp and the signer's chain, judged by verifyCertificateChain. intact: true with valid: false means unaltered, but not by anybody you trust. That is why trustAnchors is required: an empty list is accepted and always yields PKI_REASON_NO_TRUST_ANCHOR, because a verifier that called a message valid without knowing whom its signer answers to would make "anyone with a key" the default signer.

The rest of VerifySignedDataInput is the chain's vocabulary: at (now by default), purposes (KEY_PURPOSES.emailProtection for S/MIME), certificates beyond those the message carries, crls and ocspResponses added to the evidence the message embeds, requireRevocation, allowSha1, encodingRules: 'ber' for a PKCS #7 producer that streams with indefinite lengths. A message is valid when it has at least one signer and every signer is valid; a SignedData with no signer โ€” a .p7b certificate bundle โ€” parses fine and verifies as PKI_REASON_CMS_NO_SIGNERS, because [].every(valid) is true and a bundle is not a signature. The VerifySignedDataReport also counts signatureVerifications, the signature verifications spent, chains and timestamps included.

Two requirements RFC 5652 does not make, and profiles do. requireSigningCertificate refuses a signer that does not name its certificate in a signing-certificate attribute, as CAdES and PAdES require: without it, anyone holding a second certificate for the same key โ€” a rollover, a cross-signature โ€” can present the signature under that one. requireAlgorithmProtection refuses a signer without CMSAlgorithmProtection, whose absence leaves digestAlgorithm and signatureAlgorithm outside the signature. Both are off by default, both come back as PKI_REASON_CMS_ATTRIBUTE_INVALID naming the attribute, and when the attributes are present they are always checked: a committed certificate that is not the one whose key verified is PKI_REASON_CMS_SIGNING_CERTIFICATE_MISMATCH, disagreeing algorithms are PKI_REASON_CMS_ALGORITHM_MISMATCH.

Keys held elsewhere. A Signer is a SigningKey โ€” a CryptoKey pkinative hands to crypto.subtle.sign and nothing else โ€” or an ExternalSigner, a function for a key in an HSM, a smart card, a cloud KMS or a remote service. produceSignature receives the exact bytes to sign, not a digest, and must return what crypto.subtle.sign would: raw r โ€– s for ECDSA, the plain octets otherwise. A DER ECDSA signature is refused with PKI_API_MISUSE rather than guessed at โ€” guessing which form arrived is how a signature gets encoded twice. A key you were given as a file โ€” PKCS#8 or a .p12 โ€” is turned into a non-extractable SigningKey as Private keys and PKCS#12 describes.

Reading without judging. parseSignedData returns a SignedData: content (undefined when detached), the certificates, crls and ocspResponses bags as DER โ€” a bag is a claim by whoever assembled the message, and parsing all of it up front would refuse a whole message over one unrelated certificate โ€” and each SignerInfo. signedAttributesDer is the exact bytes the signature covers: the transmitted [0] with its tag replaced by the SET OF tag RFC 5652 ยง5.4 signs under; nothing downstream re-derives them. The conveniences โ€” contentType, messageDigest, signingTime, signingCertificate, algorithmProtection โ€” are set only when their attribute appears exactly once with one value, because "the first one" would hide a second messageDigest, which is the shape of an attack. timeStampTokens lists every RFC 3161 token among the unsigned attributes. ParseSignedDataOptions adds allowTrailingData to the ordinary parse options.

verifySignerInfoSignature is the primitive underneath, as verifyOcspSignature is under revocation: did this certificate's key sign what this SignerInfo covers? It returns a boolean and reads nothing else โ€” no messageDigest, no sid, no chain โ€” so it is true for a signer whose content has been swapped. Build a verifier with it; never report its true as the verdict on a message. VerifySignerInfoSignatureOptions carries content, needed only for a signer without signed attributes, and allowSha1.

addUnsignedAttribute adds one unsigned attribute to one signer by surgery on the encoding: every octet of the SignerInfo but its unsignedAttrs is copied as it is, so every signed octet โ€” and every signature โ€” survives. A parse-and-rebuild would re-sort a set some other signer never sorted.

The three vocabularies, applied to CMS. They answer different questions here as everywhere else:

Vocabulary Codes Raised by Means
Thrown PkiCmsError PKI_CMS_STRUCTURE_INVALID, PKI_CMS_CONTENT_TYPE_UNEXPECTED, PKI_CMS_VERSION_UNSUPPORTED, PKI_CMS_CONTENT_NOT_OCTET_STRING parseSignedData, the TSP readers, addUnsignedAttribute The DER is well formed but is not the RFC 5652 or RFC 3161 structure it was read as; path and offset say where
Emitted diagnostics PKI_DIAG_CMS_VERSION_MISMATCH, PKI_DIAG_CMS_SET_NOT_SORTED, PKI_DIAG_CMS_SIGNED_ATTRIBUTES_NOT_DER, PKI_DIAG_CMS_DIGEST_ALGORITHM_NOT_LISTED parseSignedData, through onDiagnostic and SignedData.diagnostics The structure deviates from the profile in a way no signature depends on
Returned reasons PKI_REASON_CMS_*, PKI_REASON_TSP_*, plus every chain reason under signerInfos[i].chain verifySignedData, verifyTimeStampToken The message is readable, and the judgement is no

PkiCmsErrorCode is the union a PkiCmsError narrows code to. verifySignedData never throws for bad input โ€” bytes that are not a SignedData are PKI_REASON_INPUT_MALFORMED, carrying in errorCode the code that would have been thrown โ€” and reads quietly, so the diagnostics are parseSignedData's to show. It throws only for a call that is wrong: content given twice, an unknown limits key, a certificate parseCertificate did not make. maxSignerInfos and maxCmsCertificatesAndCrls bound what one message may cost, and maxAttributes each attribute list.

recipes/sign-message.ts runs all of this, from the detached signature to the three vocabularies; recipes/external-signer.ts signs a pre-computed PDF digest through an ExternalSigner.

Prove when it was signed #

The job: a signature has to outlive its certificate. A signing certificate lives a year or two; a contract, an invoice or a PDF must still verify after that. An RFC 3161 timestamp over the signature proves it existed before the certificate expired โ€” CAdES-T, PAdES B-T.

import { addTimeStampToken, createTimeStampRequest, parseSignedData, parseTimeStampResponse, verifySignedData } from 'pkinative';

// Inside your async handler: pkinative sends nothing, the fetch is yours.
const signature = parseSignedData(p7s).signerInfos[0]!.signature;
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', signature.slice()));
const nonce = new DataView(crypto.getRandomValues(new Uint8Array(8)).buffer).getBigUint64(0);
const body = createTimeStampRequest(hash, { nonce });
const response = parseTimeStampResponse(new Uint8Array(await (await fetch(tsaUrl, {
    method: 'POST', headers: { 'content-type': 'application/timestamp-query' }, body: body.slice(),
})).arrayBuffer()));
if (response.tokenDer === undefined) return `the TSA declined: ${response.status} ${response.failInfo.join(',')}`;

const stamped = addTimeStampToken(p7s, 0, response.tokenDer);
const report = await verifySignedData({ signedData: stamped, content: signedContent, trustAnchors: roots, atTimeStamp: true });

What is stamped is the hash of the signature value (RFC 3161 Appendix A) โ€” the signature octets of the SignerInfo, not the document and not the signed attributes. createTimeStampRequest takes that hash, never the data, and a CreateTimeStampRequestOptions: hashAlgorithm (SHA-256 by default; SHA-384 and SHA-512 offered, SHA-1 not, because a collision would let one token cover two documents), nonce, policy, and certReq โ€” true by default, which is not the grammar's default: with certReq false the TSA must leave its certificate out, and the verifier must then be given it through certificates. Supply a nonce of at least 64 random bits: without one, nothing says a response answers this request, and a replayed answer is indistinguishable from a fresh one. pkinative generates none.

Reading the answer. parseTimeStampResponse returns a TimeStampResponse: status, the TSA's statusStrings and failInfo (badAlg, unacceptedPolicy, โ€ฆ), and โ€” only when granted โ€” tokenDer, the bytes to attach, and token, a TimeStampToken. A token is a SignedData whose content is a TstInfo, and the type keeps them apart on purpose: who signed is token.signedData, what was stamped and when is token.tstInfo โ€” messageImprint, genTime, accuracy, the echoed nonce, policy, serialNumber, and tsa, a name that is a hint, never an identity. parseTimeStampToken reads a token already attached to a signature, and parseTstInfo reads the TSTInfo DER alone. Hand either reader a SignedData over anything but a TSTInfo and it throws PKI_CMS_CONTENT_TYPE_UNEXPECTED.

Judging the token. verifyTimeStampToken makes every check RFC 3161 ยง2.4.2 lists, in one call, and returns a VerifyTimeStampTokenReport. Its VerifyTimeStampTokenInput must say what was stamped โ€” request, data or imprint โ€” or it throws PKI_API_MISUSE, because a check that never asked of what would confirm that some hash existed at some time, which is true of every token ever issued. The timestamp itself goes in as exactly one of token or response: the latter is the whole TimeStampResp the TSA sent over HTTP, and a response that granted nothing comes back as PKI_REASON_TSP_NOT_GRANTED, carrying the TSA's status, its text and its failInfo, rather than as a malformed input. request is the strongest of the three: the token must stamp the same imprint, echo the same nonce as the same integer and carry the policy asked for, and it is the only one that catches a replay (PKI_REASON_TSP_REQUEST_MISMATCH). A token over anything else is PKI_REASON_TSP_IMPRINT_MISMATCH; one with a second signer, or a tsa name its certificate does not hold, is PKI_REASON_TSP_TOKEN_INVALID. The signing-certificate attribute RFC 5816 makes mandatory is always required of a token, as requireSigningCertificate would require it of a message, and its absence is PKI_REASON_CMS_ATTRIBUTE_INVALID. The TSA's certificate must carry an extKeyUsage that is present, critical, and names timeStamping alone (ยง2.3); allowNonCriticalTimeStampingEku is the documented escape for deployed TSAs that did not mark it critical, off by default. genTime must fall inside that certificate's validity, and the certificate must chain to your trustAnchors. The report's genTime, earliest and latest โ€” genTime minus and plus the declared accuracy โ€” are filled in only when the token is valid.

A response that was not granted carries no token, so there is nothing to verify: branch on status before you reach for tokenDer.

at or atTimeStamp โ€” when is the signer judged? Without a timestamp, verifySignedData judges each signer's chain at at, which is now by default: a signer whose certificate expired yesterday is PKI_REASON_EXPIRED today, however long ago it signed. atTimeStamp: true judges each signer's chain at the time its own verified timestamp proves โ€” the earliest latest among its valid tokens, because the true time may be as late as genTime plus the accuracy, and a certificate that expired inside that window is not proved to have been valid. That is the long-term validation question: was the certificate good when this was signed? A signer with no valid timestamp is still judged at at, and the signer's own signingTime is never used: nothing vouches for it but the signer, which is why SignerReport.signingTime is reported as a claim.

The timestamp authority is always judged at at, never at the genTime its token asserts. A timestamp is proof of time only while its TSA is trusted. Judging the TSA's chain at the time its own token claims would let a TSA key compromised after its certificate expired backdate tokens that would then be believed โ€” the attacker picks the genTime. So once the TSA's certificate has expired too, the stamped signature is PKI_REASON_EXPIRED under signerInfos[0].unsignedAttrs.timeStampToken[0].token.tsaChain, and it is also expired under signerInfos[0].chain, because the only proof of time no longer counts. TSA certificates are long-lived for this reason; carrying the proof beyond one needs an archive timestamp (PAdES B-LTA), which pkinative does not implement (ADR 0009). Calling verifyTimeStampToken directly with at set to the token's genTime is possible, and is a deliberate decision to trust the time the token itself asserts.

A timestamp present on a signer and invalid makes the signer invalid, not merely unstamped: the message is claiming a time it cannot prove. addTimeStampToken attaches a token as id-aa-signatureTimeStampToken through addUnsignedAttribute, so every signed octet is unchanged; a SignerInfo's timeStampTokens lists what is already there.

recipes/timestamp.ts runs the whole loop without a network โ€” it plays the TSA with createSignedData over a TSTInfo built from the public encoders โ€” including the replayed answer, the signer judged at the proved time, and the TSA judged after its own expiry.

Private keys and PKCS#12 #

The job: somebody handed you the key to sign with โ€” a PRIVATE KEY or ENCRYPTED PRIVATE KEY PEM file, or a .p12/.pfx and its password โ€” and createCertificate, createCertificationRequest or createSignedData needs a SigningKey.

Architecture: a PKCS#8 key, an encrypted PKCS#8 key, or a .p12 file and its password flow into openPkcs12 or the primitives under it, importPrivateKey and decryptPrivateKey. They parse without a password, verify only an RFC 9579 PBMAC1 MAC and otherwise fail closed, open PBES2 alone, and take each key's algorithm from the certificate sharing its localKeyId. The host's Web Crypto unwraps the key into a non-extractable CryptoKey. Out come an OpenPkcs12Report with keys and certificates, or a refusal by name. Bands: the key never becomes bytes pkinative holds; legacy schemes are refused.

import { createSignedData, openPkcs12 } from 'pkinative';

// An rsaEncryption key needs its scheme named: that certificate does not say it, and pkinative does not guess
// (an id-RSASSA-PSS certificate does โ€” see below).
const report = await openPkcs12(p12Bytes, { password, rsaAlgorithm: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } });
if (!report.valid) throw new Error(report.reasons.map((r) => `${r.code} at ${r.path}`).join('; '));
const [{ signingKey, certificate }] = report.keys;
const p7s = await createSignedData({ content: message, certificate: certificate! }, signingKey!);

The key never becomes bytes pkinative holds. Every call in this section returns a SigningKey whose CryptoKey is extractable: false with the single usage sign: it signs, and it cannot give its bits back to anyone. An unencrypted PKCS#8 is your own buffer and goes to the host exactly as it came. An encrypted one โ€” a PKCS#8 under PBES2, or a PKCS#12 pkcs8ShroudedKeyBag, which is how OpenSSL and Windows store the key โ€” is decrypted by the host, with unwrapKey, straight into that handle: the decrypted PKCS#8 is never a JavaScript value, so no code of pkinative's, and none of yours, can log it, copy it or leave it in a buffer. exportKey is refused inside src/ in every version, so nothing here can turn the handle back into bytes either. The one case outside this is a writer that puts a plain keyBag inside an encrypted SafeContents: that SafeContents is opened with decrypt, and the key it holds is then as much in the clear as an unencrypted PKCS#8 you read from disk โ€” openPkcs12 wipes those bytes as soon as Web Crypto has imported them, a best effort, since the engine may already hold a copy.

That is why the algorithm must be known before decrypting. A key's type is inside the ciphertext, and the host has to be told what it is unwrapping before it unwraps it โ€” pkinative never looks inside first. So:

Describe before you decrypt. parsePrivateKeyInfo returns a PrivateKeyInfo โ€” version, the algorithm, kind (rsa, rsa-pss, ec, ed25519, ed448 or unknown), curve (the same EcCurve a public key reports: P-256, P-384, P-521 or undefined), the attributes and, for a version 1 key, publicKey โ€” and no field holding the private key: the octets are checked for shape and never copied out, because a second convenient name for a secret is how it ends up in a log. (der is still your whole input, key included.) parseEncryptedPrivateKeyInfo returns an EncryptedPrivateKeyInfo whose encryption.scheme says in words what protects the key โ€” PBES2 (PBKDF2 with HMAC-SHA-256, AES-256-CBC) โ€” and whose encryption.pbes2 carries the salt, iteration count, PRF, AES size and IV, or is undefined for a scheme pkinative refuses. A refused scheme still parses, so a tool can say what a file is protected with, and why it will not open it, before anyone types a password.

PBES2 only, by policy. pkinative opens PBES2 with PBKDF2 (HMAC-SHA-1, -256, -384 or -512) and AES-128, -192 or -256 in CBC mode โ€” what OpenSSL 1.1 and later write by default โ€” and refuses every other scheme by name with PKI_KEY_ENCRYPTION_UNSUPPORTED: the RFC 7292 Appendix C schemes (pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd40BitRC2-CBC and the rest), PBES1, and PBES2 with another cipher. The PKCS#12 schemes derive their key with the RFC 7292 Appendix B KDF, iterated hashing with byte arithmetic over the password, and protect it with 3DES or 40-bit RC2; implementing them would put secret-dependent code in TypeScript, which is the one thing this library exists without. It is a refusal, not a backlog โ€” the full table of OIDs is in SECURITY.md. A legacy file converts once, with OpenSSL 3.4 or later, in two commands verified against OpenSSL 3.5.5, 3.6.4 and 4.0.0:

openssl pkcs12 -in legacy.p12 -legacy -aes256 -out bundle.pem
openssl pkcs12 -export -in bundle.pem -pbmac1_pbkdf2 -out modern.p12

-aes256 keeps the key in bundle.pem under AES-256-CBC and the passphrase you give, where -legacy alone would pick 3DES; the export is PBES2 with AES-256-CBC by default, and bundle.pem is yours to delete afterwards. A key alone converts with openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256.

Why most MACs cannot be verified, and what allowUnverifiedIntegrity trades. A PKCS#12 MAC is how a reader knows the file was not altered by someone without the password. Every MAC OpenSSL wrote before 3.4, and most it still writes, is keyed with the same Appendix B KDF โ€” so pkinative can verify only an RFC 9579 PBMAC1 MAC, which is what -pbmac1_pbkdf2 writes. openPkcs12 fails closed: a file whose integrity cannot be checked โ€” an Appendix B MAC, no MAC at all, a PBMAC1 the host cannot run, or public-key integrity mode โ€” is still read and its contents reported, but valid is false with PKI_REASON_PKCS12_INTEGRITY_UNVERIFIED. OpenPkcs12Options's allowUnverifiedIntegrity: true accepts it, and it is a decision about the file's origin: without a MAC, any bag that is not encrypted โ€” a certificate, a CRL, a plain key โ€” can be replaced by anyone who can write the file, and the certificate you would then sign under is theirs. Pass it only for a file you trust by other means. The OpenPkcs12Report's integrity says what was established either way: verified, unverified, or mismatch โ€” a PBMAC1 that does not match, a wrong password most likely, after which nothing is decrypted and the only reason is PKI_REASON_PKCS12_MAC_MISMATCH.

The password is UTF-8. A string is encoded as UTF-8, which is what OpenSSL and RFC 9579 use under PBES2 and PBMAC1. A file written with another encoding opens with the exact octets as a Uint8Array, which is used as given and never modified โ€” it is yours to wipe. A string with a lone surrogate has no UTF-8 form and is refused with PKI_API_MISUSE rather than silently replaced. An empty string is a password; undefined is not, and openPkcs12 throws PKI_INVALID_OPTION for it.

Windows writes BER. A .pfx exported by Windows uses indefinite lengths and segmented OCTET STRINGs; pass encodingRules: 'ber' to openPkcs12 or parsePkcs12 for it. Without it, the strict DER reader refuses the file, and openPkcs12 reports that as PKI_REASON_INPUT_MALFORMED carrying the encoding code.

The report, and what it never does. openPkcs12 resolves with the MAC checked where it can be, every SafeContents opened, every certificate parsed (certificates, the keys' own and any chain), every CRL bag's DER (crls), and keys โ€” one Pkcs12Key per private key, in encoded order, with its path, localKeyId, friendlyName, matching certificate and signingKey, which is undefined when that key could not be opened. It never rejects for a problem with the file: one malformed certificate or one key under a refused scheme is a reason, and the rest is still read. It throws only for a call that could never succeed โ€” no Web Crypto (PKI_CRYPTO_UNAVAILABLE), a missing password or a bad option, an unknown limits key, an input that is not a Uint8Array. Guard with canDecrypt(), which says whether globalThis.crypto.subtle offers every operation PBES2 and RFC 9579 need โ€” not which ciphers: several browsers that answer true lack AES-192, and a file using it comes back as PKI_REASON_PKCS12_ENCRYPTION_UNSUPPORTED. It reads quietly: nothing is printed, and the container's diagnostics are on report.pkcs12.diagnostics.

The primitives underneath. parsePkcs12 needs no password: it returns a Pkcs12 whose contents are SafeContentsInfo entries โ€” encrypted, the encryption scheme and ciphertext, or the bags already read when the SafeContents is plain โ€” and whose mac says kind: 'pbmac1' or kind: 'pkcs12-kdf', or is undefined. verifyPkcs12Mac(pkcs12, password) checks a PBMAC1 and returns true or false, the two failures being indistinguishable; it throws PKI_KEY_MAC_UNSUPPORTED for an Appendix B MAC and PKI_API_MISUSE for a file with none, so check pkcs12.mac first. openSafeContents(contents, password) returns the SafeBags of one entry, decrypting it when it is PBES2-encrypted; each bag has its kind, friendlyName, localKeyId, and certificateDer, crlDer, encryptedKey or privateKey by kind, and a path such as authSafe[1].bags[0]. Nested safeContentsBags are flattened, and every bag counts against maxPkcs12Bags. The layer never parses a certificate for you โ€” certificateDer goes to parseCertificate โ€” and decryptPrivateKey(bag.encryptedKey.der, { password, algorithm }) opens a key. Public-key privacy mode (envelopedData) is reported as encrypted with no password scheme and refused by openSafeContents; public-key integrity mode is refused by parsePkcs12 with PKI_KEY_MAC_UNSUPPORTED.

The file declares its own cost. The PBKDF2 iteration count is the file's to declare and the host's to run, inside Web Crypto where no JavaScript bound reaches, so maxKdfIterations (10 000 000, about ten seconds of SHA-256) is checked before the host is asked for anything: a file declaring 2ยณยน iterations is PKI_LIMIT_EXCEEDED โ€” thrown by the primitives, reported by openPkcs12 as PKI_REASON_INPUT_MALFORMED carrying that code โ€” and not a frozen process. A file may declare thousands of derivations, so maxPkcs12KdfIterations (also 10 000 000) bounds what one PKCS#12 costs in total โ€” its PBMAC1 MAC, every encrypted SafeContents and every shrouded key โ€” checked by parsePkcs12 over what it can see, by openSafeContents before it derives and over the keys it reveals, and by openPkcs12 before each derivation it runs. A count below the 1 000 RFC 8018 recommends still opens, with PKI_DIAG_KEY_KDF_ITERATIONS_LOW: the count is what a stolen copy costs to brute-force.

The three vocabularies, applied to keys.

Vocabulary Codes Raised by Means
Thrown PkiKeyError PKI_KEY_STRUCTURE_INVALID, PKI_KEY_VERSION_UNSUPPORTED, PKI_KEY_ENCRYPTION_UNSUPPORTED, PKI_KEY_MAC_UNSUPPORTED the parsers, importPrivateKey, decryptPrivateKey, verifyPkcs12Mac, openSafeContents The structure is not RFC 5958 or RFC 7292, or it is protected by something pkinative refuses; path and offset say where, and the message names the conversion
Thrown PkiCryptoError PKI_CRYPTO_DECRYPTION_FAILED decryptPrivateKey, openSafeContents The file was readable and the host could not decrypt it: the password, the data, or the algorithm named
Emitted diagnostic PKI_DIAG_KEY_KDF_ITERATIONS_LOW the parsers, through onDiagnostic and diagnostics A PBKDF2 count below 1 000; the file still opens
Returned reasons PKI_REASON_PKCS12_INTEGRITY_UNVERIFIED, PKI_REASON_PKCS12_MAC_MISMATCH, PKI_REASON_PKCS12_ENCRYPTION_UNSUPPORTED, PKI_REASON_PKCS12_DECRYPTION_FAILED, PKI_REASON_PKCS12_KEY_UNMATCHED, PKI_REASON_PKCS12_KEY_UNSUPPORTED, PKI_REASON_PKCS12_RSA_SCHEME_UNSPECIFIED openPkcs12 The file is readable, and something in it could not be opened or trusted

A PkiKeyError never means a wrong password โ€” only the host can find that out, and it says so as a PkiCryptoError. PkiKeyErrorCode is the union a PkiKeyError narrows code to.

recipes/private-key.ts reads, imports and decrypts PKCS#8 keys written by node:crypto, names the RSA algorithm, and refuses a 3DES and an Appendix C key by name; recipes/pkcs12.ts writes a PBMAC1 .p12, opens it in one call and with the primitives, signs with the key, and reads the unverifiable, the wrong-password and the legacy cases apart.

What none of these do #

Each of the cases above is complete. What is not here is deliberate, and the choosing guide says what to use instead:

You need pkinative Instead
Open a legacy PKCS#12 or PKCS#8 โ€” RFC 7292 Appendix C ciphers, PBES1, an Appendix B MAC checked never, by design: the Appendix B KDF is iterated hashing with byte arithmetic over the password, secret-dependent code this library will not write openssl pkcs12 -in legacy.p12 -legacy -aes256 -out bundle.pem, then openssl pkcs12 -export -in bundle.pem -pbmac1_pbkdf2 -out modern.p12, once
Decide that a key is too small or a curve unacceptable never, by design one comparison on the parsed subjectPublicKeyInfo; that floor moves by CA/Browser Forum ballot and does not belong frozen in a library
A public suffix list, so that *.co.uk is refused never, by design a maintained PSL package; a parsing library carrying a stale copy is worse than one that says it does not know

Three separate questions, and you need all three. A fingerprint proves two byte strings are the same certificate. A verified signature proves one key signed one set of bytes. A validated path proves a chain reaches something you trust โ€” and still says nothing about which host the certificate is for or what it may be used for, which is why checkServerName and checkExtendedKeyUsage are separate calls rather than options. pkinative does not collapse them, and that distinction is the whole of the security model.

How well the path validator actually agrees with the world is measured rather than asserted: every case of the x509-limbo corpus is scored on each release, every disagreement carries a written reason in scripts/data/limbo-score.json, and a reviewed subset is pinned on its PkiReasonCode โ€” because rejected for the wrong reason is a defect no pass/fail count can see.