# pkinative > Zero-dependency TypeScript toolkit for public-key infrastructure data: strict ASN.1 DER (BER on request), RFC 7468 PEM, object identifiers and complete RFC 5280 X.509 certificate parsing with every standard extension, CWE-tagged resource limits, stable error codes and a conformance gate over x509-limbo, NIST PKITS, Wycheproof and the text of RFC 5280. One API for every runtime with Web Crypto: tested in CI on Node.js 22 (Linux, Windows, macOS) and 24 (Linux), with Deno, Bun and headless Chromium smoke tests and an advisory run on Node.js 26; other Web Crypto runtimes such as Workers are expected to work, not tested in CI. It installs from npm: `npm install pkinative`. Beyond reading, it verifies and creates signatures through Web Crypto, reads, verifies and builds PKCS#10 certification requests, validates RFC 5280 §6 paths with CRL and OCSP revocation, reads, builds and verifies CMS SignedData and RFC 3161 timestamps, hashes public data (SHA-1/256/384/512, SHAKE256), and opens PKCS#8 keys and PKCS#12 files (PBES2 only) into non-extractable signing keys. Six RFC requirement inventories (5280, 5652, 3161, 6960, 7292, 7468) hold it sentence by sentence to the pinned texts. pkinative never implements secret-dependent cryptography in TypeScript; signing and verification are one call to Web Crypto with the caller's key. Every failure is a PkiError subclass with a stable `code`; profile concerns are diagnostics (`onDiagnostic`, `strict: true`, `certificate.diagnostics`). There is no PEM-to-certificate shortcut: compose `decodePem` and `parseCertificate`. ## Start here - [Agent brief](https://pkinative.dev/agent-brief.md): what to import, what to catch, what not to write - [Quick start](https://pkinative.dev/guides/quickstart.md): read a certificate, extensions, diagnostics, errors, limits - [API manifest](https://pkinative.dev/assets/api.json): every public export with module, signature, summary and thrown error classes ## Guides - [Use cases](https://pkinative.dev/guides/use-cases.md): inventory, pinning, chains, revocation, CMS signatures (`createSignedData`, `verifySignedData`) RFC 3161 timestamps (`verifyTimeStampToken`, `addTimeStampToken`, `atTimeStamp`), and private keys and PKCS#12 (`openPkcs12`, `decryptPrivateKey`, `allowUnverifiedIntegrity`) - [Security model](https://pkinative.dev/guides/security.md): strict DER, the twenty-two limits, typed failures, the cryptography boundary - [Conformance](https://pkinative.dev/guides/conformance.md): x509-limbo, NIST PKITS, Wycheproof, RFC 5280 clause by clause, OpenSSL differential - [Standards](https://pkinative.dev/guides/standards.md): every ISO/IEC, ITU-T and IETF standard pkinative touches, its evidence and its gaps — a self-assessment, not a certification - [Errors, diagnostics and reasons](https://pkinative.dev/guides/errors.md): every error, reason and diagnostic code with its cause and remedy - [CLI reference](https://pkinative.dev/guides/cli.md): pkinative-cli — every command, flag, exit code and error class of the `pkinative` command, and its `--json` agent contract - [CLI use cases](https://pkinative.dev/guides/use-cases-cli.md): a CI chain-and-expiry gate, offline revocation, signing and time-stamping a release, auditing a PKCS#12 - [MCP server](https://pkinative.dev/guides/mcp.md): pkinative-mcp — client configuration, the 34 tools, seven prompts, resources, error and security models - [MCP use cases](https://pkinative.dev/guides/use-cases-mcp.md): an agent triaging a failing chain, issuing a test certificate, offline revocation, opening keys safely - [Choosing a library and a surface](https://pkinative.dev/guides/choose.md): when to use pkinative, when another tool is the right one, and library, CLI or MCP server for each capability ## Data - [Error registry](https://pkinative.dev/data/errors.json): code, class, cause, remedy, standard, CWE - [Diagnostic registry](https://pkinative.dev/data/diagnostics.json): code, severity, cause, remedy, standard - [Limits](https://pkinative.dev/data/limits.json): the named resource bounds with defaults and CWE - [Capabilities](https://pkinative.dev/data/surfaces.json): what pkinative does, the exports, CLI command lines and MCP tools that do it, since when - [CLI surface](https://pkinative.dev/data/cli-surface.json): the pkinative-cli commands, flags, exit codes and error classes, from its own capability manifest - [Ecosystem](https://pkinative.dev/assets/ecosystem.json): versions, milestones, corpus pins and counts ## Ecosystem pkinative is the engine; two published satellites, each its own repository and npm package pinning `pkinative ^1.0.0`, expose it without code. Both run offline and carry pkinative's `PKI_*` codes verbatim. - [pkinative-cli](https://github.com/Nizoka/pkinative-cli): `npm install --global pkinative-cli`, binary `pkinative` — 18 commands, 53 invocations, `--json` envelope (exit 0 / 1 / 2), 13 error classes, JSON Schemas (`pkinative schema`); agent contract: https://github.com/Nizoka/pkinative-cli/blob/main/docs/AGENT_CONTRACT.md - [pkinative-mcp](https://github.com/Nizoka/pkinative-mcp): `npx -y pkinative-mcp`, registry name `io.github.Nizoka/pkinative-mcp` — 34 tools, seven prompts, `pkinative://output/{+path}` resources, stdio and Streamable HTTP; agent contract: https://github.com/Nizoka/pkinative-mcp/blob/main/docs/AGENT_CONTRACT.md - [Playgrounds](https://pkinative.dev/playgrounds/): the library in a browser tab, a CLI command builder and an MCP tool explorer ## Optional - [Full documentation](https://pkinative.dev/llms-full.txt): this file, the README and every guide in one text - [Executable recipes](https://pkinative.dev/llms-recipes.txt): every recipe, CI-tested - [Documentation index](https://pkinative.dev/llms-index.json): artefacts and guides with sizes and anchors - [Source](https://github.com/Nizoka/pkinative): repository, AGENTS.md, CHANGELOG.md, ROADMAP.md --- # pkinative **Read, verify and build the certificates your software trusts — strictly, safely, on every runtime, without a single dependency.** [![npm: pkinative](https://img.shields.io/npm/v/pkinative?label=pkinative)](https://www.npmjs.com/package/pkinative) [![npm: pkinative-cli](https://img.shields.io/npm/v/pkinative-cli?label=pkinative-cli)](https://www.npmjs.com/package/pkinative-cli) [![npm: pkinative-mcp](https://img.shields.io/npm/v/pkinative-mcp?label=pkinative-mcp)](https://www.npmjs.com/package/pkinative-mcp) ![Zero runtime dependencies](https://img.shields.io/badge/dependencies-0-brightgreen) ![TypeScript strict mode](https://img.shields.io/badge/TypeScript-strict-blue) ![Conformance: x509-limbo, Wycheproof and NIST PKITS](https://img.shields.io/badge/conformance-x509--limbo%20%2B%20Wycheproof%20%2B%20NIST%20PKITS-blueviolet) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) Zero runtime dependencies. 100% TypeScript. One API for every runtime with Web Crypto: tested in CI on Node.js 22 on Linux, Windows and macOS and on Node.js 24 on Linux, with a Deno, a Bun and a headless Chromium smoke test, and on Node.js 26 (Current) in an advisory run until its LTS date; other Web Crypto runtimes, such as Cloudflare Workers, are expected to work and are not tested in CI. The third library of the *native* family, under the engineering doctrine of [pdfnative](https://github.com/Nizoka/pdfnative) and [zipnative](https://github.com/Nizoka/zipnative). > **Status: 1.0 — stable, on npm.** The public API, the error codes and the reason codes follow semantic versioning: a minor release only adds, and a removal or an incompatible change waits for the next major ([ROADMAP.md](ROADMAP.md), [SECURITY.md §Compatibility promise](SECURITY.md#compatibility-promise)). No version below 1.0.0 was tagged or released: the 0.x milestones are history in [CHANGELOG.md](CHANGELOG.md) and `release-notes/`, and `0.0.1` on npm is the name reservation, not a release. Its two satellites are published too: [pkinative-cli](https://github.com/Nizoka/pkinative-cli), the `pkinative` command line, and [pkinative-mcp](https://github.com/Nizoka/pkinative-mcp), a Model Context Protocol server for AI assistants ([Ecosystem](#ecosystem)). pkinative reads certificates and verifies them the whole way: signatures, RFC 5280 §6 paths, CRL and OCSP revocation, host names and key purposes. It reads, builds and verifies CMS SignedData and RFC 3161 timestamps, opens PKCS#8 keys and PKCS#12 files into non-extractable Web Crypto keys, and creates certificates and certification requests — with Web Crypto doing every signature. ## Why pkinative? The JavaScript ecosystem parses certificates with node-forge, pkijs, asn1js and @peculiar/x509 — tens of millions of weekly downloads between them, pre-ES2015 code or dependency chains, and a history of ASN.1 parser advisories. pkinative starts from the other end: - **Strict by default.** DER's length, tag, constructed-string, BOOLEAN, INTEGER and BIT STRING rules (X.690 §8, §10, §11.1–11.2.1) are refused, not guessed at: two encodings of one value is an ambiguity an attacker can exploit. The departures from §11.2.2, §11.5 and §11.6 that real issuers commit — trailing zero bits in a named bit list, an explicit DEFAULT, an unsorted SET OF — are diagnostics, refused under `strict: true`. BER is an explicit option. - **Safe on hostile input.** Every loop runs under a named, CWE-tagged, caller-configurable limit; nesting is iterative; every failure is a typed error with a stable code — never a `TypeError`. - **Complete.** Every RFC 5280 extension decoded to its ASN.1 module, every GeneralName form, every DirectoryString type, RFC 4514 names, RSA, EC, EdDSA, XDH and ML-DSA keys. - **Honest about profiles.** What real issuers get wrong — a 21-octet serial, an explicit DEFAULT, a non-critical name constraint — is a diagnostic with its RFC section, not a crash and not silence. - **Verdicts that explain themselves.** A chain, a signed message, a timestamp or a PKCS#12 file comes back as a report carrying every reason it was refused, each a stable code with its clause, never an exception for a problem with the input. - **No cryptography it should not own.** pkinative never implements signing, key generation or arithmetic on secret material; every signature is created and verified through Web Crypto, with your key. - **Held to external corpora.** A blocking conformance gate runs x509-limbo, Wycheproof and NIST PKITS, pinned by commit and checksum, holds the parser to the pinned text of RFC 5280 clause by clause, and cross-checks the serial number, validity, CA flag and fingerprint of every parsed certificate against OpenSSL. - **Agent-pilotable.** Machine-readable error codes, a diagnostics channel, [`llms.txt`](llms.txt), a generated [API manifest](docs/assets/api.json), and a human-in-the-loop AI governance policy. Beyond the library, the MCP server gives an assistant 34 typed tools and seven prompts, and the CLI a `--json` contract whose 13 error classes carry pkinative's codes verbatim. ## How it compares Registry facts only, read on 2026-09-19 ([docs/data/comparison-2026-09-19.json](docs/data/comparison-2026-09-19.json)). | Library | Latest | Runtime dependencies | Types bundled | ES modules | Scope | |---|---|---|---|---|---| | **pkinative** | 1.0 (npm) | **0** | yes | yes | ASN.1, PEM, OIDs, X.509 and PKCS#10 requests, path validation with CRL and OCSP, CMS and RFC 3161, PKCS#8 and PKCS#12 under PBES2; every signature through Web Crypto | | node-forge | 1.4.0 | 0 | no | no | Broad: ASN.1, X.509, TLS, its own RSA and ciphers in JavaScript | | asn1js | 3.0.10 | 3 | yes | yes | ASN.1 BER/DER codec | | @peculiar/x509 | 2.1.0 | 11 | yes | yes | X.509 over Web Crypto, on the @peculiar/asn1 schema stack | | pkijs | 3.4.0 | 6 | yes | yes | Broad PKI over asn1js and Web Crypto: X.509, CRL, OCSP, CMS, timestamps | | jsrsasign | 11.1.5 | 0 | no | no | Broad: ASN.1, X.509, JWS, its own RSA and ECDSA in JavaScript | | micro509 | 0.14.0 | 0 | yes | yes | Small X.509 toolkit, pre-1.0 | Choose pkinative for strict, dependency-free PKI with every refusal explained by a stable code; choose another tool for what it will never do — legacy PKCS#12, DSA, network fetching, key generation — each a recorded decision ([choose guide](docs/guides/choose.md)). ## Installation pkinative is on npm, published by [publish.yml](.github/workflows/publish.yml) from the tagged commit, after the full gate, with npm provenance: ```bash npm install pkinative npm audit signatures # optional: verify the registry signatures and provenance of what you installed ``` Every GitHub release also carries that same tarball, fetched back from the registry, and three SBOMs — CycloneDX and SPDX for the package, CycloneDX for the toolchain that built it — all attested with Sigstore build provenance: ```bash npm install https://github.com/Nizoka/pkinative/releases/download/v1.0.0/pkinative-1.0.0.tgz gh attestation verify pkinative-1.0.0.tgz --repo Nizoka/pkinative # optional: check where it was built ``` A plain git install (`github:Nizoka/pkinative#v1.0.0`) does not work: `dist/` is not committed. Every runtime runs the same build; the package has `browser`, `import` and `require` conditions and no runtime dependency. From a shell, or from an AI assistant, without writing code — each satellite is its own package, pinning `pkinative ^1.0.0`: ```bash npm install --global pkinative-cli # the `pkinative` command; then `pkinative doctor` npx -y pkinative-mcp # the MCP server, over stdio; configure your client as the MCP guide shows ``` ## Quick start Read every certificate of a PEM text and print what a person checks first: ```ts 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; } ``` This block is [recipes/quick-start.ts](recipes/quick-start.ts), executed on every test run against the letsencrypt.org certificate. The [quick start guide](docs/guides/quickstart.md) goes further: extensions, diagnostics, `strict`, limits and errors; the [use cases](docs/guides/use-cases.md) go the whole way, from a chain verdict to a timestamped signature. Every public export, with its signature and the errors it throws, is listed in [docs/assets/api.json](docs/assets/api.json). ## What you get | Area | Exports | |---|---| | The one-call verdict | `verifyCertificateChain` — path building, RFC 5280 §6 validation, host name, key purpose and revocation in one report that lists every reason at once ([use cases](docs/guides/use-cases.md#just-tell-me-whether-to-accept-this-certificate)) | | Certificates | `parseCertificate`, `getExtension`, `decodeExtensionValue`, `formatDistinguishedName` — every RFC 5280 field and standard extension | | Certification requests | `parseCertificationRequest` (PKCS#10, a `CertificationRequest`: subject, key, attributes and the extensions it asks for, read with the certificate's own readers), `verifyCertificationRequest` — the proof of possession checked with the key inside the request, as a `VerifyCertificationRequestReport` that never throws for bad bytes (`VerifyCertificationRequestOptions` for `allowSha1` and the reading options) | | Signature verification | `verifyCertificateSignature`, `verifySelfSignature`, `canVerify` — one signature against one issuer key, through Web Crypto; `PkiCryptoError` when it could not be checked, never when it failed | | Creation | `createCertificate`, `createCertificationRequest` (PKCS#10), `canSign` — signed by a Web Crypto key or an `ExternalSigner`; the structural encoders `encodeDistinguishedName`, `encodeExtensions`, `encodeSubjectAltName`, `encodeKeyUsage`, `KEY_USAGE_BITS`, `encodeSubjectPublicKeyInfo` and the rest | | Paths | `buildCertificatePath`, `validateCertificatePath` — RFC 5280 §6 with name constraints and the policy tree | | Host names and purposes | `checkServerName`, `matchDnsName` (RFC 6125), `checkExtendedKeyUsage`, `KEY_PURPOSES`, `ANY_EXTENDED_KEY_USAGE` | | Revocation | CRLs: `parseCertificateList`, `findRevocation`, `verifyCrlSignature`, `checkRevocation` (delta lists and scopes included); OCSP: `createOcspRequest`, `encodeOcspCertId`, `parseOcspResponse`, `verifyOcspSignature`, `checkOcspStatus`, `OCSP_NONCE_OID` — you fetch, pkinative judges | | CMS and timestamps | `createSignedData` (a Web Crypto key or an `ExternalSigner` such as an HSM), `verifySignedData`, `parseSignedData`, `verifySignerInfoSignature`, `addUnsignedAttribute`; RFC 3161 `createTimeStampRequest`, `parseTimeStampResponse`, `parseTimeStampToken`, `parseTstInfo`, `verifyTimeStampToken`, `addTimeStampToken`; `PkiCmsError` ([use cases](docs/guides/use-cases.md#sign-a-message-and-verify-one-the-whole-way)) | | Private keys and PKCS#12 | `openPkcs12` (one call: MAC, SafeContents, certificates, keys), `parsePkcs12`, `verifyPkcs12Mac`, `openSafeContents`; PKCS#8 `parsePrivateKeyInfo`, `parseEncryptedPrivateKeyInfo`, `importPrivateKey`, `decryptPrivateKey` — keys unwrapped into non-extractable Web Crypto handles, PBES2 only; `canDecrypt`, `PkiKeyError` ([use cases](docs/guides/use-cases.md#private-keys-and-pkcs12)) | | PEM | `decodePem` (strict or lax RFC 7468, optionally restricted to one label), `encodePem` | | ASN.1 | `decodeAsn1`, `decodeAsn1Sequence`; the typed readers `readBoolean`, `readInteger`, `readSmallInteger`, `readEnumerated`, `readNull`, `readBitString`, `readOctetString`, `readObjectIdentifier`, `readRelativeOid`, `readString` (eight string types) and `readTime` (both time types); DER encoders (`encodeSequence`, `encodeInteger`, `encodeRelativeOid`, `encodeTlv`, …); `encodeAsn1Node` (byte-identical re-encoding) | | OIDs | `encodeOid`, `decodeOid`, `isValidOid`, `getOidName` over a registry of 300+ names | | Fingerprints, key identifiers and hashing | `computeFingerprint`, `computeFingerprintAsync` (Web Crypto), `formatFingerprint`, `computeKeyIdentifier`; `shake256` (FIPS 202, over public data — the digest an Ed448 CMS signer uses) | | Errors and limits | `PkiError` and its six subclasses — `PkiEncodingError`, `PkiCertificateError`, `PkiLimitError`, `PkiCryptoError`, `PkiCmsError`, `PkiKeyError` — each with a stable `code` ([error guide](docs/guides/errors.md)); `DEFAULT_PKI_LIMITS` | pkinative has 294 public exports. There is deliberately no PEM-to-certificate shortcut: `decodePem` and `parseCertificate` compose, as Go's `encoding/pem` and `crypto/x509` do, so the certificate parser carries no PEM code ([recipes/pem-bundle.ts](recipes/pem-bundle.ts)). ## Security model Every certificate, PEM text and DER blob is attacker-controlled. Twenty-two named limits (`maxDepth`, `maxNodes`, `maxExtensions`, …) bound every loop, each with its CWE; structural failures throw a `PkiError` subclass with a stable `code`, conformance concerns go to a diagnostics channel (`onDiagnostic`, or `strict: true` to refuse them), and a judgement — a chain, a message, a timestamp, a key file — returns its reasons in a report. No `eval`, no I/O, no dynamic import in the engine, and no secret-dependent cryptography in TypeScript. Details: [SECURITY.md](SECURITY.md) and the [security guide](docs/guides/security.md). ## Conformance A blocking gate ([conformance guide](docs/guides/conformance.md)) runs the built package over corpora pinned by commit and SHA-256, levels L0 to L8: - **x509-limbo** — all 30 361 unique x509-limbo certificates parse, or are refused only where every limbo case using them expects failure; 565 certificates refused, each held to a reviewed baseline. Every certificate re-encodes byte for byte, every parsed one agrees with OpenSSL on serial, validity, CA flag and fingerprint, and every limbo path-validation case is scored against the verdict the corpus expects. - **RFC 5280, clause by clause** — the requirement sentences of §4.1 and §4.2 in the pinned RFC text, each held by a clause pkinative must diagnose or excluded with a written reason. - **NIST PKITS** — the 405 certificates and 173 CRLs all parse; 195 of the 203 scored paths agree with NIST, and the 224 signed S/MIME messages are verified whole, each verdict equal to its signer's path. Every disagreement is a reviewed, written deviation. - **Wycheproof** — all 1 530 Wycheproof ECDSA vectors on P-256, P-384 and P-521: every valid signature decodes, every encoding defect is refused. What this is and is not evidence of, standard by standard, is the [standards self-assessment](docs/guides/standards.md). ## Known limitations - **No external security audit** ([ADR 0010](docs/adr/0010-no-external-security-audit-at-1-0.md)). What stands in its place — the adversarial release audit, the conformance gate, 100 % coverage, the seeded adversarial suites — is listed in [SECURITY.md](SECURITY.md#in-place-of-an-external-audit). - **Coverage-guided fuzzing has not run on GitHub yet.** The ClusterFuzzLite workflow is in place; its first run needs the published repository ([SECURITY.md](SECURITY.md#verification-of-the-parser)). The seeded adversarial suites run in every gate. - **An `id-RSASSA-PSS` private key under PBES2 depends on the host**: a `pkcs8ShroudedKeyBag` or an encrypted PKCS#8 is unwrapped by Web Crypto and never handed back to re-wrap, so on Node.js 22 it stays `PKI_REASON_PKCS12_KEY_UNSUPPORTED` or `PKI_CRYPTO_KEY_UNSUPPORTED` — re-export it under `rsaEncryption` with the tool that holds it. Public keys, unencrypted PKCS#8 and plain `keyBag`s under such a certificate verify and open everywhere. - **No built-in trust store, no fetching.** The caller supplies the trust anchors, and the CRLs and OCSP responses a check needs: pkinative does no network I/O ([ADR 0006](docs/adr/0006-no-network-io-in-the-engine.md)). - **No OCSP-request or timestamp-request reader.** `createOcspRequest` and `createTimeStampRequest` write them; no public function parses one. A PKCS#10 request is read by `parseCertificationRequest` and judged by `verifyCertificationRequest`. - **No CRL or OCSP-response writer.** pkinative reads and verifies both, and writes neither. - **ML-DSA signatures are not verified**, though ML-DSA keys are read; **DSA signers are not verified** ([ADR 0004](docs/adr/0004-dsa-and-ed448-cms-signers-not-verified.md)); **an Ed448 CMS signer is verified where the host verifies Ed448** (Node.js) and reported `PKI_REASON_SIGNATURE_NOT_CHECKED` where it does not (Bun, Chromium) ([ADR 0022](docs/adr/0022-ed448-cms-signers-host-dependent.md)). - **Internationalized names are not converted**: a non-ASCII octet in an IA5String name is refused, not guessed, and IDNA is not applied. No public suffix list is embedded: a wildcard needs three labels, and registry-level policy is the caller's. - **X.520 attribute syntaxes are diagnosed, not enforced, when reading a name**: a `countryName` that is not a two-character PrintableString, or an `emailAddress` that is not an IA5String, is read with a `warning` (refused under `strict: true`); a value past its RFC 5280 Appendix A.1 upper bound, or a country code outside ISO 3166-1, is read as it is with an `info` (`PKI_DIAG_NAME_ATTRIBUTE_TOO_LONG`, `PKI_DIAG_NAME_COUNTRY_UNKNOWN`). TeletexString is read as Latin-1, with a diagnostic. - **The Certificate Transparency SCT list** is kept in its TLS encoding, neither decoded nor verified. - **The SHA implementations** are synchronous TypeScript over public data; `computeFingerprintAsync` uses Web Crypto when the host has it. - **Attribute certificates** (ITU-T X.509 §12 and RFC 5755) are not read; the scope and priority are determined when a consumer requests one. - **Refusals by design** — PKCS#12 and PKCS#8 under PBES2 only, no DSA verification, no network fetching, no key generation or export, no PKCS#8 or PKCS#12 writer, no ETSI long-term (B-LTA) signature formats — are recorded decisions, listed below. ## What pkinative will NOT do No runtime dependency. No TypeScript implementation of signing, key generation or modular arithmetic on secrets. No filesystem, network or process access inside the engine. No lenient decoder that silently accepts what the standard forbids. No PEM parsing inside the certificate parser. The larger refusals, and why each is a decision rather than a gap, are recorded as architecture decision records in [docs/adr/](docs/adr/README.md). ## Ecosystem | Package | Purpose | Status | |---|---|---| | [`pkinative`](https://www.npmjs.com/package/pkinative) | the engine (this repository) — zero runtime dependencies, every runtime with Web Crypto | 1.0.0 | | [`pkinative-cli`](https://github.com/Nizoka/pkinative-cli) | command line (`npm install --global pkinative-cli`, binary `pkinative`) — 18 commands, 53 invocations, a `--json` envelope whose 13 error classes carry pkinative's `PKI_*` codes verbatim, JSON Schemas, shell completion; the filesystem and the process the engine refuses to own ([guide](docs/guides/cli.md)) | 1.0.0 | | [`pkinative-mcp`](https://github.com/Nizoka/pkinative-mcp) | Model Context Protocol server (`npx -y pkinative-mcp`) — 34 tools, seven prompts, sandboxed file resources, stdio and Streamable HTTP; no network call and no key byte returned, ever ([guide](docs/guides/mcp.md)) | 1.0.0 | Both satellites pin `pkinative ^1.0.0` in their `dependencies`, vendor its registries at the tag, and implement no cryptography of their own — beyond the engine, they call `node:crypto` only for randomness — serials, nonces and, in the CLI, the temporary file behind `--overwrite` — and the public key of a plain PKCS#8 signer and, in the MCP server, for SHA-256 — the digest of each returned artefact and the cache keys — and a constant-time token check. The engine stays dependency-free by exiling the shell and the protocol to separate repositories — the pdfnative ecosystem pattern. [docs/guides/choose.md](docs/guides/choose.md#choosing-your-surface) says which surface fits which job; versions and inventories are recorded once, in [docs/assets/ecosystem.json](docs/assets/ecosystem.json), and policed by `npm run verify:docs`. The [playgrounds](https://pkinative.dev/playgrounds/) run the library in a browser tab and build CLI command lines and MCP tool calls. The *native* family: - [pdfnative](https://github.com/Nizoka/pdfnative) — the mother project; its PAdES and LTV signature stack is where pkinative comes from, and adopting pkinative there is pdfnative's own milestone. - [zipnative](https://github.com/Nizoka/zipnative) — the sibling whose error vocabulary, limits and conformance-gate patterns pkinative inherits. ## Development ```bash npm ci --ignore-scripts npm run gate:fast # typecheck, lint, tests, documentation checks npm run gate # the CI profile npm run conformance:fetch && npx tsx scripts/gate.ts --publish --require-all ``` [CONTRIBUTING.md](CONTRIBUTING.md) has the conventions and the release procedure; [AGENTS.md](AGENTS.md) is the brief for AI coding agents, who work under a human-in-the-loop policy ([.github/AGENT_RULES.md](.github/AGENT_RULES.md)). ## Origin pkinative grew out of the X.509 and CMS code pdfnative wrote for PDF signatures. An audit found that code useful but not reusable as it stood — a signed-shift length bug, recursion without a bound, extensions silently lost after an `issuerUniqueID`, and pure-JavaScript RSA and ECDSA that are not constant-time. pkinative keeps the ideas, rewrites the parser, and leaves the secret-dependent arithmetic to Web Crypto. ## License [MIT](LICENSE) © Nizoka — Plika --- # Quick start > **Read a certificate in three calls, then learn the four things every pkinative call shares: strict DER, typed errors, diagnostics and limits.** Everything below runs on pkinative 1.0 as it is tested; every export named here is in `docs/assets/api.json`. ## Install pkinative is on npm, published with provenance from the tagged commit after the full gate: ```bash npm install pkinative ``` `npm audit signatures` verifies the registry signature and the provenance of what you installed. Every GitHub release also carries the tarball, attested with Sigstore build provenance: `npm install https://github.com/Nizoka/pkinative/releases/download/v1.0.0/pkinative-1.0.0.tgz`, and `gh attestation verify pkinative-1.0.0.tgz --repo Nizoka/pkinative` checks where it was built. Every runtime loads the same build, and there is no runtime dependency. CI tests it on Node.js 22 on Linux, Windows and macOS and on Node.js 24 on Linux, with a Deno, a Bun and a headless Chromium smoke test, and on Node.js 26 (Current) in an advisory run until its LTS date; other Web Crypto runtimes, such as Cloudflare Workers, are expected to work and are not tested in CI. ## Read a certificate A certificate usually arrives as PEM text. `decodePem` returns its blocks, and the `label` option refuses any block that is not a certificate — a private key pasted into the same file is an error, not a certificate: ```ts import { decodePem, formatDistinguishedName, getExtension, parseCertificate } from 'pkinative'; const [block] = decodePem(pemText, { label: 'CERTIFICATE' }); const cert = parseCertificate(block.bytes); formatDistinguishedName(cert.subject); // 'CN=letsencrypt.org' cert.serialNumber.hex; // '0543933be386b3a2b3add534f2103bc8b65f' new Date(cert.validity.notAfter.epochMilliseconds); getExtension(cert, 'subjectAltName')?.names; // [{ kind: 'dNSName', value: 'cp.letsencrypt.org', … }, …] getExtension(cert, 'basicConstraints')?.cA; // false ``` `parseCertificate` takes DER bytes. The result is frozen and holds zero-copy views of your input, so do not mutate the bytes while you use it (pass `bytes.slice()` to decouple). Times are `epochMilliseconds` numbers, exact from year 0000 to 9999. ## What a certificate holds Every field is listed with its type in `docs/assets/api.json` (the `members` of `Certificate` and of each extension interface); the ones you reach for first: | Field | Holds | |---|---| | `der`, `tbsDer` | The whole certificate, and the `tbsCertificate` bytes its signature covers | | `version` | `1`, `2` or `3` (the encoded INTEGER is 0, 1 or 2) | | `serialNumber` | `{ bytes, hex, value }` — the content octets, their lowercase hex, and a `bigint` | | `signatureAlgorithm`, `tbsSignatureAlgorithm` | `{ oid, parameters, der }`; RFC 5280 requires the two to be equal (a diagnostic says when they are not) | | `signatureValue` | `{ bytes, unusedBits }` — the signature; `verifyCertificateSignature(cert, issuer)` checks it | | `issuer`, `subject` | `{ rdns, der }`: each RDN a list of `{ type, value, valueDer }`, `value` a `{ stringType, value, raw }` string when the attribute is one | | `validity` | `{ notBefore, notAfter }`, each `{ type, epochMilliseconds, text }` | | `subjectPublicKeyInfo` | `{ kind, algorithm, publicKey, der, … }`, by `kind`: `rsa` and `rsa-pss` add `modulus`, `modulusBits`, `publicExponent`; `ec` adds `namedCurve`, `curve` (`P-256`, `P-384`, `P-521` or `undefined`), `pointFormat`, `point`; `ed25519`, `ed448`, `x25519`, `x448`, `ml-dsa-44`, `ml-dsa-65`, `ml-dsa-87` add `key`; `unknown` adds nothing | | `issuerUniqueId`, `subjectUniqueId` | `{ bytes, unusedBits }` or `undefined` | | `extensions`, `diagnostics` | In encoded order, below | The fields of the extensions `getExtension` returns: | Kind | Fields | |---|---| | `basicConstraints` | `cA`, `pathLenConstraint` (`undefined` when absent) | | `keyUsage` | `usages` (`digitalSignature`, `nonRepudiation`, `keyEncipherment`, `dataEncipherment`, `keyAgreement`, `keyCertSign`, `cRLSign`, `encipherOnly`, `decipherOnly`), `bits` | | `extendedKeyUsage` | `purposes`: KeyPurposeId OIDs, e.g. `1.3.6.1.5.5.7.3.1` (serverAuth) | | `subjectAltName`, `issuerAltName` | `names`: GeneralNames, by `kind` — `dNSName`, `rfc822Name`, `uniformResourceIdentifier` with `value`; `iPAddress` with `version`, `address`, `mask`, `bytes`; `directoryName` with `name`; `otherName` with `typeId`, `value`; `registeredID` with `oid`; `x400Address`, `ediPartyName` with `value` | | `subjectKeyIdentifier` | `keyIdentifier` | | `authorityKeyIdentifier` | `keyIdentifier`, `authorityCertIssuer`, `authorityCertSerialNumber` | | `authorityInfoAccess`, `subjectInfoAccess` | `descriptions`: `{ accessMethod, accessLocation }` — `1.3.6.1.5.5.7.48.1` is OCSP, `1.3.6.1.5.5.7.48.2` CA issuers, the location a GeneralName (usually a `uniformResourceIdentifier`) | | `crlDistributionPoints`, `freshestCRL` | `points`: `{ fullName, nameRelativeToCRLIssuer, reasons, cRLIssuer }` | | `certificatePolicies` | `policies`: `{ policyIdentifier, qualifiers }`, a qualifier `cps` with `uri`, `userNotice` with `noticeRef` and `explicitText`, or `unknown` | | `policyMappings` | `mappings`: `{ issuerDomainPolicy, subjectDomainPolicy }` | | `policyConstraints` | `requireExplicitPolicy`, `inhibitPolicyMapping` | | `inhibitAnyPolicy` | `skipCerts` | | `nameConstraints` | `permittedSubtrees`, `excludedSubtrees`: `{ base, minimum, maximum }`, `base` a GeneralName (an `iPAddress` base carries its `mask`) | | `signedCertificateTimestampList` | `list`: the RFC 6962 SCT list in its TLS encoding | | `ocspNoCheck` | nothing beyond `oid`, `critical`, `valueDer` | | `subjectDirectoryAttributes` | `attributes`: `{ oid, values }` — the X.501 attributes of RFC 5280 §4.2.1.8 (RFC 3739 §3.2.2 identification data), values as DER | ## Every extension, typed `cert.extensions` lists every extension in encoded order, each with `oid`, `critical` and `valueDer`, and a `kind` that says how it was decoded: `basicConstraints`, `keyUsage`, `extendedKeyUsage`, `subjectAltName`, `issuerAltName`, `subjectDirectoryAttributes`, `subjectKeyIdentifier`, `authorityKeyIdentifier`, `nameConstraints`, `certificatePolicies`, `policyMappings`, `policyConstraints`, `inhibitAnyPolicy`, `authorityInfoAccess`, `subjectInfoAccess`, `crlDistributionPoints`, `freshestCRL`, `signedCertificateTimestampList`, `ocspNoCheck` — or `unknown` for an extension pkinative does not decode. `getExtension(cert, kind)` returns the one of that kind, typed. An inspection tool that must show a certificate even when one extension is malformed parses with `decodeExtensions: false` — every extension stays `kind: 'raw'` — and decodes what it needs with `decodeExtensionValue(oid, valueDer, { critical })`; `critical` (default `false`) is reported on the result and decides whether an unknown extension raises `PKI_DIAG_UNKNOWN_CRITICAL_EXTENSION`. `computeFingerprint(der, algorithm)` takes `'SHA-1'`, `'SHA-256'`, `'SHA-384'` or `'SHA-512'` (anything else is `PKI_INVALID_OPTION`); `formatFingerprint(digest, { separator, letterCase })` writes `96:BC:EC:…` by default — `separator` defaults to `':'`, `letterCase` to `'upper'` (or `'lower'`), so `{ separator: '', letterCase: 'lower' }` gives the pinning form. ## Diagnostics: what real issuers get wrong A structural violation throws; a profile violation that real issuers commit is a diagnostic. Diagnostics are recorded on `cert.diagnostics`, each with a `code`, the RFC section it cites, a `path` and an `offset`: ```ts const cert = parseCertificate(der, { onDiagnostic: (d) => log(d.code, d.path, d.standard) }); cert.diagnostics; // the same list, in order ``` By default each code is also written to `console.warn`, once per code in each call; `onDiagnostic` replaces that, and `strict: true` turns the first `warning` diagnostic — a MUST the input broke — into a thrown `PkiError` with code `PKI_STRICT_DIAGNOSTIC`, and still reports every `info` — a SHOULD it did not follow — the choice for a verifier that accepts only clean certificates without refusing the Web PKI's own profile. ## Errors: branch on the code Every failure is a `PkiError` subclass with a stable `code`, a message that starts with `pkinative: ` and names the remedy, and — for certificates — the `path` and `offset` of the offending field: ```ts import { parseCertificate, PkiCertificateError, PkiError, PkiLimitError } from 'pkinative'; try { parseCertificate(der); } catch (error) { if (error instanceof PkiLimitError) console.error(error.limit, error.configured, error.observed); else if (error instanceof PkiCertificateError) console.error(error.code, error.path, error.offset); else if (error instanceof PkiError) console.error(error.code); else throw error; } ``` Never branch on the message. The [error guide](errors.md) lists every code with its cause and remedy. ## Limits Twenty-two named limits bound every loop over untrusted bytes. Tighten one for a context that expects small inputs, raise one only for input you trust: ```ts parseCertificate(der, { limits: { maxExtensions: 32, maxGeneralNames: 100 } }); ``` The [security guide](security.md) lists them with their defaults and CWE. ## Below the certificate: ASN.1 and OIDs The layers the certificate parser stands on are public too. `decodeAsn1` decodes one DER value (BER with `encodingRules: 'ber'`) into frozen nodes that keep their exact bytes, and refuses anything after that value unless you pass `allowTrailingData: true`; typed readers turn a node into a value; the encoders emit DER only and refuse what DER cannot represent; `encodeAsn1Node` re-encodes a decoded tree to the exact input bytes: ```ts import { decodeAsn1, encodeInteger, encodeObjectIdentifier, encodeSequence, getOidName, readInteger, readObjectIdentifier } from 'pkinative'; const der = encodeSequence([encodeObjectIdentifier('2.5.29.17'), encodeInteger(65537n)]); const [oidNode, integerNode] = decodeAsn1(der).children; readObjectIdentifier(oidNode); // '2.5.29.17' getOidName('2.5.29.17'); // 'subjectAltName' readInteger(integerNode); // 65537n ``` A node is `{ tagClass, tagNumber, constructed, offset, headerLength, contentLength, indefinite, bytes, content, children }`: `tagClass` is `'universal'`, `'application'`, `'context'` or `'private'`, `bytes` the whole encoding and `content` the content octets, both views of the input. A reader accepts its universal tag or any implicit (non-universal) tag, which it reads as its own type; `readString` and `readTime` need the type of an implicit tag — `readString(node, { stringType: 'ia5' })`, `readTime(node, { timeType: 'GeneralizedTime' })` — and throw `PKI_API_MISUSE` without it. The readers are `readBoolean`, `readInteger` (a bigint), `readSmallInteger` (a number), `readNull`, `readBitString`, `readOctetString`, `readObjectIdentifier`, `readString` (UTF8String, NumericString, PrintableString, TeletexString, IA5String, VisibleString, UniversalString, BMPString) and `readTime` (UTCTime and GeneralizedTime); `readEnumerated` and `readRelativeOid` read ENUMERATED and RELATIVE-OID (X.690 §8.4, §8.20); REAL has no typed reader: read its `content` octets. For OIDs, `encodeOid` and `decodeOid` convert between dotted text and content octets, `isValidOid` checks a string, and `getOidName` names 300+ registered OIDs. `decodePem` returns blocks of `{ label, bytes, headers, offset }` — `headers` holds the RFC 1421 `[name, value]` pairs lax mode reads; it takes an optional `label` that every block must carry, and `mode: 'lax'` accepts whitespace around and inside the block, long lines and RFC 1421 headers, each reported once per call as a diagnostic. `OID_REGISTRY` entries are `{ oid, name, standard }`. ## Types you write Most of what pkinative returns you never name: you reach it through a return value, and its fields are in `docs/assets/api.json`. These are the ones you may have to write down — in a signature, a variable annotation or an options object. - **Certificate**: `DistinguishedName` (`subject`, `issuer`), `PkiTime` (both ends of `validity`), `Extension` (the union `certificate.extensions` holds) and `DecodedExtensionKind` (the argument `getExtension` takes). - **ASN.1**: `Asn1Node` (what `decodeAsn1` returns and every reader takes), `TagClass` (`node.tagClass`, and the first argument of `encodeTlv`), `Asn1String` and `Asn1StringType` (what `readString` returns, and the eight types it can read), `BitString` (`readBitString`, `encodeBitString`, and `certificate.signatureValue`). - **PEM**: `PemBlock` — what `decodePem` yields; `encodePem(label, bytes)` writes one back from its two parts. - **Fingerprints**: `FingerprintAlgorithm` — `'SHA-1'`, `'SHA-256'`, `'SHA-384'` or `'SHA-512'`. - **Options**, one per function, each extending `PkiParseOptions` (which carries `limits`, `strict` and `onDiagnostic`): `ParseCertificateOptions`, `DecodeExtensionValueOptions`, `DecodeAsn1Options`, `DecodePemOptions`, `ReadStringOptions`, `ReadTimeOptions`; and `FormatFingerprintOptions` (`separator`, `letterCase`), which parses nothing and extends nothing. Error code unions are in the [errors guide](errors.md). ## Next - [recipes/](../../recipes/) — 22 executable recipes: the quick start, the agent brief, a CA bundle, fingerprints, extensions on demand, ASN.1 and OIDs, the ASN.1 primitives, hostile input, signature verification, certificate and CSR creation, reading and judging a CSR, path validation, the one-call verification, server-name matching, extended key usage, revocation lists, OCSP, CMS signing and verification, an external signer, RFC 3161 timestamps, PKCS#8 private keys, PKCS#12 files. - [Use cases](use-cases.md) — the jobs end to end, from a certificate inventory to a timestamped signature and the `.p12` holding its key. - [Conformance](conformance.md) — how pkinative is held to x509-limbo, NIST PKITS, Wycheproof, the text of RFC 5280 and OpenSSL. - [Standards](standards.md) — every ISO/IEC, ITU-T and IETF standard pkinative touches, what holds each, and the gaps: a tooled self-assessment, not a certification. - [Choosing a library and a surface](choose.md) — when pkinative is the right tool, when another one is, and whether to call it as a library, a CLI or an MCP server. --- # 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.](../assets/use-case-decision.svg) 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.](../assets/use-case-inventory-intake.svg) ```ts 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](https://datatracker.ietf.org/doc/html/rfc4514), 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. ```ts 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`](../../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](../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](errors.md) 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.](../assets/use-case-certificate-anatomy.svg) ```ts 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. ```ts 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. ## Check one link of a chain 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. ```ts 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](https://www.w3.org/TR/WebCryptoAPI/), 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`](../../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](#private-keys-and-pkcs12). ![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.](../assets/use-case-issue-without-key.svg) ```ts import { createCertificate, encodeBasicConstraints, encodeKeyUsage, type CertificateDescription, type SigningKey } from 'pkinative'; export async function issue(spki: Uint8Array, signer: SigningKey): Promise { // 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: ```ts 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`](../assets/api.json), [`CertificationRequestDescription`](../assets/api.json), [`NameDescription`](../assets/api.json), [`NameAttribute`](../assets/api.json), [`ExtensionDescription`](../assets/api.json) and [`PkiBuildOptions`](../assets/api.json) — 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`](../../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: ```ts 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`](../assets/api.json) 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`](../assets/api.json) 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`](../../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. ```ts 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: ```ts 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`](../assets/api.json) and its answer a [`BuildCertificatePathReport`](../assets/api.json) — §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](errors.md#three-vocabularies-three-questions) 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 trust anchor does not have to be in the chain.** RFC 5280 §6.1.1 takes it as a separate input, so a chain that stops at the intermediate is anchored when that intermediate names a root you supplied. Most servers send it exactly that way. - **A certificate with no signature verdict is `PKI_REASON_SIGNATURE_NOT_CHECKED`, never assumed valid.** A validator that read silence as success would pass whenever the caller forgot to verify anything. And `NOT_CHECKED` is not `INVALID`: a runtime without Ed448 says nothing about whether a signature is good. The input is a [`ValidateCertificatePathInput`](../assets/api.json) and the answer a [`ValidateCertificatePathReport`](../assets/api.json); `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.](../assets/use-case-verify-chain.svg) ```ts 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](#prove-when-it-was-signed). **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. ```ts 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`](../assets/api.json) 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. ```ts 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. ```ts 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`](../assets/api.json) — the §5.2.5 scope, what the list says it is about — and [the section below](#what-a-list-is-about-rfc-5280-525) is what acts on it. The envelope is a [`CertificateList`](../assets/api.json), an entry is a [`RevokedCertificate`](../assets/api.json) carrying its `reason` as a [`CrlReason`](../assets/api.json) 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: ```ts 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`](../assets/api.json), and it returns `PkiReason`s: | 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: ```ts 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`](../assets/api.json) 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. ```ts 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` 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`](../assets/api.json) whose `basicResponse` is an [`OcspBasicResponse`](../assets/api.json). `CreateOcspRequestOptions` carries `hashAlgorithm` — an [`OcspHashAlgorithm`](../assets/api.json), 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: ```ts 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.](../assets/use-case-sign-timestamp.svg) ```ts 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`](../assets/api.json) 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](conformance.md) 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`](../assets/api.json) 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`](../assets/api.json) 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`](../assets/api.json) 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](#private-keys-and-pkcs12) describes. **Reading without judging.** `parseSignedData` returns a [`SignedData`](../assets/api.json): `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`](../assets/api.json). `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`](../assets/api.json) 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`](../assets/api.json) 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. ```ts 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`](../assets/api.json): `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`](../assets/api.json): `status`, the TSA's `statusStrings` and `failInfo` (`badAlg`, `unacceptedPolicy`, …), and — only when granted — `tokenDer`, the bytes to attach, and `token`, a [`TimeStampToken`](../assets/api.json). A token is a `SignedData` whose content is a [`TstInfo`](../assets/api.json), 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`](../assets/api.json). Its [`VerifyTimeStampTokenInput`](../assets/api.json) **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](../adr/0009-no-etsi-long-term-signature-formats.md)). 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.](../assets/use-case-keys-pkcs12.svg) ```ts 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: - `decryptPrivateKey(der, { password, algorithm })` requires `algorithm` ([`DecryptPrivateKeyOptions`](../assets/api.json)). Name the wrong one and the host refuses to unwrap, which surfaces as `PKI_CRYPTO_DECRYPTION_FAILED` — AES-CBC carries no authentication tag, so a wrong password, altered bytes and a key that is not the algorithm named are one error, not three. - `openPkcs12` takes the algorithm from **the certificate that shares the key's `localKeyId`**: ECDSA on the certificate's curve with that curve's customary digest (P-256 with SHA-256, P-384 with SHA-384, P-521 with SHA-512), Ed25519, Ed448; an `id-RSASSA-PSS` certificate names the scheme, so a plain `keyBag` under it opens with `RSA-PSS` and the digest its parameters carry — without parameters `rsaAlgorithm` must name `RSA-PSS`, and a `pkcs8ShroudedKeyBag` holding such a key still depends on the host, which unwraps it and never hands the bytes back to re-wrap (`PKI_REASON_PKCS12_KEY_UNSUPPORTED` on Node.js 22). A certificate says "RSA" and not how the key will sign, and a Web Crypto key is bound to one scheme and one hash, so `rsaAlgorithm` decides, and there is no default: without it an RSA key stays shut with `PKI_REASON_PKCS12_RSA_SCHEME_UNSPECIFIED`, the rest of the file is still reported, and the next call names it — `{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }`, the scheme of nearly every RSA signing certificate in use, or `RSA-PSS`, over SHA-1, SHA-256, SHA-384 or SHA-512; anything else is `PKI_INVALID_OPTION` before the file is read. `importPrivateKey` refuses to guess an RSA scheme for the same reason, and a guess here would be worse: the key is non-extractable, so a wrong scheme could not be corrected by re-importing it. A key with no certificate sharing its `localKeyId` is not guessed at: it is `PKI_REASON_PKCS12_KEY_UNMATCHED`, and `decryptPrivateKey` with a named algorithm opens it. - `importPrivateKey(der, options?)` reads an **unencrypted** key, so it can look at the algorithm OID first ([`ImportPrivateKeyOptions`](../assets/api.json)). An EC key decides for itself as above, and so does an Ed25519 or Ed448 key. An RSA key does not — PKCS#1 v1.5 or PSS, over any digest — and neither does an `id-RSASSA-PSS` key's digest, so both throw `PKI_API_MISUSE` until you name it: `{ algorithm: { name: 'RSA-PSS', hash: 'SHA-256' } }`. An `id-RSASSA-PSS` key is then re-wrapped under `rsaEncryption` on its way to the host — the one identifier Web Crypto imports an RSA key under — and the copy wiped, so it imports on every runtime; only `decryptPrivateKey` cannot do that, since the host unwraps an encrypted key without pkinative ever seeing it. An algorithm you name must fit the key, family and curve, or it is `PKI_API_MISUSE` too. **Describe before you decrypt.** `parsePrivateKeyInfo` returns a [`PrivateKeyInfo`](../assets/api.json) — `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`](../assets/api.json) 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](../../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: ```sh 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`](../assets/api.json)'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`](../assets/api.json)'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`](../assets/api.json) 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`](../assets/api.json) whose `contents` are [`SafeContentsInfo`](../assets/api.json) 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 [`SafeBag`](../assets/api.json)s 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 `safeContentsBag`s 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](choose.md#the-alternatives-by-the-facts) 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](security.md). 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. --- # pkinative-cli — Command-line reference > **pkinative-cli 1.0.0 puts the whole pkinative engine on the command line: 18 commands, offline, with a stable `--json` contract for scripts, CI and AI agents.** It pins pkinative ^1.0.0, its only runtime dependency. [pkinative-cli](https://github.com/Nizoka/pkinative-cli) is a thin, strict front end over pkinative. Every PKI decision — parsing, path validation, revocation, signature verification, CMS, timestamps, key import — is the engine's; the CLI adds what a library deliberately does not do: it reads files and stdin, tells PEM from DER, bounds every read, turns options into flags, writes artefacts safely, renders reports, and maps every failure to one of 13 stable error classes while carrying the engine's `PKI_*` code verbatim. Its 18 commands reach every runtime export of pkinative 1.0.0 (117: 104 functions, the 7 classes of the `PkiError` family, 6 constants), and a test in the CLI repository proves it. | Command | Subcommands | Engine API behind it | |---|---|---| | `pem` | `decode`, `encode` | `decodePem`, `encodePem` | | `oid` | `name`, `encode`, `decode`, `validate`, `list` | `getOidName`; `encodeOid`, `encodeObjectIdentifier`, `encodeRelativeOid`; `decodeOid`, `readObjectIdentifier`, `readRelativeOid`; `isValidOid`; `OID_REGISTRY` | | `fingerprint` | — | `computeFingerprint`, `computeFingerprintAsync` (`--webcrypto`), `formatFingerprint`, `computeKeyIdentifier` (`--key-id`), `shake256` (`--shake256`) | | `asn1` | `decode`, `encode` | `decodeAsn1`, `decodeAsn1Sequence`, the eleven `read*` readers, `encodeAsn1Node`; the `encode*` builders (`encodeSequence`, `encodeInteger`, `encodeTime`, …) | | `cert` | `inspect`, `create`, `encode`, `decode-extension`, `verify-signature`, `check-name`, `match-name`, `check-purpose` | `parseCertificate`, `getExtension`, `formatDistinguishedName`; `createCertificate`; the X.509 encoders (`encodeDistinguishedName`, `encodeSubjectAltName`, `encodeSignatureAlgorithm`, …); `decodeExtensionValue`; `verifyCertificateSignature`, `verifySelfSignature`; `checkServerName`; `matchDnsName`; `checkExtendedKeyUsage` | | `csr` | `inspect`, `create`, `verify` | `parseCertificationRequest`, `createCertificationRequest`, `verifyCertificationRequest` | | `chain` | `verify`, `build`, `validate` | `verifyCertificateChain`; `buildCertificatePath`; `validateCertificatePath`, with `verifyCertificateSignature` on each link | | `crl` | `inspect`, `find`, `verify-signature`, `check` | `parseCertificateList`, `findRevocation`, `verifyCrlSignature`, `checkRevocation` | | `ocsp` | `request`, `cert-id`, `inspect`, `verify-signature`, `check` | `createOcspRequest`, `encodeOcspCertId`, `parseOcspResponse`, `verifyOcspSignature`, `checkOcspStatus` | | `cms` | `sign`, `verify`, `inspect`, `verify-signer`, `add-attribute`, `add-timestamp` | `createSignedData`, `verifySignedData`, `parseSignedData`, `verifySignerInfoSignature`, `addUnsignedAttribute`; `addTimeStampToken` after `verifyTimeStampToken` | | `tsp` | `request`, `inspect`, `verify` | `createTimeStampRequest`; `parseTimeStampResponse`, `parseTimeStampToken`, `parseTstInfo`; `verifyTimeStampToken` | | `key` | `inspect`, `check` | `parsePrivateKeyInfo`, `parseEncryptedPrivateKeyInfo`; `importPrivateKey`, `decryptPrivateKey` | | `p12` | `inspect`, `verify-mac`, `bags`, `open` | `parsePkcs12`, `verifyPkcs12Mac`, `openSafeContents`, `openPkcs12` | | `doctor` | — | `canVerify`, `canSign`, `canDecrypt`, plus the Node.js and engine version checks | | `limits` | — | `DEFAULT_PKI_LIMITS` | | `explain` | — | the error, reason and diagnostic registries of the pinned pkinative release | | `schema` | — | — (the CLI's own JSON Schemas, draft 2020-12, and its capability manifest) | | `completion` | — | — (generated from the same command registry as `--help`) | That is 53 invocations: 47 subcommands and the 6 commands that take none. The CLI repository's knowledge base maps every engine export to the invocations that reach it. ## Installation ```bash npm install --global pkinative-cli pkinative doctor ``` The binary is `pkinative`. It runs on Node.js `^22.22.2 || ^24.14.1 || >=25.8.2` — the first releases that fix CVE-2026-21713, a variable-time PKCS#12 MAC comparison in Web Crypto — and `pkinative doctor` checks that floor. The package has exactly one runtime dependency, pkinative ^1.0.0, imported through one module and never inlined into the CLI's own CommonJS binary. It declares Node.js only; its CI also smoke-runs the built CLI under Deno and Bun, as information rather than a promise. Every release is published from CI with npm provenance, a Sigstore build-provenance attestation and CycloneDX and SPDX SBOMs attached to the GitHub release. To check what you installed: ```bash npm pack pkinative-cli@1.0.0 gh attestation verify pkinative-cli-1.0.0.tgz --repo Nizoka/pkinative-cli ``` ## When to use the CLI | Use the **CLI** when… | Use the **library** when… | Use the **MCP server** when… | |---|---|---| | You write shell scripts, Makefiles or CI jobs | You build a service in Node.js, Deno, Bun, a browser or a worker | A conversational assistant needs PKI tools | | Certificates, CRLs and signatures live on disk or arrive through a pipe | The bytes live in memory and you call the engine directly | The bytes arrive as PEM or base64 in a tool call, or sit in a sandbox directory | | You want exit codes, `--dry-run` and one JSON envelope per run | You want the full API, its types and an `ExternalSigner` | You want typed tools with schemas, prompts and resources | The three surfaces share one engine, so a verdict is the same whichever one computes it; [Choosing your surface](choose.md#choosing-your-surface) holds the capability × surface table. ## Quick start ```bash # What is this certificate? pkinative cert inspect server.pem # Is it valid for this host, now, with revocation? pkinative chain verify server.pem --untrusted intermediate.pem --trust root.pem \ --host www.example.com --crl ca.crl --require-revocation # Issue a self-signed CA, then a leaf under it pkinative cert create --spec ca.json --key ca.key -o ca.pem pkinative cert create --spec leaf.json --key ca.key --issuer ca.pem --public-key leaf.csr -o leaf.pem # Sign and verify a file pkinative cms sign --content report.pdf --cert signer.pem --key signer.key --detached -o report.p7s pkinative cms verify report.p7s --content report.pdf --trust root.pem # Machine-readable everything pkinative chain verify server.pem --trust root.pem --untrusted ca.pem --json --summary ``` Every subcommand has a runnable example in the CLI repository's `samples/` directory, as a `.sh` and a `.ps1` script, and those are the examples its CI runs and pins. ## The agent contract The CLI is built to be driven by programs — CI steps, scripts, AI agents — without parsing prose. Everything in this section is stable across 1.x, and fields are only ever added. - **stdout is the artefact or the report — nothing else, ever.** PEM, DER or hex artefacts, text or JSON reports, schemas and completion scripts go to stdout. - **stderr is the channel.** Notes and diagnostics go there, and under `--json` (or `PKINATIVE_JSON=1`) exactly one JSON envelope, as the last line. - **`--json` is agent mode.** Report commands print compact JSON on stdout (the engine's result in pkinative's wire form) and the envelope on stderr. `--format text|json` on a report command overrides the default; `--format json` without `--json` prints indented JSON. - **A negative verdict prints its full report on stdout and exits 1 with an envelope** (`chain verify`, `cms verify`, `ocsp check`, …): read both. - **`--help` is text for people** whatever `--json` says; `--version --json` prints one object on stdout, `{"name":"pkinative-cli","version":"1.0.0","pkinative":"1.0.0"}`. Neither writes an envelope. | Exit | Meaning | |---|---| | `0` | Success; also a closed pipe (EPIPE), which ends the process quietly | | `1` | Any failure but usage: parse, I/O, policy, a limit, a negative verdict (the report is printed first) | | `2` | Usage error (`E_USAGE`) | | `130` / `143` | Interrupted by SIGINT / SIGTERM; the file being written is removed, finished outputs stay | A status envelope and two error envelopes, as an agent sees them (output of the 1.0.0 binary on the CLI's own test fixtures): ```json {"ok":true,"command":"chain verify","valid":true,"pathLength":3,"signaturesChecked":true,"diagnostics":[]} {"ok":false,"command":"cert inspect","error":{"code":"E_INPUT","message":"leaf.key.pem holds no CERTIFICATE or X509 CERTIFICATE or TRUSTED CERTIFICATE PEM block (labels present: PRIVATE KEY).","pkiCode":"PKI_PEM_UNEXPECTED_LABEL","remedy":"pkinative pem decode (lists the labels present)"},"diagnostics":[]} {"ok":false,"command":"cert inspect","error":{"code":"E_LIMIT","message":"certificate \"leaf.crt.pem\" exceeds --max-input-bytes (100 bytes; observed at least 1005). Raise the bound only for trusted input.","detail":{"limit":"maxInputBytes","flag":"--max-input-bytes","configured":100,"observed":1005},"remedy":"--max-input-bytes (trusted input only)"},"diagnostics":[]} ``` | Envelope field | Present | Meaning | |---|---|---| | `ok`, `command` | always | The outcome, and the command with its subcommand (`"chain verify"`) | | `error.code` | failures | One of the 13 `E_*` classes — branch on this | | `error.message` | failures | For people: the CLI's context, then the engine's message | | `error.pkiCode` | engine failures | pkinative's frozen `PKI_*` code, verbatim — the exact cause | | `error.detail` | when known | `limit`, `flag`, `configured`, `observed`; `offset`, `path`, `algorithm` | | `error.remedy` | when a flag or command lifts it | The CLI flag that changes the outcome, e.g. `--allow-sha1`, `--ber`, `--max-depth ` | | `error.reasons` | negative verdicts | The engine's `PkiReason` list in the wire form | | `diagnostics` | always | Every `PkiDiagnostic` the engine emitted: `{ code, severity, message, standard, path, offset? }` | | `config` | a config file supplied defaults | The `.pkinativerc.json` that did | Command-specific success fields — `valid`, `pathLength`, `signaturesChecked`, `output`, `bytes` (the DER length of an artefact), `encoding`, `nonce`, `count`, `dryRun`, … — are listed per invocation by `pkinative schema manifest`. Without `--json`, a failure prints `error E_ (PKI_): ` on stderr, then a `remedy: …` line when there is one. **JSON reports use pkinative's wire form:** `bigint` becomes a decimal string, `Uint8Array` lowercase hex, `epochMilliseconds` stays a number, absent optional members are omitted and member names are unchanged. The CLI adds nothing to an engine result except where it is a CLI view: `key` and `p12` reports are allow-list views that never carry key bytes, and command-level wrappers (`{ blocks }`, `{ oids }`, `{ valid, reasons }`) are documented per command. **Token economy.** `--summary` prints the command's minimal JSON shape (codes instead of full reasons, names instead of objects); `--fields a,b.c` keeps only these dot-paths, a segment on an array mapping over it; an unknown path is omitted, except under an array, where each element it matches nothing in becomes `null`; `--pretty` indents; `--quiet` silences text notes and diagnostics on stderr, while the envelope still carries the diagnostics. **Inputs.** A PKI input is a path, `-` for stdin, or stdin when the path is absent — refused on an interactive terminal. It is PEM when its first non-blank bytes are `-----BEGIN `, DER otherwise. Where one object is expected, a PEM file with several matching blocks is refused (`E_INPUT`) rather than reduced to its first block; `pem decode --index` extracts one. The repeatable inputs (`--trust`, `--untrusted`, `--chain`, `--crl`, `--ocsp`) accept PEM bundles. Time is `--at `, UTC unless zoned: pin it for reproducible verdicts. **Parsing rules.** Global flags and subcommand flags may sit anywhere on the line; a boolean flag never consumes the next token; `--flag=value` is the explicit form; a lone `-` is a value (stdin or stdout); `--` ends the flags. Short aliases: `-i` (`--input`), `-o` (`--output`), `-f` (`--format`), `-q`, `-h`, `-V`. Each flag is given once unless the manifest marks it repeatable, and a subcommand takes at most the operands the manifest declares, so a verdict never depends on argument order. ### The `schema` subjects `pkinative schema` (or `schema list`) lists the twelve subjects; every JSON Schema is draft 2020-12 with an `$id` versioned by the CLI release. | Subject | Kind | Prints | |---|---|---| | `manifest` | document | Commands, flags, operands, exit codes, error classes, the wire form, each invocation's outputs and envelope status fields | | `errors` | document | The `E_*` classes and the 57 `PKI_*` → `E_*` mappings with their remedies | | `limits` | document | The 22 bounds: flag, kind, effective value | | `status` | schema | The `--json` success envelope | | `error` | schema | The `--json` failure envelope | | `asn1-spec` | schema | The `asn1 encode` node spec | | `cert-spec` | schema | The `cert create` spec | | `csr-spec` | schema | The `csr create` spec | | `config` | schema | The `.pkinativerc.json` file | | `cert-encode-spec` | schema | The `cert encode` spec of each structure (`$defs/`) | | `report` | schema | `report []`: the `--json` report on stdout, generated from the TypeScript types and held to every sample | | `summary` | schema | `summary []`: its `--summary` shape | ```bash pkinative schema manifest --pretty pkinative schema report chain verify pkinative schema summary cert inspect ``` ### Refused combinations Usage errors are `E_USAGE`, exit 2, and they are detected before anything is written — save the one only the input can reveal, `p12 open` on an RSA key without `--rsa-scheme` (below). Beyond an unknown flag, a malformed value or a missing required flag, the CLI refuses: | Where | Refused | |---|---| | Every command | `--password`, `--pass`, `--passin` and `--key-password` (before anything else runs); `--password-stdin=`; two password sources at once; the password from stdin when an input is read from stdin too; combined short flags (`-qf`) and `-o=value`; a single-value flag given twice, a switch given twice, a flag under both its names; more operands than the invocation takes; an operand beside the flag it stands for; a `--max-*` value that is zero, not an integer, or beyond `Number.MAX_SAFE_INTEGER` (there is no "unbounded"); an `--at` that is not ISO 8601, epoch milliseconds or `now`, or a date that does not exist; `--encoding pem` with no PEM label; raw DER to an interactive terminal; a `.pkinativerc.json` key outside the nine presentation keys; `--config` naming a file that does not exist | | `pem` | `decode --output` without `--index`; `encode` without `--label` | | `oid` | `name`, `encode` or `validate` without an OID; `encode` with more than one; `--relative` without `--tlv`; `decode` with both, or neither, a hex argument and `--input` | | `fingerprint` | `--key-id` with `--shake256`; `--webcrypto` with `--key-id` or `--shake256` | | `asn1` | `decode --sequence` with `--path`, `--read` or `--reencode`; `--read` with `--reencode`; a `--path` that is not dot-separated indices; `encode` without `--spec` | | `cert` | `inspect --extension` with `--raw-extensions`, or naming a kind outside the 19; `create` or `encode ` without `--spec`; `create` with no subject key (it is derived from `--key` only for a self-signed certificate from an unencrypted key); `encode` without one known structure; `encode signature-algorithm` with `--spec`; `decode-extension` without `--oid`, or with both or neither of `--value` and `--input`; `check-name` without exactly one of `--host` and `--ip`; `match-name` without two operands; `check-purpose` without `--purpose` | | Signing (`cert create`, `cert encode signature-algorithm`, `csr create`, `cms sign`) | neither or both of `--key` and `--p12`; an RSA key without `--rsa-scheme`; `--rsa-scheme` or `--salt-length` on a non-RSA key; `--salt-length` with `pkcs1`; `pkcs1` for an id-RSASSA-PSS key; `--hash` on Ed25519 or Ed448; `--hash SHA-1` without `--allow-sha1`; an encrypted key or a PKCS#12 without a password; an encrypted key whose type nothing names (`--key-type`, the certificate or the public key) | | `csr` | `create` without `--spec`, or with no public key (derived from `--key` only for an unencrypted key) | | `chain` | `verify`, `build` or `validate` without `--trust`; `verify` with both `--host` and `--ip`; `validate` without a path | | `crl` | `find` without exactly one of `--serial` and `--cert`; `verify-signature` without `--issuer`; `check` without `--cert` | | `ocsp` | `request` or `cert-id` without `--issuer`; a nonce outside 1–32 octets (RFC 8954), also for `chain verify --ocsp-nonce`; `check` without `--cert` and `--issuer` | | `cms` | `sign` without `--content` or `--content-digest`, with both, or with `--content-digest` but no `--detached`; `sign` without a signer certificate (`--cert`, or a `--p12` that holds it); `verify` without `--trust`; `verify-signer` without `--cert`; `add-attribute` without `--attribute`; `add-timestamp` without `--token` | | `tsp` | `request` with both, or neither, of `--data` and `--digest`; `verify` with both, or neither, of `--token` and `--response`, without `--trust`, or without what was stamped (`--request`, `--data` or `--digest`) | | `p12` | `verify-mac`, `bags` or `open` without a password; `open` on an RSA key without `--rsa-scheme` — only the file can reveal it, so the report (`PKI_REASON_PKCS12_RSA_SCHEME_UNSPECIFIED`, the key not imported) is printed first, then `E_USAGE` exits 2 with the remedy `--rsa-scheme pkcs1\|pss`, and no `--certs-out` is written | | `explain` | neither a code nor `--list` | | `schema` | an unknown subject; an argument after a subject other than `report` and `summary`; `report` or `summary` without a known invocation | | `completion` | no shell, or a shell other than `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted for PowerShell) | `--config` together with `--no-config` is not refused: `--no-config` wins and the named file is never read. ## Error classes, codes and remedies Every failure is one of 13 classes. pkinative's 57 frozen error codes map to them by a fixed table — typed against the engine's code union, so an engine release that added a code would fail the CLI's type-check rather than leak as `E_RUNTIME` — and the engine's code always rides along, verbatim, in `error.pkiCode`. | Class | Exit | `PKI_*` codes mapped to it | When | |---|---|---|---| | `E_USAGE` | 2 | `PKI_INVALID_OPTION`, `PKI_API_MISUSE`, `PKI_LIMIT_INVALID` | A missing, unknown or invalid flag, argument or option value | | `E_INPUT` | 1 | `PKI_INVALID_INPUT`, `PKI_ASN1_VALUE_OUT_OF_RANGE`, `PKI_PEM_LABEL_INVALID`, `PKI_PEM_UNEXPECTED_LABEL`, `PKI_CMS_CONTENT_TYPE_UNEXPECTED` | A spec, label or value is unacceptable; a key does not belong to its certificate | | `E_PARSE` | 1 | the other 35: every remaining `PKI_ASN1_*`, `PKI_OID_INVALID`, `PKI_PEM_NO_BLOCK`, `PKI_PEM_LABEL_MISMATCH`, `PKI_PEM_UNTERMINATED`, `PKI_PEM_BASE64_INVALID`, the ten `PKI_X509_*`, `PKI_CMS_STRUCTURE_INVALID`, `PKI_KEY_STRUCTURE_INVALID` | The bytes are not the DER, PEM or JSON they claim to be | | `E_IO` | 1 | — | A filesystem failure, including a refused overwrite (`remedy: --overwrite`) | | `E_SECURITY` | 1 | `PKI_CRYPTO_ALGORITHM_REFUSED`, `PKI_KEY_ENCRYPTION_UNSUPPORTED`, `PKI_KEY_MAC_UNSUPPORTED` | A policy refusal: SHA-1, a legacy PKCS#12 cipher or MAC, a PBES1 key | | `E_LIMIT` | 1 | `PKI_LIMIT_EXCEEDED` | A bound was exceeded: one of the 22 pkinative limits, or `--max-content-size` | | `E_UNSUPPORTED` | 1 | `PKI_PEM_HEADERS_FORBIDDEN`, `PKI_CRYPTO_ALGORITHM_UNSUPPORTED`, `PKI_CRYPTO_KEY_UNSUPPORTED`, `PKI_CMS_VERSION_UNSUPPORTED`, `PKI_CMS_CONTENT_NOT_OCTET_STRING`, `PKI_KEY_VERSION_UNSUPPORTED` | The algorithm, key or structure is not supported | | `E_CRYPTO` | 1 | `PKI_CRYPTO_UNAVAILABLE` | Web Crypto could not run the operation | | `E_PASSWORD` | 1 | `PKI_CRYPTO_DECRYPTION_FAILED` | A wrong password, or a MAC that does not match it | | `E_NOT_FOUND` | 1 | — | A named item is absent: an extension, a PEM block, a signer, a code | | `E_VERIFY_FAILED` | 1 | — | A signature, chain, request, timestamp or MAC verdict is negative; `error.reasons` says why | | `E_CHECK_FAILED` | 1 | `PKI_STRICT_DIAGNOSTIC` | A check returned reasons (revocation, OCSP, name, purpose), or `--strict` escalated a warning | | `E_RUNTIME` | 1 | `PKI_INTERNAL` | Anything else; the CLI's seeded fuzz suite holds that no input reaches it | `E_IO`, `E_NOT_FOUND` and `E_VERIFY_FAILED` carry no engine code: they are the CLI's own (a filesystem error, a missing item, a negative verdict whose reasons are in `error.reasons`). `pkinative schema errors` prints the whole 57 → 13 table. **Remedies.** The engine's messages name library options that do not exist on a command line, so the envelope adds `remedy` — the flag or command that lifts the refusal. A code absent from this table has no flag-level remedy: the input itself must change. A few call sites give a remedy of their own where the right advice depends on what was being read (`cert decode-extension`, a legacy-encrypted PKCS#8 `--key`), and a limit the CLI enforces before the engine runs names its own flag. | `pkiCode` | `remedy` | |---|---| | `PKI_ASN1_LENGTH_NON_MINIMAL`, `PKI_ASN1_INDEFINITE_LENGTH_FORBIDDEN`, `PKI_ASN1_CONSTRUCTED_STRING_FORBIDDEN`, `PKI_ASN1_BOOLEAN_INVALID` | `--ber` (the producer wrote BER, not DER) | | `PKI_ASN1_TRAILING_DATA` | Pass exactly one object; `asn1 decode --sequence` for concatenated objects; `--allow-trailing` (`asn1 decode`, `cms inspect`, `cms verify`) when the container defines what follows | | `PKI_PEM_BASE64_INVALID` | `--pem-mode lax` (whitespace, line length, headers); a missing `=` padding must be restored first | | `PKI_PEM_UNEXPECTED_LABEL` | `pkinative pem decode ` (lists the labels present) | | `PKI_STRICT_DIAGNOSTIC` | Drop `--strict`, or fix the producer the diagnostic names | | `PKI_CRYPTO_ALGORITHM_REFUSED` | `--allow-sha1` (only for legacy material you already trust) | | `PKI_CRYPTO_DECRYPTION_FAILED` | `--password-file ` \| `--password-stdin` \| `PKINATIVE_PASSWORD` (check the password) | | `PKI_X509_EXTENSION_MALFORMED` | `cert inspect --raw-extensions` (keeps every extension undecoded) | | `PKI_KEY_ENCRYPTION_UNSUPPORTED` | From a PKCS#12: convert it once with OpenSSL 3.4 or later (`openssl pkcs12 -in legacy.p12 -legacy -aes256 -out bundle.pem`, then `openssl pkcs12 -export -in bundle.pem -pbmac1_pbkdf2 -out modern.p12`). From a PKCS#8 `--key`: re-encrypt it with PBES2 (`openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256`) | | `PKI_KEY_MAC_UNSUPPORTED` | `--allow-unverified-integrity` (`p12 open`; only for a file whose origin you trust), or re-export it with `openssl pkcs12 -export -pbmac1_pbkdf2` | | `PKI_API_MISUSE` | `pkinative --help` (the options given are incomplete or inconsistent for this input) | | `PKI_LIMIT_EXCEEDED` | `--max- `, the flag `detail.flag` names (`pkinative limits` lists them); raise a bound for trusted input only | **Diagnostics are not errors.** The engine's `PKI_DIAG_*` codes ride in the envelope's `diagnostics[]` and print as `warning PKI_DIAG_… at : ()` lines on stderr unless `--quiet`. `--strict` escalates the first warning to `E_CHECK_FAILED` (`pkiCode: PKI_STRICT_DIAGNOSTIC`) before any report or artefact is written — including a warning that a verify or check report collects, not only one raised while parsing. **`pkinative explain `** explains any code the CLI can print: the 13 `E_*` classes, and from the registries of the pinned pkinative release every `PKI_*` error (with its CLI class, exit code and the flag that lifts it), every `PKI_REASON_*` reason and every `PKI_DIAG_*` diagnostic. `pkinative explain --list [--kind error|reason|diagnostic|cli]` enumerates them. The [errors guide](errors.md) explains the engine's three vocabularies, and [Where the codes surface](errors.md#where-the-codes-surface) compares the library, CLI and MCP views of the same failure. ```bash pkinative explain PKI_CRYPTO_ALGORITHM_REFUSED pkinative explain E_VERIFY_FAILED --json pkinative explain --list --kind reason ``` ## Command reference Eighteen commands in six groups: Encodings (`pem`, `oid`, `fingerprint`, `asn1`), Certificates (`cert`, `csr`), Paths & revocation (`chain`, `crl`, `ocsp`), Signatures & time-stamps (`cms`, `tsp`), Keys (`key`, `p12`) and Meta (`doctor`, `limits`, `explain`, `schema`, `completion`). Every invocation also accepts the [global options](#global-options); the tables below list each subcommand's own flags, and `pkinative --help` prints the same reference. A flag marked *(repeatable)* may be given several times; `` may be `-` for stdin. ### Encodings #### `pkinative pem` RFC 7468 textual encoding. `decode` lists every block (label, size, offset, headers), or writes one block's bytes with `--index`; `encode` wraps DER bytes in one block. The global `--pem-mode lax` tolerates whitespace, line length and RFC 1421 headers; a missing base64 padding is refused in both modes. ```bash pkinative pem decode [] [--index [--encoding pem|der|hex] [-o ]] pkinative pem encode [] --label