Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Certificate and CSR builders reject RFC 5280 MUST-NOT constructions with coded
throws. `pathLenConstraint` requires the keyUsage extension to assert
`keyCertSign`; absent, empty, or `keyCertSign`-less keyUsage is rejected
(§4.2.1.9, `path_length_requires_key_cert_sign`). An empty subject DN requires
a critical subjectAltName carrying at least one non-empty GeneralName; an empty
typed value (`{ type: 'dns', value: '' }`), an empty `subjectAltNames` array,
and a critical `customExtensions` SAN whose value holds no usable GeneralName
are all rejected (§4.2.1.6, `empty_subject_requires_subject_alt_name`), so
`subject: {}` can no longer sign a certificate with no identity. Encoding a
GeneralName with an empty `dNSName`, `rfc822Name`, URI, or SRV value is
rejected (§4.2.1.6, `empty_general_name_value`). A `cRLIssuer`, when present,
may only contain `directoryName` entries, rejecting a non-DN entry or a
directoryName smuggled through an `unknown` general name; a
`nameRelativeToCRLIssuer` distribution point additionally permits only one
(§4.2.1.13, `distribution_point_crl_issuer_not_directory_name`,
`distribution_point_relative_name_multiple_crl_issuers`). Known extensions
supplied through `customExtensions` participate in these cross-field checks.
A `customExtensions` entry carrying a known OID must decode as that extension,
rather than reaching the wire as opaque bytes the parser then rejects
(`malformed_known_extension_value`). Extension OIDs resolve by their encoded
value, so a non-canonical spelling such as `2.5.029.17` is the same extension
as `2.5.29.17` for registry lookup, certificate-versus-CSR context
restrictions, and duplicate detection; the diagnostic still quotes the OID as
submitted. A custom `cRLDistributionPoints` payload runs the same §4.2.1.13
cRLIssuer checks as the typed field, since decoding proves structure but not
the profile the builder promises. `validateOid` also rejects an OID that parses
as decimals but breaks the X.660 arc bounds (`3.1`, `1.40`) with `invalid_oid`
rather than an uncoded `Error`.
(https://github.com/kjanat/micro509/pull/88)
- Parsing rejects a zero-length `dNSName`, `rfc822Name`, or
`uniformResourceIdentifier` GeneralName, which RFC 5280 §4.2.1.6 forbids. An
external certificate could previously carry an empty subjectAltName value and
parse, leaving chain verification to accept a certificate with no usable
identity when no identity match was requested. Certificate and CRL parsing
share the decoder, so this covers subjectAltName, issuerAltName,
authorityInfoAccess locations, CRL distribution points, `cRLIssuer`, the
issuing distribution point, and `certificateIssuer`. Name constraints keep
their own decoder, where an empty base is meaningful.
(https://github.com/kjanat/micro509/pull/88)
- CRL applicability follows the RFC 5280 §6.3.3 relying-party algorithm in
three places it diverged. A certificate without a CRLDP extension accepts a
CRL whose issuing distribution point names the certificate issuer or one of
Expand Down
202 changes: 138 additions & 64 deletions bun.lock

Large diffs are not rendered by default.

37 changes: 19 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,21 @@
"deno:cache": "deno cache --import-map=deno.import_map.json {src,docs,packages}/",
"deno:cleandocs": "rm -rf ./.denodocs 2>/dev/null",
"deno:doc": "run -qpk deno:importsmap deno:cleandocs; deno doc --import-map deno.import_map.json --no-npm --name=\"$(jq -r '.name' package.json)\"",
"deno:importsmap": "npx -y importmapify@latest --quiet --out deno.import_map.json --import 'bun:test=./node_modules/bun-types/test.d.ts' --import '@deno/doc=jsr:@deno/doc@0.199.0' --import 'vue=./node_modules/vue/dist/vue.d.ts'",
"deno:importsmap": "run importmapify --quiet --out deno.import_map.json --import 'bun:test=./node_modules/bun-types/test.d.ts' --import '@deno/doc=jsr:@deno/doc@0.199.0' --import 'vue=./node_modules/vue/dist/vue.d.ts'",
"dev": "run -pK build:watch site:dev",
"docs:build": "run -q deno:doc --html --output=.denodocs $(jq -r '.exports | .[]? // .' jsr.json) 2>/dev/null",
"docs:lint": "run -q deno:doc --lint $(jq -r '.exports | .[]? // .' jsr.json)",
"fmt": "dprint fmt",
"format": "run -q fmt",
"lint": "bun lint:biome",
"lint": "run -qq lint:biome",
"lint:biome": "biome lint",
"lint:deno": "deno lint src",
"lint:fix": "biome check --fix",
"playwright:chromium": "npx playwright install --with-deps chromium",
"prepare": "run -sq deno:importsmap deno:cache",
"prepublishOnly": "[ -n \"${GITHUB_ACTIONS:-}\" ] || { printf '%s\\n' 'manual npm publish blocked; use release workflow for provenance' >&2; exit 1; }",
"publish:jsr": "[ -n \"${GITHUB_ACTIONS:-}\" ] || { printf '%s\\n' 'manual JSR publish blocked; use release workflow' >&2; exit 1; }; bunx jsr publish",
"publish:pkgprnew": "bunx pkg-pr-new publish --packageManager='npm,pnpm,bun' --bun --template './examples/vite'",
"publish:jsr": "[ -n \"${GITHUB_ACTIONS:-}\" ] || { printf '%s\\n' 'manual JSR publish blocked; use release workflow' >&2; exit 1; }; BIN=\"${BIN:-bun}\"; $BIN x jsr publish",
"publish:pkgprnew": "BIN=\"${BIN:-bun}\"; $BIN x pkg-pr-new publish --packageManager='npm,pnpm,bun' --bun --template './examples/vite'",
"site:build": "run --dir site/.vitepress build",
"site:dev": "run --dir site/.vitepress dev",
"site:import-maps": "deno run --allow-read --allow-net --allow-write --allow-run scripts/site-import-maps.deno.ts",
Expand All @@ -112,19 +112,20 @@
"site:typecheck": "run --dir site/.vitepress typecheck",
"smoke": "run build -l silent; run -p smoke:browser smoke:bun smoke:deno smoke:node smoke:workerd",
"smoke:browser": "node scripts/smoke-browser.mjs",
"smoke:bun": "bun --bun scripts/smoke.mjs",
"smoke:bun": "BIN=\"${BIN:-bun}\"; $BIN --bun scripts/smoke.mjs",
"smoke:deno": "deno run --allow-read scripts/smoke.mjs",
"smoke:node": "node scripts/smoke.mjs",
"smoke:workerd": "node scripts/smoke-workerd.mjs",
"test": "AGENT=1 bun test --concurrent",
"test": "BIN=\"${BIN:-bun}\"; AGENT=\"${AGENT:-1}\" $BIN test --concurrent",
"test:35433": "PR=\"35433\"; (command -v \"bun-${PR}\" >/dev/null || bunx bun-pr \"${PR}\") && BIN=\"bun-${PR}\" bun --silent run test",
"test:coverage": "run -q test --coverage",
"test:differential": "bun test test/differential.test.ts test/differential-fuzz.test.ts",
"test:pkits": "bun test test/pkits.test.ts",
"test:watch": "AGENT=1 bun test --watch --concurrent",
"test:differential": "BIN=\"${BIN:-bun}\"; $BIN test test/differential.test.ts test/differential-fuzz.test.ts",
"test:pkits": "BIN=\"${BIN:-bun}\"; $BIN test test/pkits.test.ts",
"test:watch": "BIN=\"${BIN:-bun}\"; $BIN run test --watch",
"typecheck": "run -pk typecheck:src typecheck:other typecheck:regular site:typecheck",
"typecheck:other": "tsc --noEmit -p tsconfig.other.json",
"typecheck:regular": "tsc --noEmit -p tsconfig.json",
"typecheck:src": "tsc --noEmit -p tsconfig.src.json",
"typecheck:other": "run typescript-7 --noEmit -p tsconfig.other.json",
"typecheck:regular": "run typescript-7 --noEmit -p tsconfig.json",
"typecheck:src": "run typescript-7 --noEmit -p tsconfig.src.json",
"wrangler:build": "wrangler build",
"wrangler:deploy": "wrangler deploy",
"wrangler:deploy:versions": "wrangler versions upload",
Expand Down Expand Up @@ -191,25 +192,25 @@
"catalog": {
"biome": "npm:@biomejs/biome@^2.5.5",
"dprint": "^0.55.2",
"runner-run": "^0.21.0",
"runner-run": "^0.23.0",
Comment thread
kjanat marked this conversation as resolved.
"vue": "^3.5.40"
},
"catalogs": {
"build": {
"@arethetypeswrong/core": "^0.18.4",
"publint": "^0.3.21",
"tsdown": "^0.22.13",
"publint": "^0.3.22",
"tsdown": "^0.22.14",
"unplugin-unused": "^0.5.7"
},
"cloudflare": {
"@cloudflare/vite-plugin": "^1.46.0",
"wrangler": "^4.113.0"
"wrangler": "^4.114.0"
},
"deno": {
"@deno/doc": "npm:@jsr/deno__doc@0.199.0",
"@types/deno": "^2.7.0",
"deno": "^2.9.3",
"importmapify": "^1.6.1"
"deno": "^2.9.4",
"importmapify": "^1.7.0"
},
"site": {
"markdown-it-task-lists": "^2.1.1",
Expand Down
15 changes: 13 additions & 2 deletions src/internal/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,19 @@ internal/
- Register new OIDs in `asn1/oids.json` under their registration arc; consume
them as `OIDS.<name>`. Never inline a dotted-decimal literal in source.
- New certificate extensions get an `ExtensionDefinition` in
`x509/extension-registry.ts` (decode/encode/applyParsed + accumulator field),
not ad-hoc decoding at call sites.
`x509/extension-registry.ts` (decode/encode/assertProfile/applyParsed +
accumulator field), not ad-hoc decoding at call sites.
- `assertProfile` is required. It receives the decoded value and the extension's
criticality, and delegates payload rules to the encoder that owns them. It runs
only in builders, over a `customExtensions` entry carrying a known OID, so a raw
value meets the same bar as the typed input. Parsing stays tolerant and never
calls it.
- An extension whose criticality RFC 5280 fixes calls `assertExtensionCriticality`
from its `assertProfile`, and its `defaultCritical` must agree; a test in
`test/internals.test.ts` runs every definition's hook at its own default.
- Compare OIDs canonically. `validateOid` returns the canonical spelling, and
`getExtendedKeyUsageOid` / `getAuthorityInfoAccessMethodOid` resolve to it, so a
redundant-leading-zero alias cannot dodge a rule keyed on OID equality.
- `x509/general-name.ts` is the only GeneralName decoder; certificate and CRL
parsing both consume it so the two layers cannot drift on an alternative.
- Keep sign/verify dispatch symmetric in `signing.ts` and `sig-verify.ts`.
Expand Down
16 changes: 14 additions & 2 deletions src/internal/asn1/asn1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type { DerElement } from '#micro509/internal/asn1/der';
import { readElement } from '#micro509/internal/asn1/der';
import { objectIdentifier, readElement } from '#micro509/internal/asn1/der';

/** Shared UTF-8 text decoder for ASN.1 string types. */
const textDecoder = new TextDecoder('utf-8', { fatal: true });
Expand Down Expand Up @@ -41,6 +41,18 @@ export function decodeObjectIdentifier(bytes: Uint8Array): string {
return values.join('.');
}

/**
* Reduces an OID to the dotted-decimal form its DER encoding decodes back to, so
* that spellings differing only by redundant leading zeros in an arc resolve to
* one identity.
*
* @example `canonicalizeOid('2.5.029.17')` returns `'2.5.29.17'`
* @throws if the OID has a non-numeric segment or violates the X.660 arc constraints.
*/
export function canonicalizeOid(oid: string): string {
return decodeObjectIdentifier(readElement(objectIdentifier(oid), 0).value);
}

/** Converts raw bytes to a lowercase hex string with no separator. */
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
Expand Down Expand Up @@ -285,7 +297,7 @@ export function hexToBytes(value: string): Uint8Array {
return out;
}

/** Decodes a DER BOOLEAN value: any non-zero first byte is `true`. */
/** Decodes a DER BOOLEAN value. X.690 clause 11.1 restricts TRUE to an all-ones octet, so only `0xff` and `0x00` are accepted. */
export function decodeBoolean(bytes: Uint8Array): boolean {
if (bytes.length !== 1) {
throw new Error('BOOLEAN must contain exactly one octet');
Expand Down
7 changes: 2 additions & 5 deletions src/internal/x509/extension-bits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
* `flags` contains the recognized flag values with any non-zero padding bits
* masked out. `nonZeroPadding` is `true` when the original BIT STRING encoding
* had non-zero bits in positions that DER ({@linkcode https://www.itu.int/rec/T-REC-X.690-202102-I/en | X.690 §11.2.1}) requires to be zero.
* Verification layers can use this signal to reject non-conformant encodings.
* `requireCanonicalBitFlags` rejects such encodings before they reach parsed
* extension values.
*/
import { bitString, DEFAULT_MAX_DER_DEPTH, readRootElement } from '#micro509/internal/asn1/der';
import { throwExtensionEncoderError } from '#micro509/internal/x509/extension-errors';
Expand Down Expand Up @@ -162,10 +163,6 @@ function decodeBitFlags<T extends string>(
if (bytes.length === 0 && unusedBits !== 0) {
throw new Error('Invalid BIT STRING');
}
// Detect non-zero padding bits in the unused positions of the last byte.
// DER X.690 §11.2.1 requires these to be zero. Rather than rejecting here
// (which would break interop with real-world non-conformant certificates),
// we record the violation so verification layers can decide.
let nonZeroPadding = false;
if (unusedBits > 0 && bytes.length > 0) {
const lastByte = bytes[bytes.length - 1] ?? 0;
Expand Down
9 changes: 9 additions & 0 deletions src/internal/x509/extension-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,29 @@ export type ExtensionEncoderErrorCode =
| 'directory_name_not_sequence'
| 'display_text_out_of_range'
| 'distribution_point_crl_issuer_empty'
| 'distribution_point_crl_issuer_not_directory_name'
| 'distribution_point_empty'
| 'distribution_point_full_name_empty'
| 'distribution_point_name_conflict'
| 'distribution_point_name_empty'
| 'distribution_point_relative_name_multiple_crl_issuers'
| 'duplicate_extension_oid'
| 'duplicate_policy_oid'
| 'empty_general_name_value'
| 'empty_subject_requires_subject_alt_name'
| 'extended_key_usage_empty'
| 'extension_must_be_critical'
| 'extension_must_be_non_critical'
| 'extension_not_supported_in_context'
| 'invalid_general_name_tag'
| 'invalid_ia5_string'
| 'invalid_ip_name_constraint'
| 'invalid_oid'
| 'key_usage_empty'
| 'malformed_known_extension_value'
| 'name_constraints_empty'
| 'path_length_requires_ca'
| 'path_length_requires_key_cert_sign'
| 'policy_constraints_empty'
| 'policy_mappings_any_policy'
| 'policy_mappings_empty'
Expand Down
Loading
Loading