From 5c57d9cc7abc00c2d0ec3bd0e41dd569e3b1ba28 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 24 Jul 2026 17:59:07 +0200 Subject: [PATCH 01/14] fix(x509): reject RFC 5280 MUST-NOT builder constructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three builder gaps emitted RFC-invalid certificates. pathLenConstraint now requires the keyUsage keyCertSign bit when a keyUsage extension is present (§4.2.1.9); the encoder emitted a certificate this library's own verifier rejects. An empty subject DN requires a subjectAltName that is present and critical (§4.2.1.6), counting a customExtensions SAN only when critical and an empty subjectAltNames array as absent; only criticality was enforced, so subject: {} signed a certificate with no identity at all. A relativeName distribution point rejects more than one cRLIssuer distinguished name (§4.2.1.13). All three throw coded ExtensionEncoderErrorCode errors on both the certificate and CSR paths. Verifier fixtures that intentionally omit keyCertSign drop their pathLength so the malformed-chain scenarios stay constructible. Citation riders from the audit: the ParsedBitFlags docs no longer claim non-canonical padding is reported for callers to judge (extension flag decoding rejects it), and decodeBoolean's doc cites the X.690 11.1 DER restriction instead of the BER any-non-zero rule. --- CHANGELOG.md | 12 +++++ src/internal/asn1/asn1.ts | 2 +- src/internal/x509/extension-bits.ts | 7 +-- src/internal/x509/extension-errors.ts | 3 ++ src/x509/extensions.ts | 65 ++++++++++++++++++++-- test/certificate.test.ts | 73 ++++++++++++++++++++++++- test/csr.test.ts | 14 +++++ test/internals.test.ts | 78 +++++++++++++++++++++++++++ test/verify.test.ts | 6 +-- 9 files changed, 246 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9910916..59054f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Certificate and CSR builders reject three RFC 5280 MUST-NOT constructions + with coded throws. `pathLenConstraint` requires the keyUsage `keyCertSign` + bit when a keyUsage extension is present (§4.2.1.9, + `path_length_requires_key_cert_sign`); the verifier already rejected such + certificates. An empty subject DN requires a subjectAltName extension, + present and critical, counting a `customExtensions` SAN only when + `critical: true` and a present-but-empty `subjectAltNames` array as absent + (§4.2.1.6, `empty_subject_requires_subject_alt_name`); only criticality was + enforced before, so `subject: {}` signed a certificate with no identity. A + `relativeName` distribution point rejects more than one `cRLIssuer` + distinguished name (§4.2.1.13, + `distribution_point_relative_name_multiple_crl_issuers`). - `importPkcs8Der` accepts a `OneAsymmetricKey` (RFC 5958 §2 / RFC 8410 §7) that carries both `attributes [0]` and `publicKey [1]`. The parser capped at four elements, so a five-element v2 key that OpenSSL and Node WebCrypto both accept diff --git a/src/internal/asn1/asn1.ts b/src/internal/asn1/asn1.ts index 25b8248..1ed7088 100644 --- a/src/internal/asn1/asn1.ts +++ b/src/internal/asn1/asn1.ts @@ -285,7 +285,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'); diff --git a/src/internal/x509/extension-bits.ts b/src/internal/x509/extension-bits.ts index 35c8ac9..d9b0e85 100644 --- a/src/internal/x509/extension-bits.ts +++ b/src/internal/x509/extension-bits.ts @@ -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'; @@ -162,10 +163,6 @@ function decodeBitFlags( 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; diff --git a/src/internal/x509/extension-errors.ts b/src/internal/x509/extension-errors.ts index 69dce31..8f9120f 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -24,8 +24,10 @@ export type ExtensionEncoderErrorCode = | '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_subject_requires_subject_alt_name' | 'extended_key_usage_empty' | 'extension_not_supported_in_context' | 'invalid_general_name_tag' @@ -34,6 +36,7 @@ export type ExtensionEncoderErrorCode = | 'invalid_oid' | 'key_usage_empty' | 'name_constraints_empty' + | 'path_length_requires_key_cert_sign' | 'policy_constraints_empty' | 'policy_mappings_any_policy' | 'policy_mappings_empty' diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index 8d4a77b..c907cfe 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -72,7 +72,8 @@ export type { * `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. + * Extension flag decoding rejects such encodings, so parsed extension values + * always report `false`. */ export interface ParsedBitFlags { /** Decoded flag values, padding bits masked. */ @@ -725,8 +726,8 @@ const AUTHORITY_INFO_ACCESS_METHOD_OIDS: Record(); const basicConstraints = input?.basicConstraints ?? { ca: false }; @@ -799,12 +804,57 @@ function appendConfiguredExtensions( appendCustomExtensions(encoded, seen, input, context); } +/** + * RFC 5280 §4.2.1.9: pathLenConstraint requires the keyUsage keyCertSign bit + * when a keyUsage extension is present. + */ +function assertPathLengthKeyUsage( + basicConstraints: BasicConstraints | undefined, + keyUsage: readonly KeyUsage[] | undefined, +): void { + if (basicConstraints?.pathLength === undefined) { + return; + } + if (keyUsage === undefined || keyUsage.length === 0) { + return; + } + if (!keyUsage.includes('keyCertSign')) { + throwExtensionEncoderError( + 'path_length_requires_key_cert_sign', + 'basicConstraints pathLength requires the keyUsage keyCertSign bit', + ); + } +} + +/** + * RFC 5280 §4.2.1.6: an empty subject DN requires a subjectAltName extension, + * present and marked critical. + */ +function assertEmptySubjectHasCriticalSubjectAltName( + input: CertificateExtensionsInput | undefined, +): void { + if (input?.subjectAltNames !== undefined && input.subjectAltNames.length > 0) { + return; + } + const criticalCustomSan = input?.customExtensions?.some( + (extension) => extension.oid === OIDS.subjectAltName && extension.critical === true, + ); + if (criticalCustomSan === true) { + return; + } + throwExtensionEncoderError( + 'empty_subject_requires_subject_alt_name', + 'An empty subject requires a critical subjectAltName extension', + ); +} + function appendConstraintExtensions( encoded: Uint8Array[], seen: Set, input: CertificateExtensionsInput, includeBasicConstraints: boolean, ): void { + assertPathLengthKeyUsage(input.basicConstraints, input.keyUsage); if (includeBasicConstraints && input.basicConstraints !== undefined) { pushKnownExtension( encoded, @@ -1318,6 +1368,15 @@ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { 'DistributionPoint must contain distributionPoint or crlIssuer', ); } + if ( + point.distributionPoint?.relativeName !== undefined && + (point.crlIssuer?.filter((name) => name.type === 'directoryName').length ?? 0) > 1 + ) { + throwExtensionEncoderError( + 'distribution_point_relative_name_multiple_crl_issuers', + 'DistributionPointName relativeName requires at most one cRLIssuer distinguished name', + ); + } const fields: Uint8Array[] = []; if (point.distributionPoint !== undefined) { fields.push( diff --git a/test/certificate.test.ts b/test/certificate.test.ts index f4bf8ff..94e22bf 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -11,10 +11,10 @@ import { unwrap, verifyCertificateChain, } from '#micro509'; -import { readElement } from '#micro509/internal/asn1/der'; +import { readElement, sequence } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/internal/crypto/rsa-pss'; -import { encodeName } from '#micro509/x509'; +import { encodeName, encodeSubjectAltName } from '#micro509/x509'; import { childrenOf, decodeObjectIdentifier, hasExtensionOid } from '#test/helpers'; async function expectRejectedErrorCode(promise: Promise, code: string): Promise { @@ -479,6 +479,75 @@ describe('certificate', () => { expect(sanExtension?.critical).toBe(true); }); + it('rejects an empty subject DN without a subjectAltName (RFC 5280 §4.2.1.6)', async () => { + const ca = await createSelfSignedCertificate({ + subject: { commonName: 'Empty Subject CA 2' }, + extensions: { basicConstraints: { ca: true }, keyUsage: ['keyCertSign'] }, + }); + const leafKeys = await generateKeyPair(); + await expectRejectedErrorCode( + createCertificate({ + issuer: { commonName: 'Empty Subject CA 2' }, + subject: {}, + publicKey: leafKeys.publicKey, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + }), + 'empty_subject_requires_subject_alt_name', + ); + await expectRejectedErrorCode( + createCertificate({ + issuer: { commonName: 'Empty Subject CA 2' }, + subject: {}, + publicKey: leafKeys.publicKey, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + extensions: { subjectAltNames: [] }, + }), + 'empty_subject_requires_subject_alt_name', + ); + const withCriticalCustomSan = await createCertificate({ + issuer: { commonName: 'Empty Subject CA 2' }, + subject: {}, + publicKey: leafKeys.publicKey, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + extensions: { + customExtensions: [ + { + oid: '2.5.29.17', + critical: true, + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'custom-san.example' })]), + }, + ], + }, + }); + expect(unwrap(parseCertificateDer(withCriticalCustomSan.der)).subjectAltNames).toEqual([ + { type: 'dns', value: 'custom-san.example' }, + ]); + }); + + it('rejects pathLenConstraint without keyCertSign (RFC 5280 §4.2.1.9)', async () => { + await expectRejectedErrorCode( + createSelfSignedCertificate({ + subject: { commonName: 'PathLen No CertSign' }, + extensions: { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['digitalSignature'], + }, + }), + 'path_length_requires_key_cert_sign', + ); + const qualified = await createSelfSignedCertificate({ + subject: { commonName: 'PathLen No KeyUsage' }, + extensions: { basicConstraints: { ca: true, pathLength: 0 } }, + }); + expect(unwrap(parseCertificateDer(qualified.certificate.der)).basicConstraints).toEqual({ + ca: true, + pathLength: 0, + }); + }); + it('rejects invalid country code length', async () => { await expectRejectedErrorCode( createSelfSignedCertificate({ subject: { country: 'USA' } }), diff --git a/test/csr.test.ts b/test/csr.test.ts index ede13d6..83c20ec 100644 --- a/test/csr.test.ts +++ b/test/csr.test.ts @@ -43,6 +43,20 @@ describe('csr', () => { expect(custom?.critical).toBe(true); }); + it('rejects a requested pathLenConstraint without keyCertSign (RFC 5280 §4.2.1.9)', async () => { + const keyPair = await generateKeyPair({ kind: 'ed25519' }); + const pending = createCertificateSigningRequest({ + subject: { commonName: 'csr-plc.example' }, + publicKey: keyPair.publicKey, + signerPrivateKey: keyPair.privateKey, + extensions: { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['digitalSignature'], + }, + }); + await expect(pending).rejects.toThrow('path_length_requires_key_cert_sign'); + }); + it('verifies certificate request signatures for RSA and Ed25519', async () => { const rsaKeys = await generateKeyPair({ kind: 'rsa', diff --git a/test/internals.test.ts b/test/internals.test.ts index 04e4596..46645f6 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -96,6 +96,7 @@ import { encodeCrlDistributionPoints, encodeExtendedKeyUsage, encodeKeyUsage, + encodeName, encodeNameConstraints, encodePolicyMappings, encodeRelativeDistinguishedName, @@ -861,6 +862,83 @@ describe('extensions encoding', () => { expectEncoderErrorCode(() => encodeNameConstraints({}), 'name_constraints_empty'); }); + it('rejects a relativeName distribution point with multiple cRLIssuer DNs (RFC 5280 §4.2.1.13)', () => { + const issuerA = toHex(encodeName({ commonName: 'CRL Issuer A' })); + const issuerB = toHex(encodeName({ commonName: 'CRL Issuer B' })); + const relativeName = [{ type: 'commonName', value: 'CRL42' }] as const; + expectEncoderErrorCode( + () => + encodeCrlDistributionPoints([ + { + distributionPoint: { relativeName }, + crlIssuer: [ + { type: 'directoryName', derHex: issuerA }, + { type: 'directoryName', derHex: issuerB }, + ], + }, + ]), + 'distribution_point_relative_name_multiple_crl_issuers', + ); + expect( + encodeCrlDistributionPoints([ + { + distributionPoint: { relativeName }, + crlIssuer: [ + { type: 'directoryName', derHex: issuerA }, + { type: 'uri', value: 'http://example.test/backup.crl' }, + ], + }, + ]), + ).toBeInstanceOf(Uint8Array); + }); + + const subjectPublicKeyInfo = sequence([ + sequence([objectIdentifier(OIDS.rsaEncryption), nullValue()]), + bitString(Uint8Array.of(0x01, 0x02, 0x03)), + ]); + + it('rejects pathLenConstraint without keyCertSign when keyUsage is present (RFC 5280 §4.2.1.9)', () => { + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['digitalSignature'], + }), + 'path_length_requires_key_cert_sign', + ); + expect( + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['keyCertSign'], + }), + ).toBeInstanceOf(Array); + expect( + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + }), + ).toBeInstanceOf(Array); + }); + + it('rejects an empty subject without a critical subjectAltName (RFC 5280 §4.2.1.6)', () => { + expectEncoderErrorCode( + () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, undefined, true), + 'empty_subject_requires_subject_alt_name', + ); + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { subjectAltNames: [] }, true), + 'empty_subject_requires_subject_alt_name', + ); + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { subjectAltNames: [{ type: 'dns', value: 'empty-subject.example' }] }, + true, + ), + ).toBeInstanceOf(Array); + }); + it('rejects an IP name constraint whose address and mask do not form 8 or 32 octets', () => { expectEncoderErrorCode( () => diff --git a/test/verify.test.ts b/test/verify.test.ts index d9397c6..c06aac1 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -91,7 +91,7 @@ describe('chain verification', () => { signerPrivateKey: root.keyPair.privateKey, issuerPublicKey: root.keyPair.publicKey, extensions: { - basicConstraints: { ca: true, pathLength: 0 }, + basicConstraints: { ca: true }, keyUsage: ['digitalSignature'], }, }); @@ -259,7 +259,7 @@ describe('chain verification', () => { const noKeyCertSignChain = await issueChain({ intermediateExtensions: { - basicConstraints: { ca: true, pathLength: 0 }, + basicConstraints: { ca: true }, keyUsage: ['digitalSignature'], }, }); @@ -3710,7 +3710,7 @@ describe('validateCandidatePath direct', () => { it('detects key_cert_sign_required in candidate path', async () => { const chain = await issueChain({ intermediateExtensions: { - basicConstraints: { ca: true, pathLength: 0 }, + basicConstraints: { ca: true }, keyUsage: ['digitalSignature'], }, }); From 7407511f6e43a646f72b624665efd6e4aa883c6d Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 24 Jul 2026 18:00:36 +0200 Subject: [PATCH 02/14] docs(changelog): link #88 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59054f8..193407a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `relativeName` distribution point rejects more than one `cRLIssuer` distinguished name (§4.2.1.13, `distribution_point_relative_name_multiple_crl_issuers`). + (https://github.com/kjanat/micro509/pull/88) - `importPkcs8Der` accepts a `OneAsymmetricKey` (RFC 5958 §2 / RFC 8410 §7) that carries both `attributes [0]` and `publicKey [1]`. The parser capped at four elements, so a five-element v2 key that OpenSSL and Node WebCrypto both accept From bed206662ea3de1a9ea1bf362340c86b84f8a95d Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 24 Jul 2026 19:36:26 +0200 Subject: [PATCH 03/14] fix(x509): close builder cross-field validation bypasses pathLength/keyCertSign coupling, the empty-subject SAN rule, and relativeName cRLIssuer validation each admitted a bypass through public input. Resolve an effective basicConstraints/keyUsage/SAN view across typed fields and custom-known extensions before the cross-field checks, so a known extension smuggled through customExtensions no longer evades them (canonical OID match). Reject absent or empty keyUsage under pathLength, empty typed and malformed custom SAN under an empty subject, empty GeneralName string values on encode, and a non-directoryName or smuggled/multiple cRLIssuer under relativeName. --- CHANGELOG.md | 28 +++-- src/internal/x509/extension-errors.ts | 2 + src/x509/extensions.ts | 165 +++++++++++++++++++++----- test/certificate.test.ts | 26 ++-- test/csr.test.ts | 35 ++++-- test/internals.test.ts | 113 ++++++++++++++++-- test/parse.test.ts | 1 + 7 files changed, 297 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 193407a..056016a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,18 +46,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Certificate and CSR builders reject three RFC 5280 MUST-NOT constructions - with coded throws. `pathLenConstraint` requires the keyUsage `keyCertSign` - bit when a keyUsage extension is present (§4.2.1.9, - `path_length_requires_key_cert_sign`); the verifier already rejected such - certificates. An empty subject DN requires a subjectAltName extension, - present and critical, counting a `customExtensions` SAN only when - `critical: true` and a present-but-empty `subjectAltNames` array as absent - (§4.2.1.6, `empty_subject_requires_subject_alt_name`); only criticality was - enforced before, so `subject: {}` signed a certificate with no identity. A - `relativeName` distribution point rejects more than one `cRLIssuer` - distinguished name (§4.2.1.13, - `distribution_point_relative_name_multiple_crl_issuers`). +- 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 `nameRelativeToCRLIssuer` + distribution point requires `cRLIssuer` to hold exactly one `directoryName`, + rejecting a non-DN entry or a directoryName smuggled through an `unknown` + general name (§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. (https://github.com/kjanat/micro509/pull/88) - `importPkcs8Der` accepts a `OneAsymmetricKey` (RFC 5958 §2 / RFC 8410 §7) that carries both `attributes [0]` and `publicKey [1]`. The parser capped at four diff --git a/src/internal/x509/extension-errors.ts b/src/internal/x509/extension-errors.ts index 8f9120f..99391ac 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -20,6 +20,7 @@ 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' @@ -27,6 +28,7 @@ export type ExtensionEncoderErrorCode = | '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_not_supported_in_context' diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index c907cfe..e85055a 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -739,7 +739,7 @@ export function buildCertificateExtensions( if (subjectIsEmpty) { assertEmptySubjectHasCriticalSubjectAltName(input); } - assertPathLengthKeyUsage(input?.basicConstraints, input?.keyUsage); + assertPathLengthKeyUsage(input); const extensions: Uint8Array[] = []; const seen = new Set(); const basicConstraints = input?.basicConstraints ?? { ca: false }; @@ -805,20 +805,17 @@ function appendConfiguredExtensions( } /** - * RFC 5280 §4.2.1.9: pathLenConstraint requires the keyUsage keyCertSign bit - * when a keyUsage extension is present. + * RFC 5280 §4.2.1.9: pathLenConstraint requires the keyUsage extension to assert keyCertSign. + * Absent, empty, or keyCertSign-less keyUsage is rejected. Known extensions supplied through + * customExtensions participate in the effective view. */ -function assertPathLengthKeyUsage( - basicConstraints: BasicConstraints | undefined, - keyUsage: readonly KeyUsage[] | undefined, -): void { +function assertPathLengthKeyUsage(input: CertificateExtensionsInput | undefined): void { + const basicConstraints = resolveEffectiveBasicConstraints(input); if (basicConstraints?.pathLength === undefined) { return; } - if (keyUsage === undefined || keyUsage.length === 0) { - return; - } - if (!keyUsage.includes('keyCertSign')) { + const keyUsage = resolveEffectiveKeyUsage(input); + if (keyUsage === undefined || keyUsage.length === 0 || !keyUsage.includes('keyCertSign')) { throwExtensionEncoderError( 'path_length_requires_key_cert_sign', 'basicConstraints pathLength requires the keyUsage keyCertSign bit', @@ -827,17 +824,20 @@ function assertPathLengthKeyUsage( } /** - * RFC 5280 §4.2.1.6: an empty subject DN requires a subjectAltName extension, - * present and marked critical. + * RFC 5280 §4.2.1.6: an empty subject DN requires a subjectAltName extension + * present, marked critical, and carrying at least one non-empty GeneralName. */ function assertEmptySubjectHasCriticalSubjectAltName( input: CertificateExtensionsInput | undefined, ): void { - if (input?.subjectAltNames !== undefined && input.subjectAltNames.length > 0) { + if (input?.subjectAltNames?.some(subjectAltNameHasIdentity) === true) { return; } const criticalCustomSan = input?.customExtensions?.some( - (extension) => extension.oid === OIDS.subjectAltName && extension.critical === true, + (extension) => + oidEquals(extension.oid, OIDS.subjectAltName) && + extension.critical === true && + customSubjectAltNameHasIdentity(extension.value), ); if (criticalCustomSan === true) { return; @@ -848,13 +848,98 @@ function assertEmptySubjectHasCriticalSubjectAltName( ); } +/** Canonical OID equality: matches even when arcs carry redundant leading zeros. */ +function oidEquals(candidate: string, known: string): boolean { + if (candidate === known) { + return true; + } + try { + return toHex(objectIdentifier(candidate)) === toHex(objectIdentifier(known)); + } catch { + return false; + } +} + +/** First customExtensions value whose OID canonically matches `oid`. */ +function findCustomExtensionValue( + input: CertificateExtensionsInput | undefined, + oid: string, +): Uint8Array | undefined { + return input?.customExtensions?.find((extension) => oidEquals(extension.oid, oid))?.value; +} + +/** Effective basicConstraints across the typed field and any custom-known extension. */ +function resolveEffectiveBasicConstraints( + input: CertificateExtensionsInput | undefined, +): BasicConstraints | undefined { + if (input?.basicConstraints !== undefined) { + return input.basicConstraints; + } + const custom = findCustomExtensionValue(input, OIDS.basicConstraints); + if (custom === undefined) { + return undefined; + } + try { + return BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode(custom); + } catch { + return undefined; + } +} + +/** Effective keyUsage flags across the typed field and any custom-known extension. */ +function resolveEffectiveKeyUsage( + input: CertificateExtensionsInput | undefined, +): readonly KeyUsage[] | undefined { + if (input?.keyUsage !== undefined) { + return input.keyUsage; + } + const custom = findCustomExtensionValue(input, OIDS.keyUsage); + if (custom === undefined) { + return undefined; + } + try { + return KEY_USAGE_EXTENSION_DEFINITION.decode(custom).flags; + } catch { + return undefined; + } +} + +/** Whether a typed GeneralName carries a non-empty identity value. */ +function subjectAltNameHasIdentity(name: SubjectAltName): boolean { + switch (name.type) { + case 'dns': + case 'email': + case 'uri': + case 'srv': + case 'ip': + return name.value.length > 0; + case 'directoryName': + return name.derHex.length > 0; + case 'unknown': + return name.value.length > 0; + default: { + const _exhaustive: never = name; + throw new Error(`Unhandled SubjectAltName type: ${String(_exhaustive)}`); + } + } +} + +/** Whether a custom subjectAltName value decodes to at least one non-empty GeneralName. */ +function customSubjectAltNameHasIdentity(value: Uint8Array): boolean { + try { + return SUBJECT_ALT_NAME_EXTENSION_DEFINITION.decode(value).some(subjectAltNameHasIdentity); + } catch { + return false; + } +} + function appendConstraintExtensions( encoded: Uint8Array[], seen: Set, input: CertificateExtensionsInput, includeBasicConstraints: boolean, ): void { - assertPathLengthKeyUsage(input.basicConstraints, input.keyUsage); + assertPathLengthKeyUsage(input); if (includeBasicConstraints && input.basicConstraints !== undefined) { pushKnownExtension( encoded, @@ -1049,6 +1134,14 @@ function encodeIa5Content(value: string): Uint8Array { } } +/** RFC 5280 §4.2.1.6: reject an empty GeneralName string value (empty dNSName, rfc822Name, URI, or SRV name). */ +function requireNonEmptyName(value: string): string { + if (value.length === 0) { + throwExtensionEncoderError('empty_general_name_value', 'GeneralName value must not be empty'); + } + return value; +} + /** * DER-encode a single {@linkcode SubjectAltName} GeneralName element. * @@ -1057,17 +1150,17 @@ function encodeIa5Content(value: string): Uint8Array { export function encodeSubjectAltName(value: SubjectAltName): Uint8Array { switch (value.type) { case 'dns': - return implicitPrimitiveContext(2, encodeIa5Content(value.value)); + return implicitPrimitiveContext(2, encodeIa5Content(requireNonEmptyName(value.value))); case 'email': - return implicitPrimitiveContext(1, encodeIa5Content(value.value)); + return implicitPrimitiveContext(1, encodeIa5Content(requireNonEmptyName(value.value))); case 'uri': - return implicitPrimitiveContext(6, encodeIa5Content(value.value)); + return implicitPrimitiveContext(6, encodeIa5Content(requireNonEmptyName(value.value))); case 'srv': return implicitConstructedContext( 0, concatBytes([ objectIdentifier(OIDS.idOnDnsSrv), - explicitContext(0, tlv(0x16, encodeIa5Content(value.value))), + explicitContext(0, tlv(0x16, encodeIa5Content(requireNonEmptyName(value.value)))), ]), ); case 'ip': @@ -1354,6 +1447,28 @@ function encodeGeneralSubtree(subtree: GeneralSubtree): Uint8Array { return sequence([encodeNameConstraintForm(subtree.base)]); } +/** + * RFC 5280 §4.2.1.13: a nameRelativeToCRLIssuer distribution point requires + * cRLIssuer to hold exactly one directoryName (the CRL issuer's DN). + */ +function assertCrlIssuerDistinguishedNames(point: DistributionPoint): void { + if (point.crlIssuer === undefined || point.distributionPoint?.relativeName === undefined) { + return; + } + if (point.crlIssuer.some((name) => name.type !== 'directoryName')) { + throwExtensionEncoderError( + 'distribution_point_crl_issuer_not_directory_name', + 'DistributionPointName relativeName requires a directoryName cRLIssuer', + ); + } + if (point.crlIssuer.length > 1) { + throwExtensionEncoderError( + 'distribution_point_relative_name_multiple_crl_issuers', + 'DistributionPointName relativeName requires at most one cRLIssuer distinguished name', + ); + } +} + /** DER-encode the fields of a single DistributionPoint. */ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { if (point.crlIssuer !== undefined && point.crlIssuer.length === 0) { @@ -1368,15 +1483,7 @@ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { 'DistributionPoint must contain distributionPoint or crlIssuer', ); } - if ( - point.distributionPoint?.relativeName !== undefined && - (point.crlIssuer?.filter((name) => name.type === 'directoryName').length ?? 0) > 1 - ) { - throwExtensionEncoderError( - 'distribution_point_relative_name_multiple_crl_issuers', - 'DistributionPointName relativeName requires at most one cRLIssuer distinguished name', - ); - } + assertCrlIssuerDistinguishedNames(point); const fields: Uint8Array[] = []; if (point.distributionPoint !== undefined) { fields.push( diff --git a/test/certificate.test.ts b/test/certificate.test.ts index 94e22bf..c809f51 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -11,6 +11,7 @@ import { unwrap, verifyCertificateChain, } from '#micro509'; +import { toHex } from '#micro509/internal/asn1/asn1'; import { readElement, sequence } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/internal/crypto/rsa-pss'; @@ -237,6 +238,7 @@ describe('certificate', () => { keyUsage: ['keyCertSign', 'cRLSign'], }, }); + const altIssuerDnHex = toHex(encodeName({ commonName: 'Alt CRL Issuer' })); const leafKeys = await generateKeyPair(); const leaf = await createCertificate({ issuer: { commonName: 'Structured DP CA' }, @@ -254,10 +256,7 @@ describe('certificate', () => { ], }, reasons: ['keyCompromise', 'privilegeWithdrawn'], - crlIssuer: [ - { type: 'dns', value: 'crl-issuer.example.test' }, - { type: 'uri', value: 'http://issuer.example.test/alt.crl' }, - ], + crlIssuer: [{ type: 'directoryName', derHex: altIssuerDnHex }], }, { distributionPoint: { @@ -284,10 +283,7 @@ describe('certificate', () => { }, }, reasons: { flags: ['keyCompromise', 'privilegeWithdrawn'], nonZeroPadding: false }, - crlIssuer: [ - { type: 'dns', value: 'crl-issuer.example.test' }, - { type: 'uri', value: 'http://issuer.example.test/alt.crl' }, - ], + crlIssuer: [{ type: 'directoryName', derHex: altIssuerDnHex }], }); expect(parsed.crlDistributionPoints?.[1]).toEqual({ distributionPoint: { @@ -538,9 +534,19 @@ describe('certificate', () => { }), 'path_length_requires_key_cert_sign', ); + await expectRejectedErrorCode( + createSelfSignedCertificate({ + subject: { commonName: 'PathLen No KeyUsage' }, + extensions: { basicConstraints: { ca: true, pathLength: 0 } }, + }), + 'path_length_requires_key_cert_sign', + ); const qualified = await createSelfSignedCertificate({ - subject: { commonName: 'PathLen No KeyUsage' }, - extensions: { basicConstraints: { ca: true, pathLength: 0 } }, + subject: { commonName: 'PathLen With CertSign' }, + extensions: { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['keyCertSign'], + }, }); expect(unwrap(parseCertificateDer(qualified.certificate.der)).basicConstraints).toEqual({ ca: true, diff --git a/test/csr.test.ts b/test/csr.test.ts index 83c20ec..b88f22a 100644 --- a/test/csr.test.ts +++ b/test/csr.test.ts @@ -3,6 +3,7 @@ import { createCertificateSigningRequest, findExtension, generateKeyPair, + isResultError, parseCertificateSigningRequestPem, unwrap, verifyCertificateSigningRequest, @@ -18,6 +19,17 @@ import { rewriteCsrSignatureAsRsaPss, } from '#test/helpers'; +async function expectRejectedErrorCode(promise: Promise, code: string): Promise { + try { + await promise; + } catch (error) { + expect(isResultError(error)).toBe(true); + expect(isResultError(error) ? error.code : undefined).toBe(code); + return; + } + throw new Error(`expected a ResultError with code '${code}', but the promise resolved`); +} + describe('csr', () => { it('includes basicConstraints and customExtensions in CSR requested extensions', async () => { const keyPair = await generateKeyPair({ kind: 'ed25519' }); @@ -27,6 +39,7 @@ describe('csr', () => { signerPrivateKey: keyPair.privateKey, extensions: { basicConstraints: { ca: true, pathLength: 2 }, + keyUsage: ['keyCertSign'], customExtensions: [ { oid: '1.2.3.4.999', @@ -45,16 +58,18 @@ describe('csr', () => { it('rejects a requested pathLenConstraint without keyCertSign (RFC 5280 §4.2.1.9)', async () => { const keyPair = await generateKeyPair({ kind: 'ed25519' }); - const pending = createCertificateSigningRequest({ - subject: { commonName: 'csr-plc.example' }, - publicKey: keyPair.publicKey, - signerPrivateKey: keyPair.privateKey, - extensions: { - basicConstraints: { ca: true, pathLength: 0 }, - keyUsage: ['digitalSignature'], - }, - }); - await expect(pending).rejects.toThrow('path_length_requires_key_cert_sign'); + await expectRejectedErrorCode( + createCertificateSigningRequest({ + subject: { commonName: 'csr-plc.example' }, + publicKey: keyPair.publicKey, + signerPrivateKey: keyPair.privateKey, + extensions: { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['digitalSignature'], + }, + }), + 'path_length_requires_key_cert_sign', + ); }); it('verifies certificate request signatures for RSA and Ed25519', async () => { diff --git a/test/internals.test.ts b/test/internals.test.ts index 46645f6..1ca0d54 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -862,6 +862,25 @@ describe('extensions encoding', () => { expectEncoderErrorCode(() => encodeNameConstraints({}), 'name_constraints_empty'); }); + it('rejects empty GeneralName string values (RFC 5280 §4.2.1.6)', () => { + expectEncoderErrorCode( + () => encodeSubjectAltName({ type: 'dns', value: '' }), + 'empty_general_name_value', + ); + expectEncoderErrorCode( + () => encodeSubjectAltName({ type: 'email', value: '' }), + 'empty_general_name_value', + ); + expectEncoderErrorCode( + () => encodeSubjectAltName({ type: 'uri', value: '' }), + 'empty_general_name_value', + ); + expectEncoderErrorCode( + () => encodeSubjectAltName({ type: 'srv', value: '' }), + 'empty_general_name_value', + ); + }); + it('rejects a relativeName distribution point with multiple cRLIssuer DNs (RFC 5280 §4.2.1.13)', () => { const issuerA = toHex(encodeName({ commonName: 'CRL Issuer A' })); const issuerB = toHex(encodeName({ commonName: 'CRL Issuer B' })); @@ -879,17 +898,35 @@ describe('extensions encoding', () => { ]), 'distribution_point_relative_name_multiple_crl_issuers', ); - expect( - encodeCrlDistributionPoints([ - { - distributionPoint: { relativeName }, - crlIssuer: [ - { type: 'directoryName', derHex: issuerA }, - { type: 'uri', value: 'http://example.test/backup.crl' }, - ], - }, - ]), - ).toBeInstanceOf(Uint8Array); + expectEncoderErrorCode( + () => + encodeCrlDistributionPoints([ + { + distributionPoint: { relativeName }, + crlIssuer: [ + { type: 'directoryName', derHex: issuerA }, + { type: 'uri', value: 'http://example.test/backup.crl' }, + ], + }, + ]), + 'distribution_point_crl_issuer_not_directory_name', + ); + expectEncoderErrorCode( + () => + encodeCrlDistributionPoints([ + { + distributionPoint: { relativeName }, + crlIssuer: [ + { + type: 'unknown', + tag: 0xa4, + value: encodeName({ commonName: 'Hidden CRL Issuer' }), + }, + ], + }, + ]), + 'distribution_point_crl_issuer_not_directory_name', + ); }); const subjectPublicKeyInfo = sequence([ @@ -897,7 +934,7 @@ describe('extensions encoding', () => { bitString(Uint8Array.of(0x01, 0x02, 0x03)), ]); - it('rejects pathLenConstraint without keyCertSign when keyUsage is present (RFC 5280 §4.2.1.9)', () => { + it('rejects pathLenConstraint without an effective keyCertSign keyUsage (RFC 5280 §4.2.1.9)', () => { expectEncoderErrorCode( () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, { @@ -906,6 +943,21 @@ describe('extensions encoding', () => { }), 'path_length_requires_key_cert_sign', ); + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + }), + 'path_length_requires_key_cert_sign', + ); + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['digitalSignature']) }], + }), + 'path_length_requires_key_cert_sign', + ); expect( buildCertificateExtensions(subjectPublicKeyInfo, undefined, { basicConstraints: { ca: true, pathLength: 0 }, @@ -915,6 +967,7 @@ describe('extensions encoding', () => { expect( buildCertificateExtensions(subjectPublicKeyInfo, undefined, { basicConstraints: { ca: true, pathLength: 0 }, + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['keyCertSign']) }], }), ).toBeInstanceOf(Array); }); @@ -929,6 +982,26 @@ describe('extensions encoding', () => { buildCertificateExtensions(subjectPublicKeyInfo, undefined, { subjectAltNames: [] }, true), 'empty_subject_requires_subject_alt_name', ); + expectEncoderErrorCode( + () => + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { subjectAltNames: [{ type: 'dns', value: '' }] }, + true, + ), + 'empty_subject_requires_subject_alt_name', + ); + expectEncoderErrorCode( + () => + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { customExtensions: [{ oid: OIDS.subjectAltName, critical: true, value: sequence([]) }] }, + true, + ), + 'empty_subject_requires_subject_alt_name', + ); expect( buildCertificateExtensions( subjectPublicKeyInfo, @@ -937,6 +1010,22 @@ describe('extensions encoding', () => { true, ), ).toBeInstanceOf(Array); + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { + customExtensions: [ + { + oid: OIDS.subjectAltName, + critical: true, + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'custom-san.example' })]), + }, + ], + }, + true, + ), + ).toBeInstanceOf(Array); }); it('rejects an IP name constraint whose address and mask do not form 8 or 32 octets', () => { diff --git a/test/parse.test.ts b/test/parse.test.ts index 8f2e8c7..33ca4a5 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -267,6 +267,7 @@ describe('parse', () => { subject: { commonName: 'bad-basic-constraints.example' }, extensions: { basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['keyCertSign'], }, }); From 42599b06a6b7e31e06e8e3c4cc079cd0872a582c Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 24 Jul 2026 19:55:04 +0200 Subject: [PATCH 04/14] fix(x509): enforce DN-only cRLIssuer and cover new builder guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 5280 §4.2.1.13 lines 88-91 make cRLIssuer DN-only a MUST for every case, not just nameRelativeToCRLIssuer. The builder now rejects any non-directoryName cRLIssuer entry unconditionally; relativeName keeps the extra at-most-one constraint. The parser and revocation scanner stay tolerant of non-DN cRLIssuer for real-world certificates, fed through a raw-DER test helper instead of the conformant builder. Cover every new branch: custom-basicConstraints/keyUsage decode paths (including malformed-value fallbacks), the canonical leading-zero OID match, and the non-string SAN identity forms. Replace the SAN identity switch with a ternary, dropping an unreachable exhaustiveness guard. --- CHANGELOG.md | 9 ++-- src/x509/extensions.ts | 27 +++--------- test/certificate.test.ts | 45 ++++++++++++++++---- test/crl.test.ts | 32 ++++++++------ test/helpers.ts | 36 ++++++++++++++++ test/internals.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 194 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056016a..fbf32ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,10 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 `nameRelativeToCRLIssuer` - distribution point requires `cRLIssuer` to hold exactly one `directoryName`, - rejecting a non-DN entry or a directoryName smuggled through an `unknown` - general name (§4.2.1.13, `distribution_point_crl_issuer_not_directory_name`, + 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. (https://github.com/kjanat/micro509/pull/88) diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index e85055a..571c50c 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -906,22 +906,7 @@ function resolveEffectiveKeyUsage( /** Whether a typed GeneralName carries a non-empty identity value. */ function subjectAltNameHasIdentity(name: SubjectAltName): boolean { - switch (name.type) { - case 'dns': - case 'email': - case 'uri': - case 'srv': - case 'ip': - return name.value.length > 0; - case 'directoryName': - return name.derHex.length > 0; - case 'unknown': - return name.value.length > 0; - default: { - const _exhaustive: never = name; - throw new Error(`Unhandled SubjectAltName type: ${String(_exhaustive)}`); - } - } + return name.type === 'directoryName' ? name.derHex.length > 0 : name.value.length > 0; } /** Whether a custom subjectAltName value decodes to at least one non-empty GeneralName. */ @@ -1448,20 +1433,20 @@ function encodeGeneralSubtree(subtree: GeneralSubtree): Uint8Array { } /** - * RFC 5280 §4.2.1.13: a nameRelativeToCRLIssuer distribution point requires - * cRLIssuer to hold exactly one directoryName (the CRL issuer's DN). + * RFC 5280 §4.2.1.13: cRLIssuer, when present, only contains the CRL issuer's + * distinguished name; nameRelativeToCRLIssuer additionally requires exactly one. */ function assertCrlIssuerDistinguishedNames(point: DistributionPoint): void { - if (point.crlIssuer === undefined || point.distributionPoint?.relativeName === undefined) { + if (point.crlIssuer === undefined) { return; } if (point.crlIssuer.some((name) => name.type !== 'directoryName')) { throwExtensionEncoderError( 'distribution_point_crl_issuer_not_directory_name', - 'DistributionPointName relativeName requires a directoryName cRLIssuer', + 'DistributionPoint cRLIssuer must only contain directoryName entries', ); } - if (point.crlIssuer.length > 1) { + if (point.distributionPoint?.relativeName !== undefined && point.crlIssuer.length > 1) { throwExtensionEncoderError( 'distribution_point_relative_name_multiple_crl_issuers', 'DistributionPointName relativeName requires at most one cRLIssuer distinguished name', diff --git a/test/certificate.test.ts b/test/certificate.test.ts index c809f51..c559742 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -16,7 +16,12 @@ import { readElement, sequence } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/internal/crypto/rsa-pss'; import { encodeName, encodeSubjectAltName } from '#micro509/x509'; -import { childrenOf, decodeObjectIdentifier, hasExtensionOid } from '#test/helpers'; +import { + childrenOf, + decodeObjectIdentifier, + encodeUncheckedCrlDistributionPoints, + hasExtensionOid, +} from '#test/helpers'; async function expectRejectedErrorCode(promise: Promise, code: string): Promise { try { @@ -295,17 +300,21 @@ describe('certificate', () => { }); }); - it('roundtrips CRL distribution points that only name an alternate CRL issuer', async () => { + it('parses issuer-only CRL distribution points that name a non-DN CRL issuer', async () => { const { certificate } = await createSelfSignedCertificate({ subject: { commonName: 'issuer-only-dp.example' }, extensions: { - crlDistributionPoints: [ + customExtensions: [ { - reasons: ['cACompromise'], - crlIssuer: [ - { type: 'dns', value: 'indirect-issuer.example.test' }, - { type: 'uri', value: 'http://issuer.example.test/indirect.crl' }, - ], + oid: OIDS.cRLDistributionPoints, + value: encodeUncheckedCrlDistributionPoints([ + { + crlIssuer: [ + { type: 'dns', value: 'indirect-issuer.example.test' }, + { type: 'uri', value: 'http://issuer.example.test/indirect.crl' }, + ], + }, + ]), }, ], }, @@ -314,7 +323,6 @@ describe('certificate', () => { const parsed = unwrap(parseCertificatePem(certificate.pem)); expect(parsed.crlDistributionPoints).toEqual([ { - reasons: { flags: ['cACompromise'], nonZeroPadding: false }, crlIssuer: [ { type: 'dns', value: 'indirect-issuer.example.test' }, { type: 'uri', value: 'http://issuer.example.test/indirect.crl' }, @@ -323,6 +331,25 @@ describe('certificate', () => { ]); }); + it('rejects a non-directoryName cRLIssuer at build (RFC 5280 §4.2.1.13)', async () => { + await expectRejectedErrorCode( + createSelfSignedCertificate({ + subject: { commonName: 'nondn-crl-issuer.example' }, + extensions: { + crlDistributionPoints: [ + { + distributionPoint: { + fullName: [{ type: 'uri', value: 'http://example.test/nondn.crl' }], + }, + crlIssuer: [{ type: 'uri', value: 'http://example.test/issuer.crl' }], + }, + ], + }, + }), + 'distribution_point_crl_issuer_not_directory_name', + ); + }); + it('roundtrips email, URI, and IPv6 SANs through build and parse', async () => { const { certificate } = await createSelfSignedCertificate({ subject: { commonName: 'san-variety' }, diff --git a/test/crl.test.ts b/test/crl.test.ts index a88fc65..7690fda 100644 --- a/test/crl.test.ts +++ b/test/crl.test.ts @@ -33,6 +33,7 @@ import { addRevokedEntryCertificateIssuers, childrenOf, decodeObjectIdentifier, + encodeUncheckedCrlDistributionPoints, hexToBytes, sliceElement, } from '#test/helpers'; @@ -170,6 +171,7 @@ describe('crl', () => { keyUsage: ['keyCertSign', 'cRLSign'], }, }); + const deltaIssuerDnHex = unwrap(parseCertificatePem(issuer.certificate.pem)).subject.derHex; const crl = await createCertificateRevocationList({ issuer: { commonName: 'Structured CRL Issuer' }, signerPrivateKey: issuer.keyPair.privateKey, @@ -193,7 +195,7 @@ describe('crl', () => { ], }, reasons: ['cACompromise'], - crlIssuer: [{ type: 'dns', value: 'delta-issuer.example.test' }], + crlIssuer: [{ type: 'directoryName', derHex: deltaIssuerDnHex }], }, { distributionPoint: { @@ -225,7 +227,7 @@ describe('crl', () => { ], }, reasons: { flags: ['cACompromise'], nonZeroPadding: false }, - crlIssuer: [{ type: 'dns', value: 'delta-issuer.example.test' }], + crlIssuer: [{ type: 'directoryName', derHex: deltaIssuerDnHex }], }); expect(parsed.freshestCrlDistributionPoints?.[1]).toMatchObject({ distributionPoint: { @@ -620,12 +622,15 @@ describe('crl', () => { signerPrivateKey: certIssuer.keyPair.privateKey, issuerPublicKey: certIssuer.keyPair.publicKey, extensions: { - crlDistributionPoints: [ + customExtensions: [ { - distributionPoint: { - fullName: [{ type: 'uri', value: 'http://example.test/direct.crl' }], - }, - crlIssuer: [{ type: 'dns', value: 'alternate.example.test' }], + oid: OIDS.cRLDistributionPoints, + value: encodeUncheckedCrlDistributionPoints([ + { + fullNameUri: 'http://example.test/direct.crl', + crlIssuer: [{ type: 'dns', value: 'alternate.example.test' }], + }, + ]), }, ], }, @@ -1635,12 +1640,15 @@ describe('crl', () => { signerPrivateKey: certificateIssuer.keyPair.privateKey, issuerPublicKey: certificateIssuer.keyPair.publicKey, extensions: { - crlDistributionPoints: [ + customExtensions: [ { - distributionPoint: { - fullName: [{ type: 'uri', value: 'http://example.test/unsupported-crl-issuer.crl' }], - }, - crlIssuer: [{ type: 'dns', value: 'unsupported.example.test' }], + oid: OIDS.cRLDistributionPoints, + value: encodeUncheckedCrlDistributionPoints([ + { + fullNameUri: 'http://example.test/unsupported-crl-issuer.crl', + crlIssuer: [{ type: 'dns', value: 'unsupported.example.test' }], + }, + ]), }, ], }, diff --git a/test/helpers.ts b/test/helpers.ts index 62f8579..77e8d73 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -6,6 +6,7 @@ import { bool, concatBytes, explicitContext, + implicitConstructedContext, integer, integerFromNumber, nullValue, @@ -33,6 +34,41 @@ import { } from '#micro509/x509'; import { probeOpenSsl } from '#test/oracles/openssl'; +/** + * Encode a CRLDistributionPoints value with an arbitrary cRLIssuer, bypassing the + * builder's RFC 5280 §4.2.1.13 directoryName validation. Feeds the parser and + * revocation scanner the non-conformant inputs the conformant builder refuses to emit. + */ +export function encodeUncheckedCrlDistributionPoints( + points: readonly { + readonly fullNameUri?: string; + readonly crlIssuer?: readonly GeneralName[]; + }[], +): Uint8Array { + return sequence( + points.map((point) => { + const fields: Uint8Array[] = []; + if (point.fullNameUri !== undefined) { + fields.push( + implicitConstructedContext( + 0, + implicitConstructedContext( + 0, + encodeSubjectAltName({ type: 'uri', value: point.fullNameUri }), + ), + ), + ); + } + if (point.crlIssuer !== undefined) { + fields.push( + implicitConstructedContext(2, concatBytes(point.crlIssuer.map(encodeSubjectAltName))), + ); + } + return sequence(fields); + }), + ); +} + export function childrenOf( source: Uint8Array, parent: { readonly start: number; readonly end: number }, diff --git a/test/internals.test.ts b/test/internals.test.ts index 1ca0d54..609d41d 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -91,7 +91,9 @@ import { import { createPkcs12MacData, parsePkcs12MacDataOrThrow } from '#micro509/pkcs'; import { buildCertificateExtensions, + buildRequestedExtensions, encodeAuthorityInfoAccess, + encodeBasicConstraints, encodeCertificatePolicies, encodeCrlDistributionPoints, encodeExtendedKeyUsage, @@ -970,6 +972,43 @@ describe('extensions encoding', () => { customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['keyCertSign']) }], }), ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + keyUsage: ['digitalSignature'], + customExtensions: [ + { + oid: OIDS.basicConstraints, + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + }, + ], + }), + 'path_length_requires_key_cert_sign', + ); + expect( + buildRequestedExtensions({ + keyUsage: ['keyCertSign'], + customExtensions: [ + { + oid: OIDS.basicConstraints, + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + }, + ], + }), + ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + basicConstraints: { ca: true, pathLength: 0 }, + customExtensions: [{ oid: OIDS.keyUsage, value: Uint8Array.of(0x05, 0x00) }], + }), + 'path_length_requires_key_cert_sign', + ); + expect( + buildRequestedExtensions({ + customExtensions: [{ oid: OIDS.basicConstraints, value: Uint8Array.of(0x05, 0x00) }], + }), + ).toBeInstanceOf(Array); }); it('rejects an empty subject without a critical subjectAltName (RFC 5280 §4.2.1.6)', () => { @@ -1028,6 +1067,58 @@ describe('extensions encoding', () => { ).toBeInstanceOf(Array); }); + it('accepts empty-subject SANs across non-string GeneralName forms (RFC 5280 §4.2.1.6)', () => { + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { subjectAltNames: [{ type: 'ip', value: '192.0.2.1' }] }, + true, + ), + ).toBeInstanceOf(Array); + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { + subjectAltNames: [ + { type: 'directoryName', derHex: toHex(encodeName({ commonName: 'SAN DN' })) }, + ], + }, + true, + ), + ).toBeInstanceOf(Array); + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { subjectAltNames: [{ type: 'unknown', tag: 0x88, value: Uint8Array.of(0x2a) }] }, + true, + ), + ).toBeInstanceOf(Array); + }); + + it('recognizes a critical custom SAN under a redundant-leading-zero OID (RFC 5280 §4.2.1.6)', () => { + expect( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { + customExtensions: [ + { + oid: '2.5.029.17', + critical: true, + value: sequence([ + encodeSubjectAltName({ type: 'dns', value: 'canonical-oid.example' }), + ]), + }, + ], + }, + true, + ), + ).toBeInstanceOf(Array); + }); + it('rejects an IP name constraint whose address and mask do not form 8 or 32 octets', () => { expectEncoderErrorCode( () => From e231d355ffbd0a29f107ffdb0bd377ad88a1581c Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 00:01:12 +0200 Subject: [PATCH 05/14] fix(x509): resolve extension OIDs canonically and validate custom payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three escapes survived the first pass at the builder cross-field guards, all reachable through the public API with green CI. `pushExtension` keyed its duplicate set by raw OID text and `appendCustomExtensions` looked the registry up the same way. Since `2.5.029.17` and `2.5.29.17` encode to identical DER, a typed `subjectAltNames` plus a custom `2.5.029.17` emitted two wire-identical SAN OIDs that this library's own parser then rejected, and a custom `2.5.029.18` evaded the certificate-only restriction on `issuerAltName`. Both keys are now the canonical form; the diagnostic still quotes the OID as submitted, since that is the string the caller can find in their input. The effective-value resolvers swallowed DER decode failures and returned `undefined`, so a `customExtensions` entry carrying a known OID with a malformed payload passed every cross-field guard and reached the wire unchecked. Known payloads are now decoded up front, ahead of those guards, which also lets the three resolvers drop their catch clauses. `parseGeneralName` accepted a zero-length `dNSName`, `rfc822Name`, or `uniformResourceIdentifier`, which RFC 5280 §4.2.1.6 forbids. An external certificate could carry an empty subjectAltName value and parse, leaving chain verification to accept a certificate with no usable identity. Builder strictness means parser-strictness fixtures can no longer be built through the builder, so `test/helpers.ts` gains raw-extension variants of `createCertificate`, `createSelfSignedCertificate`, and `createCertificateSigningRequest` that splice extensions into the signed structure. --- CHANGELOG.md | 17 +++ bun.lock | 202 ++++++++++++++++-------- package.json | 37 ++--- src/internal/asn1/asn1.ts | 14 +- src/internal/x509/extension-errors.ts | 1 + src/internal/x509/general-name.ts | 22 ++- src/x509/extensions.ts | 125 ++++++++------- test/helpers.ts | 211 +++++++++++++++++++++++++- test/internals.test.ts | 79 +++++++++- test/keys.test.ts | 28 ++-- test/parse.test.ts | 119 +++++++++++---- test/verify.test.ts | 3 +- 12 files changed, 668 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0fb7da..4f70d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (§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. + (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 diff --git a/bun.lock b/bun.lock index 22943d0..4e43181 100644 --- a/bun.lock +++ b/bun.lock @@ -123,25 +123,25 @@ "catalog": { "biome": "npm:@biomejs/biome@^2.5.5", "dprint": "^0.55.2", - "runner-run": "^0.21.0", + "runner-run": "^0.23.0", "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", @@ -200,31 +200,31 @@ "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.46.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.16.1", "miniflare": "4.20260721.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1", "wrangler": "4.113.0", "ws": "8.21.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0 || ^8.0.0" }, "bin": { "cf-vite": "bin/cf-vite" } }, "sha512-+pnxcFWo+kMozeCah9CxYI6VywMQmBBfEiGJZgYkIji+vnNWkWEC0WkL5MQWHCBIiEPIbcoZDQiKr6yruLFI2Q=="], - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260721.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260722.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ=="], - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260721.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA=="], + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260722.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw=="], - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260721.1", "", { "os": "linux", "cpu": "x64" }, "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ=="], + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260722.1", "", { "os": "linux", "cpu": "x64" }, "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ=="], - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260721.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ=="], + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260722.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg=="], - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260721.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ=="], + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260722.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@deno/darwin-arm64": ["@deno/darwin-arm64@2.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-G9BiE99ufFiZPQVN8oGIib1oIGdi3uIAuwfet7PkjPX7OFgC484nFJiRV/BsZDpCtFaeUi9zz8WYikXyLxtN/w=="], + "@deno/darwin-arm64": ["@deno/darwin-arm64@2.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pjq1OdRauzaWtFB8W4FJuXp0U/if2tEzlQEgbmVflNIUJhUVJh0jUjxAgVP84P5vYSaKJTzXAHaftNH5PbQcFA=="], - "@deno/darwin-x64": ["@deno/darwin-x64@2.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-JEPCaAkqHODcqjyY5a5ScOlPEAn/6Iyx44ldRpk9+gq0XjLR0rsvI6Xe2mt9cuQF1Yp8cvpvi1meSi1TYoE0nQ=="], + "@deno/darwin-x64": ["@deno/darwin-x64@2.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-gsKNBOeU0iOgl/kYLItvdtcrAQhK3W0z4EdPVyF8NVZfzB+iqkZbTwbq0tgIcZbo3fpKFaJ6gT53XvaFIwVK5Q=="], "@deno/doc": ["@jsr/deno__doc@0.199.0", "https://npm.jsr.io/~/11/@jsr/deno__doc/0.199.0.tgz", { "dependencies": { "@jsr/deno__cache-dir": "^0.25.0", "@jsr/deno__graph": "^0.100" } }, "sha512-PLTYaUlFA6qUsU5CthVcEdYiaaHEAn8e8x9R0/tw1aaYd1qvEp8qYu76Labf4Bwg2jJ0CmWBfKaGRka1rQ96RA=="], - "@deno/linux-arm64-glibc": ["@deno/linux-arm64-glibc@2.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-N9OnMg9Ah1k2hvbqo942/lD6IA6ELs4NYYOYtTVuekKgupyhKtVMpNqwgDzfqxL/P4eebk4+l9/oKTw4NzpKdg=="], + "@deno/linux-arm64-glibc": ["@deno/linux-arm64-glibc@2.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-SHHmaFnfOtFe0RWEAqo0lGlKFww21O6utyNzcFNYpIFRyQSKXKrfegwYMYP7hN1GEhZ49bDPKIpjJHjPbGFqyw=="], - "@deno/linux-x64-glibc": ["@deno/linux-x64-glibc@2.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HSChBN7EkeErbDYVVu16dhLVHVIe/U+8eOAedrsuOsSThE+4Ln+IOfI+UnNU09pqnWc/ZMDI+fVRuAmKX/PRaw=="], + "@deno/linux-x64-glibc": ["@deno/linux-x64-glibc@2.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Or3ItpBDUC4GKtE723T4LpWs1nKOAQ4Zt8E+tufFeEnX9k+OhYOQTnu8kXqz+8FM42JKeuegfss8pr7k0PVQTQ=="], - "@deno/win32-arm64": ["@deno/win32-arm64@2.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-b0/yzyB4BrTzDy37fHOVoKChP5XgKxoShWkDVXcBNEh8ezxKwTgoHA8HO2BGkynrFoQ5HqxyQ6WXqSwFDSI21w=="], + "@deno/win32-arm64": ["@deno/win32-arm64@2.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Xcvs+3W7vAoSQAzyhXr954s6hDC6N95oCHQelWiZt3fbUWPJjUzxtUP4EC4AygC+EC0zYBgIlDGv6tmJ8dwSAQ=="], - "@deno/win32-x64": ["@deno/win32-x64@2.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-d+z3ySG+sP8a7NYFf739z1QwEcWxjKYAnc6YvDErwmc/1JdjarPbKJNXhug2rb2XViM29t4t9jmQKU/+qHYMJg=="], + "@deno/win32-x64": ["@deno/win32-x64@2.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-u69MJjnr/4ttQ1a/lMVch57LJJgzplEIDiHepZX+yHOw9Q4FdWinr9Gj0oZ/hpEnTvVtih/CZc3IvYagBqQBag=="], "@docsearch/css": ["@docsearch/css@4.6.3", "", {}, "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ=="], @@ -428,7 +428,7 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], - "@publint/pack": ["@publint/pack@0.1.5", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw=="], + "@publint/pack": ["@publint/pack@0.1.6", "", { "dependencies": { "tinyexec": "^1.2.4" } }, "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -464,31 +464,31 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@runner-run/darwin-arm64": ["@runner-run/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-pL5Eqk8ikAHEH7do5CHC83KYxuGPMZ7hrKU+mPNnqX5RrCafNALahsyOwvsdUC7QlWoeSYRNvcEZ0oN9J1xfZg=="], + "@runner-run/darwin-arm64": ["@runner-run/darwin-arm64@0.23.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-0vWAyacE9cNTZ+yBK6WYVO0jWmF//s40EFrETiclZRWw/7EOHTBpWyWJtVR1+gPiwjjrY7p89EnZJD/VHFe7lw=="], - "@runner-run/darwin-x64": ["@runner-run/darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-/YxUgaZTr2wznpRElPFQ4zEM50NGU5sDqhNpbStaW6kZTnyRz19NZp/1D6Ukhn6Hei48x0DE3EhRVQywWfVe3Q=="], + "@runner-run/darwin-x64": ["@runner-run/darwin-x64@0.23.0", "", { "os": "darwin", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-hyssc1LxDiJqiwGFxKfWm+kd4ZFv6iIVfwKLwByN1zmhPQUiNWAn8KYiDXcaJ4lQCGyqha077ZTra311rdO+dA=="], - "@runner-run/freebsd-arm64": ["@runner-run/freebsd-arm64@0.21.0", "", { "os": "freebsd", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-+8pQPlVTwvizkg85HU14oKZ7fyginmleGq5lYeIijA6kpIyWDieFlsFM63ime4JbIDxcYpjjey0vGqDEnUiCSQ=="], + "@runner-run/freebsd-arm64": ["@runner-run/freebsd-arm64@0.23.0", "", { "os": "freebsd", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-sssNlEOkcaRKiUMO2wCiYT9og6tx58l+ZBy/795wjrFS0KkyZF40CM3zZ0cbXOCIdJnKhwF6WrbpmmwRTOoVsw=="], - "@runner-run/freebsd-x64": ["@runner-run/freebsd-x64@0.21.0", "", { "os": "freebsd", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-+DThO+9bYfJa0vbORK3/G1E/CcoAqvPQwm4kGjo/nAy6Uu8dFwCZLvXwawGlCFKSF1cKgGwmRLz46voNtlpRxg=="], + "@runner-run/freebsd-x64": ["@runner-run/freebsd-x64@0.23.0", "", { "os": "freebsd", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-uFbcQfSsApmMUZ6JvAeW5720jnV1IzK3megMx3aSluFV+2qRhtg5RV8o0uPMg+We6X+9sbDHllyQzYuBqkj2kg=="], - "@runner-run/linux-arm64-gnu": ["@runner-run/linux-arm64-gnu@0.21.0", "", { "os": "linux", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-GqeYIwMECQQvV5zupXhV4EH6lNFDQrhQMdgmDi1kS6YI0yn7M99ApVVSTCXcEUXRgrTQVbyBHoecDOEohGyshg=="], + "@runner-run/linux-arm64-gnu": ["@runner-run/linux-arm64-gnu@0.23.0", "", { "os": "linux", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-pqJBlIh+IwTjCyBQ0k84HABXka/ILQqUYA0ArgbA04x2eXmOkI1ctgLgNKZtc3Ft6zJtqPkEhlNwbmnFMJogwA=="], - "@runner-run/linux-arm64-musl": ["@runner-run/linux-arm64-musl@0.21.0", "", { "os": "linux", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-m9vdOdlOWnYiVKQsFxAFLZv8F2hhuAfx5MTtMBSHCEYOV9gxvpCOVK4mys9hvHUB3FJKIzz8YKmyK0PrVzt5FQ=="], + "@runner-run/linux-arm64-musl": ["@runner-run/linux-arm64-musl@0.23.0", "", { "os": "linux", "cpu": "arm64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-LvhpYcxxn3hKdbq1UOBX2ZhyL1JzIE1TliagtTmGsXDyMEhVTxfwsfqx1VoCnPddg/F00v4dilG5hQbfuhf55w=="], - "@runner-run/linux-armv7-gnueabihf": ["@runner-run/linux-armv7-gnueabihf@0.21.0", "", { "os": "linux", "cpu": "arm", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-4DPhrI+JRrSImnSnoNELVtTGv+zGnmHjtiQOtBCPciZw2DhCfcHIwTG+CK8sFbs5OzWpLpDlE3BVaWrYUvFbAw=="], + "@runner-run/linux-armv7-gnueabihf": ["@runner-run/linux-armv7-gnueabihf@0.23.0", "", { "os": "linux", "cpu": "arm", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-tW2R9pe3IOSOEYSXwS+mcKPj6YDswCQi/R/p+C4omUSgsMLrh02oKE5/xaCZQOyVshqWdnOu1UZiicbA1+r8Vg=="], - "@runner-run/linux-x64-gnu": ["@runner-run/linux-x64-gnu@0.21.0", "", { "os": "linux", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-Wue3bjVWRehKDTf1QjI1xaMuHiZXzX06/aPOlNTtgStV+R5VD8G55hobvMJnwUV0m8l0twSaRKdqz8X5cGrAZA=="], + "@runner-run/linux-x64-gnu": ["@runner-run/linux-x64-gnu@0.23.0", "", { "os": "linux", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-9rsKLZWe3HuS4IrG5ln+V79OuXPCahbkQTHLQHTFNt1XxQDC7k8O123NQbZPx60gQjUZnKi+mAby6PMml1w3Ow=="], - "@runner-run/linux-x64-musl": ["@runner-run/linux-x64-musl@0.21.0", "", { "os": "linux", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-Rm3EY2dWaddFQsukOda0Y4WpVY8YZ9seJZaTWWlSqZ4KQ5zIOCTYRJejSmfoo1Lx1cHuVnsavvxeeDkWPaat/Q=="], + "@runner-run/linux-x64-musl": ["@runner-run/linux-x64-musl@0.23.0", "", { "os": "linux", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-KNg+apGAaNbmssKH4B41vzyabE+SgImY/x3NNTuNjR5VKJc+vb23TtlnHOPAsmNorEz147x4QNirYb/XrByL/A=="], - "@runner-run/netbsd-x64": ["@runner-run/netbsd-x64@0.21.0", "", { "os": "none", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-+rSv4+BUBnrTO7EGsTrbtJWxYofWflIytOt4ModedQL2wnFF6Fzj+qcOBxnZQYfl/LJdU/g93gC6S/9VtGwlXA=="], + "@runner-run/netbsd-x64": ["@runner-run/netbsd-x64@0.23.0", "", { "os": "none", "cpu": "x64", "bin": { "runner": "bin/runner", "run": "bin/run" } }, "sha512-a4ioPimVjYGBVaBz0N+jquTsvJDORh6weCHQX+WbdPuawitrWB7O5g2vi6i/dw8S25WdBeYbM/SIr7lhA6DbeA=="], - "@runner-run/win32-arm64-msvc": ["@runner-run/win32-arm64-msvc@0.21.0", "", { "os": "win32", "cpu": "arm64", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-hwXdSu9prVVB4h8/ejX3mH88z8j11wj6K9p273T+wUUAQRhr0hJLPH5ZN17JQNm9O7PcmsGSoaUEd0+mfIE0TQ=="], + "@runner-run/win32-arm64-msvc": ["@runner-run/win32-arm64-msvc@0.23.0", "", { "os": "win32", "cpu": "arm64", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-YQTb5RRUDzkX2eQkUxvjht2mUGtmLVb3TE3QzVtzXhBR9OTr4BZNuMpepM5DTHGYfyd1SZ6t6dohfy4XacZ8rg=="], - "@runner-run/win32-ia32-msvc": ["@runner-run/win32-ia32-msvc@0.21.0", "", { "os": "win32", "cpu": "ia32", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-bMiUyHD2tbuFn7umJ/A2cbCO3yQgxBFiHHdplEAX3roLOl6HJxgolwcvI2MjtqnCjEXkgEDOG1kPqgDOgmr8wA=="], + "@runner-run/win32-ia32-msvc": ["@runner-run/win32-ia32-msvc@0.23.0", "", { "os": "win32", "cpu": "ia32", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-1Q59SS2ds0TvzPA+GFbpyWnDJKAulLHVU5Yqtj6/gMVO9TK4cer5tJYiZ1ZRBLLLZ43SQuy7IHmo++4akGLoHQ=="], - "@runner-run/win32-x64-msvc": ["@runner-run/win32-x64-msvc@0.21.0", "", { "os": "win32", "cpu": "x64", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-jMh/WJu6t/n7Ec5AOJhuuAQ6bFoGjqOBF+2xXU3gq960MZ0Ikap8DfSmEz6KbSeFWbj0pkR3lT9bJrQ3H579+A=="], + "@runner-run/win32-x64-msvc": ["@runner-run/win32-x64-msvc@0.23.0", "", { "os": "win32", "cpu": "x64", "bin": { "runner": "bin/runner.exe", "run": "bin/run.exe" } }, "sha512-dR/mowjxPFNnZkCf9qkS1PIiZ1/IrqyXIcxTdcu53vuU8HBd3uYw0YnfEfAdn+Ytc2YA6u7QI+HhbuCzRCxLPg=="], "@shikijs/core": ["@shikijs/core@4.3.1", "", { "dependencies": { "@shikijs/primitive": "4.3.1", "@shikijs/types": "4.3.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA=="], @@ -702,7 +702,7 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "deno": ["deno@2.9.3", "", { "optionalDependencies": { "@deno/darwin-arm64": "2.9.3", "@deno/darwin-x64": "2.9.3", "@deno/linux-arm64-glibc": "2.9.3", "@deno/linux-x64-glibc": "2.9.3", "@deno/win32-arm64": "2.9.3", "@deno/win32-x64": "2.9.3" }, "bin": { "deno": "bin.cjs" } }, "sha512-OuR2ZfUqnsXFcKHQIfyG2GDkZdsjFxMIlkWOiozbo3IpMUv1Meja45PmOiMtgzRedc627C54I1arWxn/FarEHg=="], + "deno": ["deno@2.9.4", "", { "optionalDependencies": { "@deno/darwin-arm64": "2.9.4", "@deno/darwin-x64": "2.9.4", "@deno/linux-arm64-glibc": "2.9.4", "@deno/linux-x64-glibc": "2.9.4", "@deno/win32-arm64": "2.9.4", "@deno/win32-x64": "2.9.4" }, "bin": { "deno": "bin.cjs" } }, "sha512-fK8l+v1DGlsLN8Xich0tP6zCKU9wD22lmtZObebSXTqX8ZzVqasDguUz+V0OwOwCuzUtq9jweHfOrvvocQftYQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -748,7 +748,7 @@ "import-without-cache": ["import-without-cache@0.4.0", "", {}, "sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ=="], - "importmapify": ["importmapify@1.6.1", "", { "dependencies": { "ansispeck": "^0.4.1", "dreamcli": "npm:@kjanat/dreamcli@^3.0.1" }, "bin": { "importmapify": "dist/mod.mjs" } }, "sha512-5C8PYF6BZXVOXKU/wuHSLrxtTlIgZHMrBePwXI6sHMZ5ow5szQahST911zg8N3g/jJh3bo+VzanbuZOU4O+nKA=="], + "importmapify": ["importmapify@1.7.0", "", { "dependencies": { "dreamcli": "npm:@kjanat/dreamcli@^3.0.1" }, "bin": { "importmapify": "dist/mod.mjs" } }, "sha512-oAScmWPf0bZLwC1mzsH5hNZ7hV8f6JfcefnJR7ikrnlfkx3dzSnEjK4OWyiCs9eUdA8XLZQb1VF5brDjrTHTuQ=="], "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], @@ -804,7 +804,7 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "miniflare": ["miniflare@4.20260721.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260721.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA=="], + "miniflare": ["miniflare@4.20260722.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260722.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw=="], "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -846,7 +846,7 @@ "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], - "publint": ["publint@0.3.21", "", { "dependencies": { "@publint/pack": "^0.1.4", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "src/cli.js" } }, "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ=="], + "publint": ["publint@0.3.22", "", { "dependencies": { "@publint/pack": "^0.1.6", "package-manager-detector": "^1.7.0", "picocolors": "^1.1.1", "sade": "^1.8.1" }, "bin": { "publint": "./src/cli.js" } }, "sha512-6Z/scsr5CA7APdwyF35EY88CqgDj1textWuY788DVTJYPCWVv/Wn9G6KmLnrVRnStgYcahqN4wCDLZGSbQJ69w=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -862,7 +862,7 @@ "rolldown-plugin-dts": ["rolldown-plugin-dts@0.27.13", "", { "dependencies": { "dts-resolver": "^3.0.0", "get-tsconfig": "5.0.0-beta.5", "obug": "^2.1.4", "yuku-ast": "^0.7.0", "yuku-codegen": "^0.7.0", "yuku-parser": "^0.7.0" }, "peerDependencies": { "@typescript/native-preview": "*", "@volar/typescript": "~2.4.0", "rolldown": "^1.0.0", "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", "vue-tsc": "~3.2.0 || ~3.3.0" }, "optionalPeers": ["@typescript/native-preview", "@volar/typescript", "typescript", "vue-tsc"] }, "sha512-DeVZJbbB0ajp5q6vABqC8ZCJzxftlxbiV60Bk96GFdQaysGVpgTTVjQu0lUt4Lb+aRCtejfOixtQKDRol7IuVQ=="], - "runner-run": ["runner-run@0.21.0", "", { "optionalDependencies": { "@runner-run/darwin-arm64": "0.21.0", "@runner-run/darwin-x64": "0.21.0", "@runner-run/freebsd-arm64": "0.21.0", "@runner-run/freebsd-x64": "0.21.0", "@runner-run/linux-arm64-gnu": "0.21.0", "@runner-run/linux-arm64-musl": "0.21.0", "@runner-run/linux-armv7-gnueabihf": "0.21.0", "@runner-run/linux-x64-gnu": "0.21.0", "@runner-run/linux-x64-musl": "0.21.0", "@runner-run/netbsd-x64": "0.21.0", "@runner-run/win32-arm64-msvc": "0.21.0", "@runner-run/win32-ia32-msvc": "0.21.0", "@runner-run/win32-x64-msvc": "0.21.0" }, "bin": { "run": "bin/run.cjs", "runner": "bin/runner.cjs", "runner-run": "bin/runner.cjs" } }, "sha512-b8HWEkIDlvCQpKjy6XV3IVcd510esZWiDV/0MzITaMXuhmhEwq1cepTeq+o8UZZ3yMlA8vtBqS+T4p/z6O/H3A=="], + "runner-run": ["runner-run@0.23.0", "", { "optionalDependencies": { "@runner-run/darwin-arm64": "0.23.0", "@runner-run/darwin-x64": "0.23.0", "@runner-run/freebsd-arm64": "0.23.0", "@runner-run/freebsd-x64": "0.23.0", "@runner-run/linux-arm64-gnu": "0.23.0", "@runner-run/linux-arm64-musl": "0.23.0", "@runner-run/linux-armv7-gnueabihf": "0.23.0", "@runner-run/linux-x64-gnu": "0.23.0", "@runner-run/linux-x64-musl": "0.23.0", "@runner-run/netbsd-x64": "0.23.0", "@runner-run/win32-arm64-msvc": "0.23.0", "@runner-run/win32-ia32-msvc": "0.23.0", "@runner-run/win32-x64-msvc": "0.23.0" }, "bin": { "run": "bin/run.cjs", "runner": "bin/runner.cjs", "runner-run": "bin/runner.cjs" } }, "sha512-+fD+pIo2mS6Jq4tJjDFL6QnK9ebTrroZ45BKiIoLXUlPi+nBSH6x8hm2h4LPYKP3cBZhMUIGBuWgZqgG4OZUyA=="], "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], @@ -892,7 +892,7 @@ "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], - "tsdown": ["tsdown@0.22.13", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.12", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.1.2" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.13", "@tsdown/exe": "0.22.13", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-XaYFhtiKRUvTpXv/YAehsHdbEb3LN/iMlzjSINbjlaATtXN2zVPKox2STKhcyFPlh++8Zg7suNN27E679IfAUA=="], + "tsdown": ["tsdown@0.22.14", "", { "dependencies": { "ansis": "^4.3.1", "cac": "^7.0.0", "defu": "^6.1.7", "empathic": "^2.0.1", "hookable": "^6.1.1", "import-without-cache": "^0.4.0", "obug": "^2.1.4", "picomatch": "^4.0.5", "rolldown": "~1.2.0", "rolldown-plugin-dts": "^0.27.13", "tinyexec": "^1.2.4", "tinyglobby": "^0.2.17", "tree-kill": "^1.2.2", "unconfig-core": "^7.5.0", "verkit": "^0.3.0" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@tsdown/css": "0.22.14", "@tsdown/exe": "0.22.14", "@vitejs/devtools": "*", "publint": "^0.3.8", "tsx": "*", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*" }, "optionalPeers": ["@arethetypeswrong/core", "@tsdown/css", "@tsdown/exe", "@vitejs/devtools", "publint", "tsx", "typescript", "unplugin-unused", "unrun"], "bin": { "tsdown": "./dist/run.mjs" } }, "sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -924,7 +924,7 @@ "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], - "verkit": ["verkit@0.1.2", "", {}, "sha512-WqkT8n3hqizuCu71W3bUzf5fjBmkbXcudsehe/NbxA8PgqoKnSOY5K0Ba2ckg1qaRaSpSz7as/n9K1R9JXjQKg=="], + "verkit": ["verkit@0.3.0", "", {}, "sha512-Njrh4U8UODGajoZ44QS2C/BsoEM9DTI/aCqY5swsizb+/ap0FamvnCMcZAxrR5+aoC0ZqkawEfpC/N2SBc+xeA=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -946,9 +946,9 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], - "workerd": ["workerd@1.20260721.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260721.1", "@cloudflare/workerd-darwin-arm64": "1.20260721.1", "@cloudflare/workerd-linux-64": "1.20260721.1", "@cloudflare/workerd-linux-arm64": "1.20260721.1", "@cloudflare/workerd-windows-64": "1.20260721.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg=="], + "workerd": ["workerd@1.20260722.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260722.1", "@cloudflare/workerd-darwin-arm64": "1.20260722.1", "@cloudflare/workerd-linux-64": "1.20260722.1", "@cloudflare/workerd-linux-arm64": "1.20260722.1", "@cloudflare/workerd-windows-64": "1.20260722.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ=="], - "wrangler": ["wrangler@4.113.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260721.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA=="], + "wrangler": ["wrangler@4.114.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260722.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260722.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260722.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-M65P25t5UHA1TIJfgZXDcj+YzVobgKdRguM2QPz0xnxLFuOcuE3ErgllDht0iaho7MS4o0g/Bb4YK2+GT+bibg=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], @@ -968,6 +968,12 @@ "@arethetypeswrong/core/typescript": ["typescript@5.6.1-rc", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ=="], + "@cloudflare/vite-plugin/miniflare": ["miniflare@4.20260721.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260721.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA=="], + + "@cloudflare/vite-plugin/workerd": ["workerd@1.20260721.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260721.1", "@cloudflare/workerd-darwin-arm64": "1.20260721.1", "@cloudflare/workerd-linux-64": "1.20260721.1", "@cloudflare/workerd-linux-arm64": "1.20260721.1", "@cloudflare/workerd-windows-64": "1.20260721.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg=="], + + "@cloudflare/vite-plugin/wrangler": ["wrangler@4.113.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260721.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], @@ -982,7 +988,7 @@ "@vue/language-core/@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], - "miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "miniflare/sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1000,53 +1006,71 @@ "wrangler/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@cloudflare/vite-plugin/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "@cloudflare/vite-plugin/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260721.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ=="], + + "@cloudflare/vite-plugin/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260721.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA=="], + + "@cloudflare/vite-plugin/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260721.1", "", { "os": "linux", "cpu": "x64" }, "sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ=="], + + "@cloudflare/vite-plugin/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260721.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ=="], + + "@cloudflare/vite-plugin/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260721.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ=="], + + "@cloudflare/vite-plugin/wrangler/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@vue/language-core/@vue/compiler-dom/@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + "miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], - "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + "miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], - "miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + "miniflare/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], - "miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + "miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], - "miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + "miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], - "miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + "miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], - "miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + "miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], - "miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], - "miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + "miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], - "miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + "miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], - "miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + "miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], - "miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + "miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], - "miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + "miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], - "miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + "miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], - "miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + "miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], - "miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], - "miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + "miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], - "miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + "miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], - "miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + "miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], - "miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], - "miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], - "miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + "miniflare/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], - "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], "vite-robots-txt/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1096,6 +1120,56 @@ "vitepress/vue/@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + "vite-robots-txt/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], "vite-robots-txt/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], diff --git a/package.json b/package.json index a7d184e..db546a8 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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=1 $BIN test --concurrent", + "test:35433": "PR=\"35433\"; (command -v \"bun-${PR}\" || bunx bun-pr \"${PR}\") && BIN=\"bun-${PR}\" bun 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", @@ -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", "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", diff --git a/src/internal/asn1/asn1.ts b/src/internal/asn1/asn1.ts index 1ed7088..1b49eb8 100644 --- a/src/internal/asn1/asn1.ts +++ b/src/internal/asn1/asn1.ts @@ -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 }); @@ -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(''); diff --git a/src/internal/x509/extension-errors.ts b/src/internal/x509/extension-errors.ts index 99391ac..df6dbc7 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -37,6 +37,7 @@ export type ExtensionEncoderErrorCode = | 'invalid_ip_name_constraint' | 'invalid_oid' | 'key_usage_empty' + | 'malformed_known_extension_value' | 'name_constraints_empty' | 'path_length_requires_key_cert_sign' | 'policy_constraints_empty' diff --git a/src/internal/x509/general-name.ts b/src/internal/x509/general-name.ts index a60cf81..9a1bbbb 100644 --- a/src/internal/x509/general-name.ts +++ b/src/internal/x509/general-name.ts @@ -33,6 +33,19 @@ export function parseGeneralNames(source: Uint8Array, element: DerElement): read return names.map((name) => parseGeneralName(source, name)); } +/** + * Decode an IA5String GeneralName alternative, rejecting a zero-length value. + * + * RFC 5280 §4.2.1.6 forbids empty GeneralName fields, and a certificate carrying + * one presents no usable identity. + */ +function requireNonEmptyIa5(element: DerElement, alternative: string): string { + if (element.value.length === 0) { + throw new Error(`GeneralName ${alternative} must not be empty`); + } + return decodeString(0x16, element.value); +} + /** Decode a single GeneralName from its implicit context tag. */ export function parseGeneralName(source: Uint8Array, element: DerElement): GeneralName { switch (element.tag) { @@ -48,11 +61,14 @@ export function parseGeneralName(source: Uint8Array, element: DerElement): Gener }; } case 0x81: - return { type: 'email' as const, value: decodeString(0x16, element.value) }; + return { type: 'email' as const, value: requireNonEmptyIa5(element, 'rfc822Name') }; case 0x82: - return { type: 'dns' as const, value: decodeString(0x16, element.value) }; + return { type: 'dns' as const, value: requireNonEmptyIa5(element, 'dNSName') }; case 0x86: - return { type: 'uri' as const, value: decodeString(0x16, element.value) }; + return { + type: 'uri' as const, + value: requireNonEmptyIa5(element, 'uniformResourceIdentifier'), + }; case 0x87: return { type: 'ip' as const, value: decodeIpAddress(element.value) }; case 0xa4: diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index 571c50c..237f255 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -7,7 +7,7 @@ * @module */ -import { hexToBytes, toHex } from '#micro509/internal/asn1/asn1'; +import { canonicalizeOid, hexToBytes, toHex } from '#micro509/internal/asn1/asn1'; import { bool, concatBytes, @@ -736,6 +736,7 @@ export function buildCertificateExtensions( input: CertificateExtensionsInput | undefined, subjectIsEmpty = false, ): Uint8Array[] { + assertCustomExtensionsValid(input, 'certificate'); if (subjectIsEmpty) { assertEmptySubjectHasCriticalSubjectAltName(input); } @@ -758,7 +759,7 @@ export function buildCertificateExtensions( buildSubjectKeyIdentifier(issuerPublicKeyInfo), ); } - appendConfiguredExtensions(extensions, seen, input, 'certificate', { + appendConfiguredExtensions(extensions, seen, input, { includeBasicConstraints: false, subjectIsEmpty, }); @@ -776,9 +777,10 @@ export function buildCertificateExtensions( export function buildRequestedExtensions( input: CertificateExtensionsInput | undefined, ): Uint8Array[] { + assertCustomExtensionsValid(input, 'csr'); const extensions: Uint8Array[] = []; const seen = new Set(); - appendConfiguredExtensions(extensions, seen, input, 'csr', { includeBasicConstraints: true }); + appendConfiguredExtensions(extensions, seen, input, { includeBasicConstraints: true }); return extensions; } @@ -787,7 +789,6 @@ function appendConfiguredExtensions( encoded: Uint8Array[], seen: Set, input: CertificateExtensionsInput | undefined, - context: ExtensionRegistryContext, options: { readonly includeBasicConstraints: boolean; /** When true, SAN is marked critical per RFC 5280 §4.2.1.6. */ @@ -801,7 +802,7 @@ function appendConfiguredExtensions( appendIdentityExtensions(encoded, seen, input, options.subjectIsEmpty === true); appendPolicyExtensions(encoded, seen, input); appendAccessExtensions(encoded, seen, input); - appendCustomExtensions(encoded, seen, input, context); + appendCustomExtensions(encoded, seen, input); } /** @@ -848,16 +849,14 @@ function assertEmptySubjectHasCriticalSubjectAltName( ); } -/** Canonical OID equality: matches even when arcs carry redundant leading zeros. */ +/** + * Canonical OID equality: matches even when arcs carry redundant leading zeros. + * + * Both sides must be encodable. Callers run after `assertCustomExtensionsValid`, + * which rejects an OID that cannot be canonicalized. + */ function oidEquals(candidate: string, known: string): boolean { - if (candidate === known) { - return true; - } - try { - return toHex(objectIdentifier(candidate)) === toHex(objectIdentifier(known)); - } catch { - return false; - } + return candidate === known || canonicalizeOid(candidate) === canonicalizeOid(known); } /** First customExtensions value whose OID canonically matches `oid`. */ @@ -868,6 +867,40 @@ function findCustomExtensionValue( return input?.customExtensions?.find((extension) => oidEquals(extension.oid, oid))?.value; } +/** + * Rejects a custom extension carrying a known OID whose payload is not the DER + * that OID's schema defines, and a known extension offered in the wrong context. + * + * Runs before every cross-field guard so the effective-value resolvers below can + * decode a custom payload without having to tolerate failure. + */ +function assertCustomExtensionsValid( + input: CertificateExtensionsInput | undefined, + context: ExtensionRegistryContext, +): void { + for (const extension of input?.customExtensions ?? []) { + validateOid(extension.oid); + const definition = getExtensionDefinition(canonicalizeOid(extension.oid)); + if (definition === undefined) { + continue; + } + if (!definition.contexts.includes(context)) { + throwExtensionEncoderError( + 'extension_not_supported_in_context', + `Extension ${extension.oid} is not supported in ${context} context`, + ); + } + try { + definition.decode(new Uint8Array(extension.value)); + } catch { + throwExtensionEncoderError( + 'malformed_known_extension_value', + `Custom extension ${extension.oid} does not decode as ${definition.oid}`, + ); + } + } +} + /** Effective basicConstraints across the typed field and any custom-known extension. */ function resolveEffectiveBasicConstraints( input: CertificateExtensionsInput | undefined, @@ -876,14 +909,7 @@ function resolveEffectiveBasicConstraints( return input.basicConstraints; } const custom = findCustomExtensionValue(input, OIDS.basicConstraints); - if (custom === undefined) { - return undefined; - } - try { - return BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode(custom); - } catch { - return undefined; - } + return custom === undefined ? undefined : BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode(custom); } /** Effective keyUsage flags across the typed field and any custom-known extension. */ @@ -894,14 +920,7 @@ function resolveEffectiveKeyUsage( return input.keyUsage; } const custom = findCustomExtensionValue(input, OIDS.keyUsage); - if (custom === undefined) { - return undefined; - } - try { - return KEY_USAGE_EXTENSION_DEFINITION.decode(custom).flags; - } catch { - return undefined; - } + return custom === undefined ? undefined : KEY_USAGE_EXTENSION_DEFINITION.decode(custom).flags; } /** Whether a typed GeneralName carries a non-empty identity value. */ @@ -911,11 +930,7 @@ function subjectAltNameHasIdentity(name: SubjectAltName): boolean { /** Whether a custom subjectAltName value decodes to at least one non-empty GeneralName. */ function customSubjectAltNameHasIdentity(value: Uint8Array): boolean { - try { - return SUBJECT_ALT_NAME_EXTENSION_DEFINITION.decode(value).some(subjectAltNameHasIdentity); - } catch { - return false; - } + return SUBJECT_ALT_NAME_EXTENSION_DEFINITION.decode(value).some(subjectAltNameHasIdentity); } function appendConstraintExtensions( @@ -1026,29 +1041,20 @@ function appendAccessExtensions( } } +/** Push each custom extension. Context and payload were checked by `assertCustomExtensionsValid`. */ function appendCustomExtensions( encoded: Uint8Array[], seen: Set, input: CertificateExtensionsInput, - context: ExtensionRegistryContext, ): void { - if (input.customExtensions !== undefined) { - for (const extension of input.customExtensions) { - const knownDefinition = getExtensionDefinition(extension.oid); - if (knownDefinition !== undefined && !knownDefinition.contexts.includes(context)) { - throwExtensionEncoderError( - 'extension_not_supported_in_context', - `Extension ${extension.oid} is not supported in ${context} context`, - ); - } - pushExtension( - encoded, - seen, - extension.oid, - new Uint8Array(extension.value), - extension.critical ?? false, - ); - } + for (const extension of input.customExtensions ?? []) { + pushExtension( + encoded, + seen, + extension.oid, + new Uint8Array(extension.value), + extension.critical ?? false, + ); } } @@ -1652,7 +1658,13 @@ function validatePolicyOid(oid: string): void { validateOid(oid); } -/** Encode and push an extension, rejecting duplicate OIDs. */ +/** + * Encode and push an extension, rejecting duplicate OIDs. + * + * Duplicate identity is the canonical OID, because two spellings that differ only + * by leading zeros in an arc encode to the same wire bytes. The diagnostic quotes + * the OID as submitted, which is the string the caller can find in their input. + */ function pushExtension( encoded: Uint8Array[], seen: Set, @@ -1661,9 +1673,10 @@ function pushExtension( critical = false, ): void { validateOid(oid); - if (seen.has(oid)) { + const identity = canonicalizeOid(oid); + if (seen.has(identity)) { throwExtensionEncoderError('duplicate_extension_oid', `Duplicate extension OID: ${oid}`); } - seen.add(oid); + seen.add(identity); encoded.push(encodeExtension(oid, value, critical)); } diff --git a/test/helpers.ts b/test/helpers.ts index 77e8d73..503171d 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -26,9 +26,17 @@ import { } from '#micro509/internal/crypto/signing'; import { exportPkcs8Der, generateKeyPair, importPkcs8Der } from '#micro509/keys'; import { unwrap } from '#micro509/result'; -import type { BasicConstraints, GeneralName, ParsedCertificate } from '#micro509/x509'; +import type { + BasicConstraints, + CertificateMaterial, + CsrMaterial, + GeneralName, + ParsedCertificate, + SelfSignedCertificateResult, +} from '#micro509/x509'; import { createCertificate, + createCertificateSigningRequest, createSelfSignedCertificate, encodeSubjectAltName, } from '#micro509/x509'; @@ -356,6 +364,207 @@ export async function addRevokedEntryCertificateIssuers( ]); } +/** + * Drop-in {@link createSelfSignedCertificate} that splices `customExtensions` + * into the signed TBSCertificate instead of routing them through the builder. + * + * The builder refuses to emit a known OID whose value is not that extension's + * DER, so parser-strictness fixtures need a path that bypasses that check while + * still producing a properly signed certificate. Takes and returns the same + * shapes as the real function. + */ +export async function createSelfSignedCertificateWithRawExtensions( + input: Parameters[0], +): Promise { + const { customExtensions, ...builderExtensions } = input.extensions ?? {}; + const issued = await createSelfSignedCertificate({ + ...input, + extensions: builderExtensions, + }); + if (customExtensions === undefined || customExtensions.length === 0) { + return issued; + } + const der = await appendCertificateExtensions( + issued.certificate.der, + issued.keyPair.privateKey, + customExtensions.map((extension) => + encodeExtension(extension.oid, new Uint8Array(extension.value), extension.critical ?? false), + ), + ); + const base64 = Buffer.from(der).toString('base64'); + return { + certificate: { der, base64, pem: toPemBlock('CERTIFICATE', der) }, + keyPair: issued.keyPair, + }; +} + +/** Append encoded extensions to a certificate's TBS extensions and re-sign it. */ +export async function appendCertificateExtensions( + certificateDer: Uint8Array, + signerPrivateKey: CryptoKey, + extensionDers: readonly Uint8Array[], +): Promise { + const top = readSequenceChildren(certificateDer); + const tbsCertificate = top[0]; + if (tbsCertificate === undefined) { + throw new Error('Missing TBSCertificate'); + } + const tbsDer = sliceElement(certificateDer, tbsCertificate); + const tbsChildren = readSequenceChildren(tbsDer); + const extensionsIndex = tbsChildren.findIndex((child) => child.tag === 0xa3); + if (extensionsIndex === -1) { + throw new Error('TBSCertificate has no extensions'); + } + const extensionsElement = tbsChildren[extensionsIndex]; + if (extensionsElement === undefined) { + throw new Error('TBSCertificate has no extensions'); + } + const extensionsSequence = childrenOf(tbsDer, extensionsElement)[0]; + if (extensionsSequence === undefined) { + throw new Error('Extensions [3] is empty'); + } + const existing = childrenOf(tbsDer, extensionsSequence).map((extension) => + sliceElement(tbsDer, extension), + ); + const rebuiltTbsDer = sequence( + tbsChildren.map((child, childIndex) => + childIndex === extensionsIndex + ? explicitContext(3, sequence([...existing, ...extensionDers])) + : sliceElement(tbsDer, child), + ), + ); + const signatureAlgorithm = getSignatureAlgorithm(signerPrivateKey); + const signatureValue = await signBytes(signerPrivateKey, signatureAlgorithm, rebuiltTbsDer); + return sequence([ + rebuiltTbsDer, + encodeAlgorithmIdentifier(signatureAlgorithm), + bitString(signatureValue), + ]); +} + +/** + * Drop-in {@link createCertificate} that splices `customExtensions` into the + * signed TBSCertificate instead of routing them through the builder. + */ +export async function createCertificateWithRawExtensions( + input: Parameters[0], +): Promise { + const { customExtensions, ...builderExtensions } = input.extensions ?? {}; + const issued = await createCertificate({ ...input, extensions: builderExtensions }); + if (customExtensions === undefined || customExtensions.length === 0) { + return issued; + } + const der = await appendCertificateExtensions( + issued.der, + input.signerPrivateKey, + customExtensions.map((extension) => + encodeExtension(extension.oid, new Uint8Array(extension.value), extension.critical ?? false), + ), + ); + return { der, base64: base64Of(der), pem: toPemBlock('CERTIFICATE', der) }; +} + +/** + * Drop-in {@link createCertificateSigningRequest} that splices `customExtensions` + * into the signed extensionRequest attribute instead of routing them through the + * builder. The CSR counterpart of + * {@link createSelfSignedCertificateWithRawExtensions}. + */ +export async function createCsrWithRawExtensions( + input: Parameters[0], +): Promise { + const { customExtensions, ...builderExtensions } = input.extensions ?? {}; + const csr = await createCertificateSigningRequest({ ...input, extensions: builderExtensions }); + if (customExtensions === undefined || customExtensions.length === 0) { + return csr; + } + const encoded = customExtensions.map((extension) => + encodeExtension(extension.oid, new Uint8Array(extension.value), extension.critical ?? false), + ); + const criElement = readSequenceChildren(csr.der)[0]; + if (criElement === undefined) { + throw new Error('Missing CertificationRequestInfo'); + } + const criDer = sliceElement(csr.der, criElement); + const criChildren = readSequenceChildren(criDer); + const attributesElement = criChildren[3]; + if (attributesElement === undefined || attributesElement.tag !== 0xa0) { + throw new Error('CertificationRequestInfo has no attributes'); + } + const rebuiltCriDer = sequence([ + ...criChildren.slice(0, 3).map((child) => sliceElement(criDer, child)), + implicitConstructedContext( + 0, + concatBytes(withExtensionRequest(criDer, attributesElement, encoded)), + ), + ]); + const signatureAlgorithm = getSignatureAlgorithm(input.signerPrivateKey); + const signature = await signBytes(input.signerPrivateKey, signatureAlgorithm, rebuiltCriDer); + const der = sequence([ + rebuiltCriDer, + encodeAlgorithmIdentifier(signatureAlgorithm), + bitString(signature), + ]); + return { der, pem: toPemBlock('CERTIFICATE REQUEST', der), base64: base64Of(der) }; +} + +/** Append extensions to the extensionRequest attribute, creating it when absent. */ +function withExtensionRequest( + criDer: Uint8Array, + attributesElement: { readonly start: number; readonly end: number }, + extensionDers: readonly Uint8Array[], +): Uint8Array[] { + const attributes = childrenOf(criDer, attributesElement); + const rebuilt: Uint8Array[] = []; + let appended = false; + for (const attribute of attributes) { + const attributeDer = sliceElement(criDer, attribute); + const attributeChildren = readSequenceChildren(attributeDer); + const typeElement = attributeChildren[0]; + const valuesElement = attributeChildren[1]; + if ( + typeElement === undefined || + valuesElement === undefined || + decodeObjectIdentifier(typeElement.value) !== OIDS.extensionRequest + ) { + rebuilt.push(attributeDer); + continue; + } + const existingSequence = childrenOf(attributeDer, valuesElement)[0]; + const existing = + existingSequence === undefined + ? [] + : childrenOf(attributeDer, existingSequence).map((extension) => + sliceElement(attributeDer, extension), + ); + rebuilt.push( + sequence([ + objectIdentifier(OIDS.extensionRequest), + setOf([sequence([...existing, ...extensionDers])]), + ]), + ); + appended = true; + } + if (!appended) { + rebuilt.push( + sequence([objectIdentifier(OIDS.extensionRequest), setOf([sequence([...extensionDers])])]), + ); + } + return rebuilt; +} + +/** Standard base64 of DER, matching the `base64` field the builders return. */ +function base64Of(der: Uint8Array): string { + return Buffer.from(der).toString('base64'); +} + +/** Wrap DER in a PEM block with 64-character base64 lines. */ +function toPemBlock(label: string, der: Uint8Array): string { + const base64 = Buffer.from(der).toString('base64'); + const lines = base64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + export function createSyntheticPkcs7SignedData(signer: ParsedCertificate): Uint8Array { const signerInfo = sequence([ integerFromNumber(1), diff --git a/test/internals.test.ts b/test/internals.test.ts index 609d41d..792cd37 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -1000,15 +1000,71 @@ describe('extensions encoding', () => { () => buildRequestedExtensions({ basicConstraints: { ca: true, pathLength: 0 }, - customExtensions: [{ oid: OIDS.keyUsage, value: Uint8Array.of(0x05, 0x00) }], + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['digitalSignature']) }], }), 'path_length_requires_key_cert_sign', ); + }); + + it("rejects a custom extension whose payload is not that known OID's DER", () => { + for (const oid of [OIDS.basicConstraints, OIDS.keyUsage, OIDS.subjectAltName]) { + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + } + }); + + it('resolves a known extension supplied under a non-canonical OID spelling', () => { + // 2.5.029.19 and 2.5.29.19 encode to the same OID, so both are basicConstraints. expect( buildRequestedExtensions({ - customExtensions: [{ oid: OIDS.basicConstraints, value: Uint8Array.of(0x05, 0x00) }], + keyUsage: ['keyCertSign'], + customExtensions: [ + { oid: '2.5.029.19', value: encodeBasicConstraints({ ca: true, pathLength: 0 }) }, + ], }), ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + keyUsage: ['digitalSignature'], + customExtensions: [ + { oid: '2.5.029.19', value: encodeBasicConstraints({ ca: true, pathLength: 0 }) }, + ], + }), + 'path_length_requires_key_cert_sign', + ); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + customExtensions: [{ oid: '2.5.029.19', value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + // issuerAltName is certificate-only, and the alias must not evade that. + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + customExtensions: [ + { + oid: '2.5.029.18', + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'alias.example' })]), + }, + ], + }), + 'extension_not_supported_in_context', + ); }); it('rejects an empty subject without a critical subjectAltName (RFC 5280 §4.2.1.6)', () => { @@ -1039,6 +1095,25 @@ describe('extensions encoding', () => { { customExtensions: [{ oid: OIDS.subjectAltName, critical: true, value: sequence([]) }] }, true, ), + 'malformed_known_extension_value', + ); + // x400Address [3] decodes as an unknown GeneralName, carrying no identity. + expectEncoderErrorCode( + () => + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { + customExtensions: [ + { + oid: OIDS.subjectAltName, + critical: true, + value: sequence([tlv(0xa3, new Uint8Array())]), + }, + ], + }, + true, + ), 'empty_subject_requires_subject_alt_name', ); expect( diff --git a/test/keys.test.ts b/test/keys.test.ts index 91f6e2c..887e2eb 100644 --- a/test/keys.test.ts +++ b/test/keys.test.ts @@ -694,6 +694,9 @@ describe('keys', () => { }); }); +/** Is a bun canary build */ +const isCanary = (await Bun.$`${process.argv0} --revision`.text()).includes('canary'); + describe('keys: coverage — malformed inputs', () => { it('importEncryptedPkcs8Der throws on malformed EncryptedPrivateKeyInfo (missing OCTET STRING)', async () => { // SEQUENCE with only one child (algorithmIdentifier) and no encryptedData @@ -814,17 +817,20 @@ describe('keys: coverage — malformed inputs', () => { ); }); - test.failing('imports RFC 5958 v2 OneAsymmetricKey with attributes and publicKey (oven-sh/bun#35432)', async () => { - const oneAsymmetricKey = hexToBytes( - '3053020101300506032b657004220420' + - '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60' + - 'a000812100' + - 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a', - ); - const key = unwrap(await importPkcs8Der(oneAsymmetricKey, { kind: 'ed25519' })); - expect(key.type).toBe('private'); - expect(key.algorithm.name).toBe('Ed25519'); - }); + test.failingIf(!isCanary)( + 'imports RFC 5958 v2 OneAsymmetricKey with attributes and publicKey (oven-sh/bun#35432, oven-sh/bun#35433)', + async () => { + const oneAsymmetricKey = hexToBytes( + '3053020101300506032b657004220420' + + '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60' + + 'a000812100' + + 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a', + ); + const key = unwrap(await importPkcs8Der(oneAsymmetricKey, { kind: 'ed25519' })); + expect(key.type).toBe('private'); + expect(key.algorithm.name).toBe('Ed25519'); + }, + ); test('rejects malformed OneAsymmetricKey tails per RFC 5958/RFC 8410', async () => { const { integerFromNumber, objectIdentifier, octetString, sequence } = await import( diff --git a/test/parse.test.ts b/test/parse.test.ts index 33ca4a5..c3c5e3f 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -36,7 +36,7 @@ import { } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/internal/crypto/rsa-pss'; -import { encodeName } from '#micro509/x509'; +import { encodeKeyUsage, encodeName, encodeSubjectAltName } from '#micro509/x509'; import { parseAuthorityKeyIdentifier, parseExtendedKeyUsage, @@ -45,6 +45,8 @@ import { } from '#micro509/x509/parse'; import { childrenOf, + createCsrWithRawExtensions, + createSelfSignedCertificateWithRawExtensions, importRsaPrivateKeyWithScheme, replaceCertificateSignatureAlgorithm, rewriteCertificateSignatureAsRsaPss, @@ -60,7 +62,7 @@ interface MalformedCustomExtensionCase { } async function expectMalformedCustomExtension(input: MalformedCustomExtensionCase): Promise { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: input.commonName }, extensions: { customExtensions: [{ oid: input.oid, critical: true, value: input.value }], @@ -75,7 +77,7 @@ async function expectMalformedCustomExtension(input: MalformedCustomExtensionCas describe('parse', () => { it('supports custom extension encode and decode hooks', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'custom-ext.example' }, extensions: { customExtensions: [ @@ -129,10 +131,26 @@ describe('parse', () => { subject: { commonName: 'dup-ext.example' }, extensions: { keyUsage: ['digitalSignature'], - customExtensions: [{ oid: OIDS.keyUsage, value: Uint8Array.of(0x05, 0x00) }], + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['digitalSignature']) }], }, }), ).rejects.toThrow('Duplicate extension OID'); + + // 2.5.029.17 encodes to the same OID as 2.5.29.17, so it is the same extension. + expect( + createSelfSignedCertificate({ + subject: { commonName: 'dup-alias-ext.example' }, + extensions: { + subjectAltNames: [{ type: 'dns', value: 'alias.example' }], + customExtensions: [ + { + oid: '2.5.029.17', + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'alias.example' })]), + }, + ], + }, + }), + ).rejects.toThrow('Duplicate extension OID: 2.5.029.17'); }); it('rejects duplicate extension OIDs during certificate parse', async () => { @@ -357,7 +375,7 @@ describe('parse', () => { }); it('rejects repeated distribution point fields during parse', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-dp-repeat.example' }, extensions: { customExtensions: [ @@ -391,7 +409,7 @@ describe('parse', () => { }); it('rejects distributionPointName wrappers with multiple choices during parse', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-dp-choice.example' }, extensions: { customExtensions: [ @@ -427,7 +445,7 @@ describe('parse', () => { }); it('rejects CRL distribution points that contain only reasons', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-dp-reasons.example' }, extensions: { customExtensions: [ @@ -797,7 +815,7 @@ describe('parse', () => { }); it('runs decoder registries directly during parse', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'parse-registry.example' }, extensions: { customExtensions: [{ oid: '1.2.3.4.210', value: Uint8Array.of(0x04, 0x02, 0xaa, 0xbb) }], @@ -1284,7 +1302,7 @@ describe('parse', () => { }); it('rejects malformed certificatePolicies during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-parse.example' }, extensions: { customExtensions: [{ oid: OIDS.certificatePolicies, critical: true, value: sequence([]) }], @@ -1300,7 +1318,7 @@ describe('parse', () => { }); it('rejects anyPolicy in policyMappings during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-mappings-parse.example' }, extensions: { customExtensions: [ @@ -1324,7 +1342,7 @@ describe('parse', () => { }); it('rejects malformed policy qualifiers during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-qualifier-parse.example' }, extensions: { customExtensions: [ @@ -1353,7 +1371,7 @@ describe('parse', () => { }); it('rejects policyInformation with trailing fields and empty qualifier sequences', async () => { - const trailing = await createSelfSignedCertificate({ + const trailing = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-trailing.example' }, extensions: { customExtensions: [ @@ -1384,7 +1402,7 @@ describe('parse', () => { } } - const emptyQualifiers = await createSelfSignedCertificate({ + const emptyQualifiers = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-empty-qualifiers.example' }, extensions: { customExtensions: [ @@ -1406,7 +1424,7 @@ describe('parse', () => { }); it('rejects malformed policy qualifier and userNotice structures during parsing', async () => { - const qualifierTrailing = await createSelfSignedCertificate({ + const qualifierTrailing = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-qualifier-trailing.example' }, extensions: { customExtensions: [ @@ -1439,7 +1457,7 @@ describe('parse', () => { } } - const duplicateNoticeRef = await createSelfSignedCertificate({ + const duplicateNoticeRef = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-user-notice-ref.example' }, extensions: { customExtensions: [ @@ -1480,7 +1498,7 @@ describe('parse', () => { } } - const duplicateExplicitText = await createSelfSignedCertificate({ + const duplicateExplicitText = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-user-notice-text.example' }, extensions: { customExtensions: [ @@ -1671,7 +1689,7 @@ describe('parse', () => { }); it('rejects malformed distributionPointName and unsupported DisplayText tags', async () => { - const badDistributionPointName = await createSelfSignedCertificate({ + const badDistributionPointName = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-dp-name.example' }, extensions: { customExtensions: [ @@ -1691,7 +1709,7 @@ describe('parse', () => { } } - const badDisplayText = await createSelfSignedCertificate({ + const badDisplayText = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-display-text.example' }, extensions: { customExtensions: [ @@ -1723,7 +1741,7 @@ describe('parse', () => { }); it('rejects empty CRLDistributionPoints and empty fullName GeneralNames during parse', async () => { - const emptyDistributionPoints = await createSelfSignedCertificate({ + const emptyDistributionPoints = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-empty-dp.example' }, extensions: { customExtensions: [ @@ -1739,7 +1757,7 @@ describe('parse', () => { } } - const emptyFullName = await createSelfSignedCertificate({ + const emptyFullName = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-empty-fullname.example' }, extensions: { customExtensions: [ @@ -1761,7 +1779,7 @@ describe('parse', () => { } } - const setWrappedDistributionPoint = await createSelfSignedCertificate({ + const setWrappedDistributionPoint = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-set-dp.example' }, extensions: { customExtensions: [ @@ -1807,7 +1825,7 @@ describe('parse', () => { }); it('rejects empty policy noticeNumbers during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-notice-parse.example' }, extensions: { customExtensions: [ @@ -1841,7 +1859,7 @@ describe('parse', () => { }); it('parses BMPString DisplayText in certificate policies', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bmp-policy-parse.example' }, extensions: { customExtensions: [ @@ -1873,7 +1891,7 @@ describe('parse', () => { }); it('rejects malformed BMPString DisplayText in certificate policies', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-bmp-policy-parse.example' }, extensions: { customExtensions: [ @@ -1906,7 +1924,7 @@ describe('parse', () => { }); it('rejects non-integer inhibitAnyPolicy during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-inhibit-any-policy-parse.example' }, extensions: { customExtensions: [ @@ -1928,7 +1946,7 @@ describe('parse', () => { }); it('rejects empty policyConstraints during parsing', async () => { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-constraints-parse.example' }, extensions: { customExtensions: [ @@ -2284,7 +2302,7 @@ describe('parse: coverage — error paths', () => { tlv(0x82, new TextEncoder().encode('ocsp.example.com')), // dNSName, not URI ]), ]); - const cert = await createSelfSignedCertificate({ + const cert = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'aia-test.example' }, extensions: { customExtensions: [{ oid: '1.3.6.1.5.5.7.1.1', value: aiaValue }], @@ -2304,7 +2322,7 @@ describe('parse: coverage — error paths', () => { tlv(0xa2, tlv(0x82, new TextEncoder().encode('x'))), // dNSName with wrong constructedness ]) { const aiaValue = sequence([sequence([objectIdentifier(caIssuers), badLocation])]); - const cert = await createSelfSignedCertificate({ + const cert = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-aia.example' }, extensions: { customExtensions: [{ oid: OIDS.authorityInfoAccess, value: aiaValue }] }, }); @@ -2318,7 +2336,7 @@ describe('parse: coverage — error paths', () => { // x400Address [3] is a valid but unsupported GeneralName alternative. sequence([objectIdentifier('1.3.6.1.5.5.7.48.2'), tlv(0xa3, new Uint8Array())]), ]); - const cert = await createSelfSignedCertificate({ + const cert = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'unsupported-aia.example' }, extensions: { customExtensions: [{ oid: OIDS.authorityInfoAccess, value: aiaValue }] }, }); @@ -2359,7 +2377,7 @@ describe('parse: coverage — error paths', () => { const sanValue = sequence([ tlv(0x87, Uint8Array.of(0x0a, 0x00, 0x00, 0x01, 0xff, 0xee)), // 6 bytes — invalid ]); - const cert = await createSelfSignedCertificate({ + const cert = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-ip.example' }, extensions: { customExtensions: [ @@ -2383,7 +2401,7 @@ describe('parse: coverage — error paths', () => { // Build keyUsage extension with unusedBits = 8 (invalid) // KeyUsage is a BIT STRING: first byte is unused bits count const keyUsageValue = tlv(0x03, Uint8Array.of(8, 0x80)); // unusedBits=8, data=0x80 - const cert = await createSelfSignedCertificate({ + const cert = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-ku.example' }, extensions: { customExtensions: [ @@ -2657,13 +2675,48 @@ describe('subjectAltName and extendedKeyUsage parse strictness', () => { ).toThrow(); }); + it('rejects zero-length IA5 GeneralNames in certificates and CSRs (RFC 5280 §4.2.1.6)', async () => { + const emptyIa5Tags = [ + { tag: 0x81, alternative: 'rfc822Name' }, + { tag: 0x82, alternative: 'dNSName' }, + { tag: 0x86, alternative: 'uniformResourceIdentifier' }, + ]; + for (const { tag, alternative } of emptyIa5Tags) { + const extension = { + oid: OIDS.subjectAltName, + value: sequence([tlv(tag, new Uint8Array())]), + }; + const certificate = await createSelfSignedCertificateWithRawExtensions({ + subject: { commonName: `empty-${alternative}.example` }, + extensions: { customExtensions: [extension] }, + }); + const certificateResult = parseCertificateDer(certificate.certificate.der); + expect(certificateResult.ok).toBe(false); + if (!certificateResult.ok) { + expect(certificateResult.error.code).toBe('malformed'); + expect(certificateResult.error.message).toContain(`${alternative} must not be empty`); + } + + const keyPair = await generateKeyPair(); + const csr = await createCsrWithRawExtensions({ + subject: { commonName: `empty-${alternative}.example` }, + publicKey: keyPair.publicKey, + signerPrivateKey: keyPair.privateKey, + extensions: { customExtensions: [extension] }, + }); + const csrResult = parseCertificateSigningRequestDer(csr.der); + expect(csrResult.ok).toBe(false); + if (!csrResult.ok) expect(csrResult.error.code).toBe('malformed'); + } + }); + it('maps malformed subjectAltName and extendedKeyUsage in certificates and CSRs', async () => { const malformedExtensions = [ { oid: OIDS.subjectAltName, value: sequence([]) }, { oid: OIDS.extendedKeyUsage, value: sequence([]) }, ]; for (const [index, extension] of malformedExtensions.entries()) { - const certificate = await createSelfSignedCertificate({ + const certificate = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: `malformed-extension-${String(index)}.example` }, extensions: { customExtensions: [{ ...extension, critical: true }] }, }); @@ -2672,7 +2725,7 @@ describe('subjectAltName and extendedKeyUsage parse strictness', () => { if (!certificateResult.ok) expect(certificateResult.error.code).toBe('malformed'); const keyPair = await generateKeyPair(); - const csr = await createCertificateSigningRequest({ + const csr = await createCsrWithRawExtensions({ subject: { commonName: `malformed-extension-${String(index)}.example` }, publicKey: keyPair.publicKey, signerPrivateKey: keyPair.privateKey, diff --git a/test/verify.test.ts b/test/verify.test.ts index 8d4056c..7670edd 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -34,6 +34,7 @@ import { import { encodeSubjectAltName } from '#micro509/x509'; import { parseNameConstraints } from '#micro509/x509/parse'; import { + createCertificateWithRawExtensions, importRsaPrivateKeyWithScheme, issueChain, replaceCertificateSignatureAlgorithm, @@ -892,7 +893,7 @@ describe('chain verification', () => { }, }); const leafKeys = await generateKeyPair(); - const leaf = await createCertificate({ + const leaf = await createCertificateWithRawExtensions({ issuer: { commonName: 'Malformed DirectoryName SAN CA' }, subject: { organization: 'Blocked Org', commonName: 'malformed-directory-name.example' }, publicKey: leafKeys.publicKey, From b994997d7d7bd86ae3f0443cee7a2ac777b53de9 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 00:37:08 +0200 Subject: [PATCH 06/14] fix(x509): apply cRLIssuer profile rules to custom CRLDP payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decoding a known custom extension proves its structure, not the RFC profile the typed encoders enforce. A `cRLDistributionPoints` value supplied through `customExtensions` therefore still emitted the exact RFC 5280 §4.2.1.13 constructions this PR claims the builders reject: a non-directoryName cRLIssuer, and more than one cRLIssuer DN alongside nameRelativeToCRLIssuer. Both builder paths now run the same assertion the typed field runs, so the escape is closed on certificates and CSRs alike. `validateOid` checked decimal syntax only, so an OID such as `3.1` or `1.40` passed it and then failed inside `objectIdentifier` with an uncoded `Error`. It now rejects them with `invalid_oid`, matching the builder's coded-error contract. The parser stays tolerant of both, so the fixtures that feed it non-conformant values move to the raw-extension helpers. Parametrized cases become `it.each` so a failure names the case rather than the whole test. --- CHANGELOG.md | 6 +- src/x509/extensions.ts | 64 +++++++++++--- test/certificate.test.ts | 3 +- test/crl.test.ts | 182 +++++++++++++++++++-------------------- test/internals.test.ts | 60 ++++++++++++- test/parse.test.ts | 62 +++++++------ 6 files changed, 235 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f70d6c..0de0127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + 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 diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index 237f255..3242e2a 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -867,6 +867,12 @@ function findCustomExtensionValue( return input?.customExtensions?.find((extension) => oidEquals(extension.oid, oid))?.value; } +/** The subset of a distribution point the RFC 5280 §4.2.1.13 cRLIssuer rules read. */ +interface CrlIssuerConstrainedPoint { + readonly distributionPoint?: { readonly relativeName?: unknown }; + readonly crlIssuer?: readonly GeneralName[]; +} + /** * Rejects a custom extension carrying a known OID whose payload is not the DER * that OID's schema defines, and a known extension offered in the wrong context. @@ -880,7 +886,8 @@ function assertCustomExtensionsValid( ): void { for (const extension of input?.customExtensions ?? []) { validateOid(extension.oid); - const definition = getExtensionDefinition(canonicalizeOid(extension.oid)); + const oid = canonicalizeOid(extension.oid); + const definition = getExtensionDefinition(oid); if (definition === undefined) { continue; } @@ -890,17 +897,39 @@ function assertCustomExtensionsValid( `Extension ${extension.oid} is not supported in ${context} context`, ); } - try { - definition.decode(new Uint8Array(extension.value)); - } catch { - throwExtensionEncoderError( - 'malformed_known_extension_value', - `Custom extension ${extension.oid} does not decode as ${definition.oid}`, - ); + const value = new Uint8Array(extension.value); + assertKnownExtensionDecodes(definition, value, extension.oid); + if (oid === OIDS.cRLDistributionPoints) { + for (const point of decodeCrlDistributionPoints(value)) { + assertCrlIssuerDistinguishedNames(point); + } } } } +/** Reject a custom payload that is not the DER its known OID's schema defines. */ +function assertKnownExtensionDecodes( + definition: { readonly oid: string; decode(valueDer: Uint8Array): unknown }, + value: Uint8Array, + submittedOid: string, +): void { + try { + definition.decode(value); + } catch { + throwExtensionEncoderError( + 'malformed_known_extension_value', + `Custom extension ${submittedOid} does not decode as ${definition.oid}`, + ); + } +} + +/** Decode a CRLDistributionPoints payload already known to be well-formed. */ +function decodeCrlDistributionPoints( + value: Uint8Array, +): ReturnType { + return CRL_DISTRIBUTION_POINTS_EXTENSION_DEFINITION.decode(value); +} + /** Effective basicConstraints across the typed field and any custom-known extension. */ function resolveEffectiveBasicConstraints( input: CertificateExtensionsInput | undefined, @@ -1441,8 +1470,12 @@ function encodeGeneralSubtree(subtree: GeneralSubtree): Uint8Array { /** * RFC 5280 §4.2.1.13: cRLIssuer, when present, only contains the CRL issuer's * distinguished name; nameRelativeToCRLIssuer additionally requires exactly one. + * + * Applies to a typed {@linkcode DistributionPoint} and to a decoded + * {@linkcode ParsedDistributionPoint} alike, so the rule holds whether the value + * arrives through `crlDistributionPoints` or through `customExtensions`. */ -function assertCrlIssuerDistinguishedNames(point: DistributionPoint): void { +function assertCrlIssuerDistinguishedNames(point: CrlIssuerConstrainedPoint): void { if (point.crlIssuer === undefined) { return; } @@ -1646,11 +1679,22 @@ export function buildSubjectKeyIdentifier(subjectPublicKeyInfo: Uint8Array): Uin return sha1(publicKeyBytes); } -/** Throw if the string is not a valid dotted-decimal OID. */ +/** + * Throw if the string is not an encodable dotted-decimal OID. + * + * Syntax alone is not enough: X.660 bounds the first arc to 0, 1, or 2 and the + * second to under 40 beneath arcs 0 and 1, so `3.1` and `1.40` parse as decimals + * yet cannot be encoded. + */ function validateOid(oid: string): void { if (!/^\d+(?:\.\d+)+$/.test(oid)) { throwExtensionEncoderError('invalid_oid', `Invalid OID: ${oid}`); } + try { + canonicalizeOid(oid); + } catch { + throwExtensionEncoderError('invalid_oid', `Invalid OID: ${oid}`); + } } /** Validate that a policy OID is syntactically valid. */ diff --git a/test/certificate.test.ts b/test/certificate.test.ts index c559742..087d51a 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -18,6 +18,7 @@ import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/inter import { encodeName, encodeSubjectAltName } from '#micro509/x509'; import { childrenOf, + createSelfSignedCertificateWithRawExtensions, decodeObjectIdentifier, encodeUncheckedCrlDistributionPoints, hasExtensionOid, @@ -301,7 +302,7 @@ describe('certificate', () => { }); it('parses issuer-only CRL distribution points that name a non-DN CRL issuer', async () => { - const { certificate } = await createSelfSignedCertificate({ + const { certificate } = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'issuer-only-dp.example' }, extensions: { customExtensions: [ diff --git a/test/crl.test.ts b/test/crl.test.ts index d272269..85dfb1e 100644 --- a/test/crl.test.ts +++ b/test/crl.test.ts @@ -34,6 +34,7 @@ import { encodeSubjectAltName } from '#micro509/x509'; import { addRevokedEntryCertificateIssuers, childrenOf, + createCertificateWithRawExtensions, decodeObjectIdentifier, encodeUncheckedCrlDistributionPoints, hexToBytes, @@ -617,7 +618,7 @@ describe('crl', () => { }); const alternateIssuerLeafKeys = await generateKeyPair(); - const alternateIssuerLeaf = await createCertificate({ + const alternateIssuerLeaf = await createCertificateWithRawExtensions({ issuer: { commonName: 'Certificate Issuer CA' }, subject: { commonName: 'alternate-crl-issuer.example' }, publicKey: alternateIssuerLeafKeys.publicKey, @@ -1084,109 +1085,100 @@ describe('crl', () => { }); }); - it('normalizes distribution point URIs before comparing them (RFC 5280 §7.4)', async () => { + // RFC 5280 §7.4 step 3 decodes unreserved octets, uppercases the remaining + // triplets, and covers the whole unreserved set; steps 2 and 5 lowercase the + // host and drop the default port even for a scheme the URL parser treats as + // opaque; step 1 reduces an IDN host carried as percent-encoded UTF-8 to ACE. + it.each([ + ['http://crl.example/%7Ecerts/a.crl', 'http://crl.example/~certs/a.crl'], + ['http://crl.example/a%2fb.crl', 'http://crl.example/a%2Fb.crl'], + ['http://crl.example/%41%39%2D%2E%5F%7E.crl', 'http://crl.example/A9-._~.crl'], + ['ldap://CRL.EXAMPLE:389/cn=crl', 'ldap://crl.example/cn=crl'], + ['ldap://xn--bcher-kva.example/cn=crl', 'ldap://b%C3%BCcher.example/cn=crl'], + ])('treats %s and %s as the same distribution point', async (certificateUri, crlUri) => { const ca = await createSelfSignedCertificate({ subject: { commonName: 'URI Norm CA' }, extensions: { basicConstraints: { ca: true }, keyUsage: ['keyCertSign', 'cRLSign'] }, }); - const equivalent = [ - // Step 3: percent-encoding normalization decodes unreserved octets. - ['http://crl.example/%7Ecerts/a.crl', 'http://crl.example/~certs/a.crl'], - // Step 3: the remaining triplets normalize to uppercase hex. - ['http://crl.example/a%2fb.crl', 'http://crl.example/a%2Fb.crl'], - // Step 3 over the whole unreserved set: ALPHA, DIGIT, "-", ".", "_", "~". - ['http://crl.example/%41%39%2D%2E%5F%7E.crl', 'http://crl.example/A9-._~.crl'], - // Steps 2 and 5 for a scheme the URL parser treats as opaque. - ['ldap://CRL.EXAMPLE:389/cn=crl', 'ldap://crl.example/cn=crl'], - // Step 1: an IDN host carried as percent-encoded UTF-8 reduces to ASCII - // Compatible Encoding. - ['ldap://xn--bcher-kva.example/cn=crl', 'ldap://b%C3%BCcher.example/cn=crl'], - ] as const; - for (const [certificateUri, crlUri] of equivalent) { - const leafKeys = await generateKeyPair(); - const leaf = await createCertificate({ - issuer: { commonName: 'URI Norm CA' }, - subject: { commonName: 'uri-norm.example' }, - publicKey: leafKeys.publicKey, - signerPrivateKey: ca.keyPair.privateKey, - issuerPublicKey: ca.keyPair.publicKey, - extensions: { - crlDistributionPoints: [ - { distributionPoint: { fullName: [{ type: 'uri', value: certificateUri }] } }, - ], - }, - }); - const crl = await createCertificateRevocationList({ - issuer: { commonName: 'URI Norm CA' }, - signerPrivateKey: ca.keyPair.privateKey, - issuerPublicKey: ca.keyPair.publicKey, - issuingDistributionPoint: { - distributionPoint: { fullName: [{ type: 'uri', value: crlUri }] }, - }, - }); - expect( - await checkCertificateRevocationAgainstCrl({ - certificate: leaf.pem, - issuerCertificate: ca.certificate.pem, - crl: crl.pem, - }), - ).toMatchObject({ ok: true, value: { status: 'good' } }); - } + const leafKeys = await generateKeyPair(); + const leaf = await createCertificate({ + issuer: { commonName: 'URI Norm CA' }, + subject: { commonName: 'uri-norm.example' }, + publicKey: leafKeys.publicKey, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + extensions: { + crlDistributionPoints: [ + { distributionPoint: { fullName: [{ type: 'uri', value: certificateUri }] } }, + ], + }, + }); + const crl = await createCertificateRevocationList({ + issuer: { commonName: 'URI Norm CA' }, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + issuingDistributionPoint: { + distributionPoint: { fullName: [{ type: 'uri', value: crlUri }] }, + }, + }); + expect( + await checkCertificateRevocationAgainstCrl({ + certificate: leaf.pem, + issuerCertificate: ca.certificate.pem, + crl: crl.pem, + }), + ).toMatchObject({ ok: true, value: { status: 'good' } }); }); - it('keeps distinct distribution point URIs distinct under normalization', async () => { + // `%2F` is reserved, so decoding it would collapse two different paths. The + // remaining cases cover a non-conformant relative reference, a host whose + // percent-decoding would change the authority, a host that decodes to an + // unparseable authority, and a host carrying invalid UTF-8. + it.each([ + ['http://crl.example/a%2Fb.crl', 'http://crl.example/a/b.crl'], + ['http://crl.example/a.crl', 'http://crl.example/b.crl'], + ['ldap://crl.example:390/cn=crl', 'ldap://crl.example/cn=crl'], + ['crl.example/a.crl', 'crl.example/b.crl'], + ['ldap://a%2Fb.example/cn=crl', 'ldap://a%2Fc.example/cn=crl'], + ['ldap://%5B%5D/cn=crl', 'ldap://crl.example/cn=crl'], + ['ldap://a%FF.example/cn=crl', 'ldap://crl.example/cn=crl'], + ])('keeps %s and %s distinct under normalization', async (certificateUri, crlUri) => { const ca = await createSelfSignedCertificate({ subject: { commonName: 'URI Distinct CA' }, extensions: { basicConstraints: { ca: true }, keyUsage: ['keyCertSign', 'cRLSign'] }, }); - const distinct = [ - // %2F is reserved, so decoding it would collapse two different paths. - ['http://crl.example/a%2Fb.crl', 'http://crl.example/a/b.crl'], - ['http://crl.example/a.crl', 'http://crl.example/b.crl'], - ['ldap://crl.example:390/cn=crl', 'ldap://crl.example/cn=crl'], - // A non-conformant relative reference normalizes to nothing comparable. - ['crl.example/a.crl', 'crl.example/b.crl'], - // A host whose percent-decoding would change the authority is left alone. - ['ldap://a%2Fb.example/cn=crl', 'ldap://a%2Fc.example/cn=crl'], - // A host that decodes to an unparseable authority. - ['ldap://%5B%5D/cn=crl', 'ldap://crl.example/cn=crl'], - // A host carrying a percent sequence that is not valid UTF-8. - ['ldap://a%FF.example/cn=crl', 'ldap://crl.example/cn=crl'], - ] as const; - for (const [certificateUri, crlUri] of distinct) { - const leafKeys = await generateKeyPair(); - const leaf = await createCertificate({ - issuer: { commonName: 'URI Distinct CA' }, - subject: { commonName: 'uri-distinct.example' }, - publicKey: leafKeys.publicKey, - signerPrivateKey: ca.keyPair.privateKey, - issuerPublicKey: ca.keyPair.publicKey, - extensions: { - crlDistributionPoints: [ - { distributionPoint: { fullName: [{ type: 'uri', value: certificateUri }] } }, - ], - }, - }); - const crl = await createCertificateRevocationList({ - issuer: { commonName: 'URI Distinct CA' }, - signerPrivateKey: ca.keyPair.privateKey, - issuerPublicKey: ca.keyPair.publicKey, - issuingDistributionPoint: { - distributionPoint: { fullName: [{ type: 'uri', value: crlUri }] }, - }, - }); - expect( - await checkCertificateRevocationAgainstCrl({ - certificate: leaf.pem, - issuerCertificate: ca.certificate.pem, - crl: crl.pem, - }), - ).toMatchObject({ - ok: false, - code: 'non_applicable', - details: { reason: 'distribution_point_mismatch' }, - }); - } + const leafKeys = await generateKeyPair(); + const leaf = await createCertificate({ + issuer: { commonName: 'URI Distinct CA' }, + subject: { commonName: 'uri-distinct.example' }, + publicKey: leafKeys.publicKey, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + extensions: { + crlDistributionPoints: [ + { distributionPoint: { fullName: [{ type: 'uri', value: certificateUri }] } }, + ], + }, + }); + const crl = await createCertificateRevocationList({ + issuer: { commonName: 'URI Distinct CA' }, + signerPrivateKey: ca.keyPair.privateKey, + issuerPublicKey: ca.keyPair.publicKey, + issuingDistributionPoint: { + distributionPoint: { fullName: [{ type: 'uri', value: crlUri }] }, + }, + }); + expect( + await checkCertificateRevocationAgainstCrl({ + certificate: leaf.pem, + issuerCertificate: ca.certificate.pem, + crl: crl.pem, + }), + ).toMatchObject({ + ok: false, + code: 'non_applicable', + details: { reason: 'distribution_point_mismatch' }, + }); }); it('freezes the canonical reason list so results cannot corrupt it', () => { @@ -2050,7 +2042,7 @@ describe('crl', () => { }, }); const leafKeys = await generateKeyPair(); - const leaf = await createCertificate({ + const leaf = await createCertificateWithRawExtensions({ issuer: { commonName: 'Unsupported cRLIssuer Leaf Issuer' }, subject: { commonName: 'unsupported-crl-issuer-name.example' }, publicKey: leafKeys.publicKey, diff --git a/test/internals.test.ts b/test/internals.test.ts index 792cd37..621edb5 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -104,7 +104,7 @@ import { encodeRelativeDistinguishedName, encodeSubjectAltName, } from '#micro509/x509'; -import { childrenOf } from '#test/helpers'; +import { childrenOf, encodeUncheckedCrlDistributionPoints } from '#test/helpers'; function expectEncoderErrorCode(fn: () => unknown, code: string): void { try { @@ -1006,8 +1006,9 @@ describe('extensions encoding', () => { ); }); - it("rejects a custom extension whose payload is not that known OID's DER", () => { - for (const oid of [OIDS.basicConstraints, OIDS.keyUsage, OIDS.subjectAltName]) { + it.each([OIDS.basicConstraints, OIDS.keyUsage, OIDS.subjectAltName])( + "rejects a custom %s payload that is not that OID's DER", + (oid) => { expectEncoderErrorCode( () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, { @@ -1022,7 +1023,60 @@ describe('extensions encoding', () => { }), 'malformed_known_extension_value', ); + }, + ); + + it('applies cRLIssuer constraints to a custom CRLDistributionPoints payload', () => { + const nonDirectoryName = encodeUncheckedCrlDistributionPoints([ + { + fullNameUri: 'http://crl.example/a.crl', + crlIssuer: [{ type: 'uri', value: 'http://crl.example/issuer' }], + }, + ]); + for (const build of [ + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + customExtensions: [{ oid: OIDS.cRLDistributionPoints, value: nonDirectoryName }], + }), + () => + buildRequestedExtensions({ + customExtensions: [{ oid: OIDS.cRLDistributionPoints, value: nonDirectoryName }], + }), + () => + buildRequestedExtensions({ + customExtensions: [{ oid: '2.5.029.31', value: nonDirectoryName }], + }), + ]) { + expectEncoderErrorCode(build, 'distribution_point_crl_issuer_not_directory_name'); } + + // A conformant custom payload still builds. + expect( + buildRequestedExtensions({ + customExtensions: [ + { + oid: OIDS.cRLDistributionPoints, + value: encodeCrlDistributionPoints([ + { + distributionPoint: { + fullName: [{ type: 'uri', value: 'http://crl.example/a.crl' }], + }, + }, + ]), + }, + ], + }), + ).toBeInstanceOf(Array); + }); + + it.each(['3.1', '1.40'])('rejects OID %s, which violates the X.660 arc bounds', (oid) => { + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'invalid_oid', + ); }); it('resolves a known extension supplied under a non-canonical OID spelling', () => { diff --git a/test/parse.test.ts b/test/parse.test.ts index c3c5e3f..148aa96 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -2675,39 +2675,37 @@ describe('subjectAltName and extendedKeyUsage parse strictness', () => { ).toThrow(); }); - it('rejects zero-length IA5 GeneralNames in certificates and CSRs (RFC 5280 §4.2.1.6)', async () => { - const emptyIa5Tags = [ - { tag: 0x81, alternative: 'rfc822Name' }, - { tag: 0x82, alternative: 'dNSName' }, - { tag: 0x86, alternative: 'uniformResourceIdentifier' }, - ]; - for (const { tag, alternative } of emptyIa5Tags) { - const extension = { - oid: OIDS.subjectAltName, - value: sequence([tlv(tag, new Uint8Array())]), - }; - const certificate = await createSelfSignedCertificateWithRawExtensions({ - subject: { commonName: `empty-${alternative}.example` }, - extensions: { customExtensions: [extension] }, - }); - const certificateResult = parseCertificateDer(certificate.certificate.der); - expect(certificateResult.ok).toBe(false); - if (!certificateResult.ok) { - expect(certificateResult.error.code).toBe('malformed'); - expect(certificateResult.error.message).toContain(`${alternative} must not be empty`); - } - - const keyPair = await generateKeyPair(); - const csr = await createCsrWithRawExtensions({ - subject: { commonName: `empty-${alternative}.example` }, - publicKey: keyPair.publicKey, - signerPrivateKey: keyPair.privateKey, - extensions: { customExtensions: [extension] }, - }); - const csrResult = parseCertificateSigningRequestDer(csr.der); - expect(csrResult.ok).toBe(false); - if (!csrResult.ok) expect(csrResult.error.code).toBe('malformed'); + // RFC 5280 §4.2.1.6 forbids a zero-length GeneralName value. + it.each([ + [0x81, 'rfc822Name'], + [0x82, 'dNSName'], + [0x86, 'uniformResourceIdentifier'], + ])('rejects a zero-length %s GeneralName in certificates and CSRs', async (tag, alternative) => { + const extension = { + oid: OIDS.subjectAltName, + value: sequence([tlv(tag, new Uint8Array())]), + }; + const certificate = await createSelfSignedCertificateWithRawExtensions({ + subject: { commonName: `empty-${alternative}.example` }, + extensions: { customExtensions: [extension] }, + }); + const certificateResult = parseCertificateDer(certificate.certificate.der); + expect(certificateResult.ok).toBe(false); + if (!certificateResult.ok) { + expect(certificateResult.error.code).toBe('malformed'); + expect(certificateResult.error.message).toContain(`${alternative} must not be empty`); } + + const keyPair = await generateKeyPair(); + const csr = await createCsrWithRawExtensions({ + subject: { commonName: `empty-${alternative}.example` }, + publicKey: keyPair.publicKey, + signerPrivateKey: keyPair.privateKey, + extensions: { customExtensions: [extension] }, + }); + const csrResult = parseCertificateSigningRequestDer(csr.der); + expect(csrResult.ok).toBe(false); + if (!csrResult.ok) expect(csrResult.error.code).toBe('malformed'); }); it('maps malformed subjectAltName and extendedKeyUsage in certificates and CSRs', async () => { From 46e46c321e1e6a609e1f9a743b608681270e402c Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 00:45:40 +0200 Subject: [PATCH 07/14] docs(test): add Bun testing reference --- test/AGENTS.md | 684 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 684 insertions(+) diff --git a/test/AGENTS.md b/test/AGENTS.md index 37cc43d..6706cbe 100644 --- a/test/AGENTS.md +++ b/test/AGENTS.md @@ -56,3 +56,687 @@ test/ - Do not overfit to OpenSSL text output. - Do not hide expected failure reasons behind generic booleans when a typed code exists. - Do not drop vendored PKITS naming; upstream-style names are part of the harness contract. + +## Documentation Index + +> Fetch the complete documentation index at: https://bun.com/docs/llms.txt +> Use this file to discover all available pages before exploring further. + +## Writing tests + +> Write tests with Bun's Jest-compatible API, including async tests, timeouts, and test modifiers + +Define tests with a Jest-like API imported from the built-in `bun:test` module. Long term, Bun aims for complete Jest compatibility; a limited set of `expect` matchers is supported. + +## Basic Usage + +To define a test: + +```ts title="math.test.ts" +import { expect, test } from 'bun:test'; + +test('2 + 2', () => { + expect(2 + 2).toBe(4); +}); +``` + +### Grouping Tests + +Group tests into suites with `describe`. + +```ts title="math.test.ts" +import { expect, test, describe } from 'bun:test'; + +describe('arithmetic', () => { + test('2 + 2', () => { + expect(2 + 2).toBe(4); + }); + + test('2 * 2', () => { + expect(2 * 2).toBe(4); + }); +}); +``` + +### Async Tests + +Tests can be async. + +```ts title="math.test.ts" +import { expect, test } from 'bun:test'; + +test('2 * 2', async () => { + const result = await Promise.resolve(2 * 2); + expect(result).toEqual(4); +}); +``` + +Alternatively, use the `done` callback to signal completion. If your test function takes a `done` parameter, you must call it or the test hangs. + +```ts title="math.test.ts" +import { expect, test } from 'bun:test'; + +test('2 * 2', (done) => { + Promise.resolve(2 * 2).then((result) => { + expect(result).toEqual(4); + done(); + }); +}); +``` + +## Timeouts + +Optionally specify a per-test timeout in milliseconds by passing a number as the third argument to `test`. + +```ts title="math.test.ts" +import { test } from 'bun:test'; + +test('wat', async () => { + const data = await slowOperation(); + expect(data).toBe(42); +}, 500); // test must run in <500ms +``` + +In `bun:test`, a timeout throws an uncatchable exception to force the test to stop running and fail. Bun also kills any child processes spawned in the test, so they don't linger as zombie processes. + +The default timeout for each test is 5000ms (5 seconds) if not overridden by this timeout option or `jest.setTimeout()`. + +## Retries and Repeats + +### test.retry + +Use the `retry` option to automatically retry a flaky test when it fails. The test passes if it succeeds within the specified number of attempts. + +```ts title="example.test.ts" +import { test } from 'bun:test'; + +test( + 'flaky network request', + async () => { + const response = await fetch('https://example.com/api'); + expect(response.ok).toBe(true); + }, + { retry: 3 }, // Retry up to 3 times if the test fails +); +``` + +### test.repeats + +Use the `repeats` option to run a test multiple times regardless of pass/fail status; the test fails if any iteration fails. Use it to detect flaky tests or for stress testing. `repeats: N` runs the test N+1 times total (1 initial run + N repeats). + +```ts title="example.test.ts" +import { test } from 'bun:test'; + +test( + 'ensure test is stable', + () => { + expect(Math.random()).toBeLessThan(1); + }, + { repeats: 20 }, // Runs 21 times total (1 initial + 20 repeats) +); +``` + +You cannot use both `retry` and `repeats` on the same test. + +### 🧟 Zombie Process Killer + +When a test times out, Bun kills any processes spawned in it with `Bun.spawn`, `Bun.spawnSync`, or `node:child_process` that are still running, and logs a message to the console. This prevents zombie processes from lingering after timed-out tests. + +## Test Modifiers + +### test.skip + +Skip individual tests with `test.skip`. These tests are not run. + +```ts title="math.test.ts" +import { expect, test } from 'bun:test'; + +test.skip('wat', () => { + // TODO: fix this + expect(0.1 + 0.2).toEqual(0.3); +}); +``` + +### test.todo + +Mark a test as a todo with `test.todo`. These tests are not run. + +```ts title="math.test.ts" +import { expect, test } from 'bun:test'; + +test.todo('fix this', () => { + myTestFunction(); +}); +``` + +To run todo tests and find any that pass, use `bun test --todo`. + +```bash terminal icon="terminal" theme={"theme":{"light":"github-light","dark":"dracula"}} +bun test --todo +``` + +```console +my.test.ts: +✗ unimplemented feature + ^ this test is marked as todo but passes. Remove `.todo` or check that test is correct. + + 0 pass + 1 fail + 1 expect() calls +``` + +With this flag, failing todo tests do not cause an error, but todo tests that pass are marked as failing so you can remove the todo mark or fix the test. + +### test.only + +To run a particular test or suite of tests, use `test.only()` or `describe.only()`. + +```ts title="example.test.ts" +import { test, describe } from 'bun:test'; + +test('test #1', () => { + // does not run +}); + +test.only('test #2', () => { + // runs +}); + +describe.only('only', () => { + test('test #3', () => { + // runs + }); +}); +``` + +The following command runs only tests #2 and #3. + +```bash terminal icon="terminal" theme={"theme":{"light":"github-light","dark":"dracula"}} +bun test --only +``` + +### test.if + +To run a test conditionally, use `test.if()`. The test runs if the condition is truthy. Use it for tests that should only run on a specific architecture or operating system. + +```ts title="example.test.ts" +test.if(Math.random() > 0.5)('runs half the time', () => { + // ... +}); + +const macOS = process.platform === 'darwin'; +test.if(macOS)('runs on macOS', () => { + // runs if macOS +}); +``` + +### test.skipIf + +To instead skip a test based on some condition, use `test.skipIf()` or `describe.skipIf()`. + +```ts title="example.test.ts" +const macOS = process.platform === 'darwin'; + +test.skipIf(macOS)('runs on non-macOS', () => { + // runs if *not* macOS +}); +``` + +### test.todoIf + +To mark the test as TODO instead, use `test.todoIf()` or `describe.todoIf()`. The choice between `skipIf` and `todoIf` signals intent: "invalid for this target" versus "planned but not implemented yet." + +```ts title="example.test.ts" +const macOS = process.platform === 'darwin'; + +// TODO: we've only implemented this for Linux so far. +test.todoIf(macOS)('runs on posix', () => { + // runs if *not* macOS +}); +``` + +### test.failing + +Use `test.failing()` when you know a test is failing but you want to track it and be notified when it starts passing. This inverts the test result: + +- A failing test marked with `.failing()` passes +- A passing test marked with `.failing()` fails, with a message that it now passes and should be fixed + +```ts math.test.ts +// This will pass because the test is failing as expected +test.failing('math is broken', () => { + expect(0.1 + 0.2).toBe(0.3); // fails due to floating point precision +}); + +// This will fail with a message that the test is now passing +test.failing('fixed bug', () => { + expect(1 + 1).toBe(2); // passes, but we expected it to fail +}); +``` + +Use it to track known bugs you plan to fix later, or for test-driven development. + +## Conditional Tests for Describe Blocks + +The conditional modifiers `.if()`, `.skipIf()`, and `.todoIf()` also work on `describe` blocks, affecting all tests in the suite: + +```ts title="example.test.ts" +const isMacOS = process.platform === 'darwin'; + +// Only runs the entire suite on macOS +describe.if(isMacOS)('macOS-specific features', () => { + test('feature A', () => { + // only runs on macOS + }); + + test('feature B', () => { + // only runs on macOS + }); +}); + +// Skips the entire suite on Windows +describe.skipIf(process.platform === 'win32')('Unix features', () => { + test('feature C', () => { + // skipped on Windows + }); +}); + +// Marks the entire suite as TODO on Linux +describe.todoIf(process.platform === 'linux')('Upcoming Linux support', () => { + test('feature D', () => { + // marked as TODO on Linux + }); +}); +``` + +## Parametrized Tests + +### `test.each` and `describe.each` + +To run the same test with multiple sets of data, use `test.each`. This creates a parametrized test that runs once for each test case provided. + +```ts title="math.test.ts" +const cases = [ + [1, 2, 3], + [3, 4, 7], +]; + +test.each(cases)('%p + %p should be %p', (a, b, expected) => { + expect(a + b).toBe(expected); +}); +``` + +`describe.each` creates a parametrized suite that runs once for each test case: + +```ts title="sum.test.ts" +describe.each([ + [1, 2, 3], + [3, 4, 7], +])('add(%i, %i)', (a, b, expected) => { + test(`returns ${expected}`, () => { + expect(a + b).toBe(expected); + }); + + test(`sum is greater than each value`, () => { + expect(a + b).toBeGreaterThan(a); + expect(a + b).toBeGreaterThan(b); + }); +}); +``` + +### Argument Passing + +How arguments are passed to your test function depends on the structure of your test cases: + +- If a table row is an array (like `[1, 2, 3]`), each element is passed as an individual argument +- If a row is not an array (like an object), it's passed as a single argument + +```ts title="example.test.ts" +// Array items passed as individual arguments +test.each([ + [1, 2, 3], + [4, 5, 9], +])('add(%i, %i) = %i', (a, b, expected) => { + expect(a + b).toBe(expected); +}); + +// Object items passed as a single argument +test.each([ + { a: 1, b: 2, expected: 3 }, + { a: 4, b: 5, expected: 9 }, +])('add($a, $b) = $expected', (data) => { + expect(data.a + data.b).toBe(data.expected); +}); +``` + +### Format Specifiers + +Use these specifiers to format the test title: + +| Specifier | Description | +| --------- | ----------------------- | +| `%p` | pretty-format | +| `%s` | String | +| `%d` | Number | +| `%i` | Integer | +| `%f` | Floating point | +| `%j` | JSON | +| `%o` | Object | +| `%#` | Index of the test case | +| `%%` | Single percent sign (%) | + +#### Examples + +```ts title="example.test.ts" +// Basic specifiers +test.each([ + ['hello', 123], + ['world', 456], +])('string: %s, number: %i', (str, num) => { + // "string: hello, number: 123" + // "string: world, number: 456" +}); + +// %p for pretty-format output +test.each([ + [{ name: 'Alice' }, { a: 1, b: 2 }], + [{ name: 'Bob' }, { x: 5, y: 10 }], +])('user %p with data %p', (user, data) => { + // "user { name: 'Alice' } with data { a: 1, b: 2 }" + // "user { name: 'Bob' } with data { x: 5, y: 10 }" +}); + +// %# for index +test.each(['apple', 'banana'])('fruit #%# is %s', (fruit) => { + // "fruit #0 is apple" + // "fruit #1 is banana" +}); +``` + +## Assertion Counting + +Bun supports verifying that a specific number of assertions were called during a test: + +### expect.hasAssertions() + +Use `expect.hasAssertions()` to verify that at least one assertion is called during a test: + +```ts title="example.test.ts" +test('async work calls assertions', async () => { + expect.hasAssertions(); // Will fail if no assertions are called + + const data = await fetchData(); + expect(data).toBeDefined(); +}); +``` + +This is especially useful in async tests, to make sure your assertions run. + +### expect.assertions(count) + +Use `expect.assertions(count)` to verify that a specific number of assertions are called during a test: + +```ts title="example.test.ts" +test('exactly two assertions', () => { + expect.assertions(2); // Will fail if not exactly 2 assertions are called + + expect(1 + 1).toBe(2); + expect('hello').toContain('ell'); +}); +``` + +This helps ensure all your assertions run, especially in complex async code with multiple code paths. + +## Type Testing + +Bun includes `expectTypeOf` for testing TypeScript types, compatible with Vitest. + +### expectTypeOf + +These functions are no-ops at runtime. Run TypeScript separately to verify the type checks. + +The `expectTypeOf` function provides type-level assertions that are checked by TypeScript's type checker. To test your types: + +1. Write your type assertions using `expectTypeOf` +2. Run `bunx tsc --noEmit` to check that your types are correct + +```ts title="example.test.ts" +import { expectTypeOf } from 'bun:test'; + +// Basic type assertions +expectTypeOf().toEqualTypeOf(); +expectTypeOf(123).toBeNumber(); +expectTypeOf('hello').toBeString(); + +// Object type matching +expectTypeOf({ a: 1, b: 'hello' }).toMatchObjectType<{ a: number }>(); + +// Function types +function greet(name: string): string { + return `Hello ${name}`; +} + +expectTypeOf(greet).toBeFunction(); +expectTypeOf(greet).parameters.toEqualTypeOf<[string]>(); +expectTypeOf(greet).returns.toEqualTypeOf(); + +// Array types +expectTypeOf([1, 2, 3]).items.toBeNumber(); + +// Promise types +expectTypeOf(Promise.resolve(42)).resolves.toBeNumber(); +``` + +For full documentation on `expectTypeOf` matchers, see the [API Reference](https://bun.com/reference/bun/test/expectTypeOf). + +## Matchers + +Bun implements the following matchers. Full Jest compatibility is planned; see the [tracking issue](https://github.com/oven-sh/bun/issues/1825). + +### Basic Matchers + +| Status | Matcher | +| ------ | ------------------ | +| ✅ | `.not` | +| ✅ | `.toBe()` | +| ✅ | `.toEqual()` | +| ✅ | `.toBeNull()` | +| ✅ | `.toBeUndefined()` | +| ✅ | `.toBeNaN()` | +| ✅ | `.toBeDefined()` | +| ✅ | `.toBeFalsy()` | +| ✅ | `.toBeTruthy()` | +| ✅ | `.toStrictEqual()` | + +### String and Array Matchers + +| Status | Matcher | +| ------ | --------------------- | +| ✅ | `.toContain()` | +| ✅ | `.toHaveLength()` | +| ✅ | `.toMatch()` | +| ✅ | `.toContainEqual()` | +| ✅ | `.stringContaining()` | +| ✅ | `.stringMatching()` | +| ✅ | `.arrayContaining()` | + +### Object Matchers + +| Status | Matcher | +| ------ | ----------------------- | +| ✅ | `.toHaveProperty()` | +| ✅ | `.toMatchObject()` | +| ✅ | `.toContainAllKeys()` | +| ✅ | `.toContainValue()` | +| ✅ | `.toContainValues()` | +| ✅ | `.toContainAllValues()` | +| ✅ | `.toContainAnyValues()` | +| ✅ | `.objectContaining()` | + +### Number Matchers + +| Status | Matcher | +| ------ | --------------------------- | +| ✅ | `.toBeCloseTo()` | +| ✅ | `.closeTo()` | +| ✅ | `.toBeGreaterThan()` | +| ✅ | `.toBeGreaterThanOrEqual()` | +| ✅ | `.toBeLessThan()` | +| ✅ | `.toBeLessThanOrEqual()` | + +### Function and Class Matchers + +| Status | Matcher | +| ------ | ------------------- | +| ✅ | `.toThrow()` | +| ✅ | `.toBeInstanceOf()` | + +### Promise Matchers + +| Status | Matcher | +| ------ | ------------- | +| ✅ | `.resolves()` | +| ✅ | `.rejects()` | + +### Mock Function Matchers + +| Status | Matcher | +| ------ | ----------------------------- | +| ✅ | `.toHaveBeenCalled()` | +| ✅ | `.toHaveBeenCalledTimes()` | +| ✅ | `.toHaveBeenCalledWith()` | +| ✅ | `.toHaveBeenLastCalledWith()` | +| ✅ | `.toHaveBeenNthCalledWith()` | +| ✅ | `.toHaveReturned()` | +| ✅ | `.toHaveReturnedTimes()` | +| ✅ | `.toHaveReturnedWith()` | +| ✅ | `.toHaveLastReturnedWith()` | +| ✅ | `.toHaveNthReturnedWith()` | + +### Snapshot Matchers + +| Status | Matcher | +| ------ | --------------------------------------- | +| ✅ | `.toMatchSnapshot()` | +| ✅ | `.toMatchInlineSnapshot()` | +| ✅ | `.toThrowErrorMatchingSnapshot()` | +| ✅ | `.toThrowErrorMatchingInlineSnapshot()` | + +### Utility Matchers + +| Status | Matcher | +| ------ | ------------------ | +| ✅ | `.extend` | +| ✅ | `.anything()` | +| ✅ | `.any()` | +| ✅ | `.assertions()` | +| ✅ | `.hasAssertions()` | + +### Not Yet Implemented + +| Status | Matcher | +| ------ | -------------------------- | +| ❌ | `.addSnapshotSerializer()` | + +## Best Practices + +### Use Descriptive Test Names + +```ts title="example.test.ts" +// Good +test('should calculate total price including tax for multiple items', () => { + // test implementation +}); + +// Avoid +test('price calculation', () => { + // test implementation +}); +``` + +### Group Related Tests + +```ts title="auth.test.ts" +describe('User authentication', () => { + describe('with valid credentials', () => { + test('should return user data', () => { + // test implementation + }); + + test('should set authentication token', () => { + // test implementation + }); + }); + + describe('with invalid credentials', () => { + test('should throw authentication error', () => { + // test implementation + }); + }); +}); +``` + +### Use Appropriate Matchers + +```ts title="auth.test.ts" +// Good: Use specific matchers +expect(users).toHaveLength(3); +expect(user.email).toContain('@'); +expect(response.status).toBeGreaterThanOrEqual(200); + +// Avoid: Using toBe for everything +expect(users.length === 3).toBe(true); +expect(user.email.includes('@')).toBe(true); +expect(response.status >= 200).toBe(true); +``` + +### Test Error Conditions + +```ts title="example.test.ts" +test('should throw error for invalid input', () => { + expect(() => { + validateEmail('not-an-email'); + }).toThrow('Invalid email format'); +}); + +test('should handle async errors', async () => { + await expect(async () => { + await fetchUser('invalid-id'); + }).rejects.toThrow('User not found'); +}); +``` + +### Use Setup and Teardown + +```ts title="example.test.ts" +import { beforeEach, afterEach, test } from 'bun:test'; + +let testUser; + +beforeEach(() => { + testUser = createTestUser(); +}); + +afterEach(() => { + cleanupTestUser(testUser); +}); + +test('should update user profile', () => { + // Use testUser in test +}); +``` + +## More info + +- https://bun.com/docs/test.md "Overview" +- https://bun.com/docs/test/writing-tests.md "This page" +- https://bun.com/docs/test/configuration.md "bunfig.toml" +- https://bun.com/docs/test/runtime-behavior.md "Runtime integration, environment variables, timeouts, and error handling" +- https://bun.com/docs/test/discovery.md "Discover and filter test files in your project" +- https://bun.com/docs/test/lifecycle.md "beforeAll, beforeEach, afterEach, afterAll, and onTestFinished lifecycle hooks" +- https://bun.com/docs/test/mocks.md "Mock functions, spies, and module mocks" +- https://bun.com/docs/test/snapshots.md "Save and compare output between test runs" +- https://bun.com/docs/test/dates-times.md "Manipulate time and dates using setSystemTime and Jest compatibility functions" +- https://bun.com/docs/test/dom.md "Test DOM elements and components using Bun with happy-dom and React Testing Library" +- https://bun.com/docs/test/code-coverage.md "Track test coverage and find untested code" +- https://bun.com/docs/test/reporters.md "Output formats through reporters, both built-in and custom" From d3df47d1b91f9cad9f184f99bd87a44eee471eab Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 01:20:00 +0200 Subject: [PATCH 08/14] fix(x509): profile-check custom extension payloads Decoding proves a payload's structure, not that it obeys the RFC 5280 profile, so a `customExtensions` value under a known OID could still carry duplicate policy OIDs, an over-long DisplayText, a non-URI OCSP location, an empty nameConstraints, or a keyUsage with no bit set. `ExtensionDefinition` now requires `assertProfile`, which delegates to the encoder owning the rule. Builders run it over the decoded payload. Parsing never calls it, so the parser stays tolerant. The payload decodes once, inside the coded-error boundary. A profile violation keeps its own code. A decode failure maps to `malformed_known_extension_value`. `encodeExtension` and the `encodeCertificatePolicies` duplicate scan validate OIDs before encoding, so an unencodable arc yields `invalid_oid` instead of a bare `Error`. `encodeBasicConstraints` gains `path_length_requires_ca`. CRLDP rules move to `assertCrlDistributionPointsProfile`, shared by the encoder and the hook. `encodeDistributionPointName` now takes the resolved name choice, dropping its unreachable branch. --- src/internal/AGENTS.md | 8 +- src/internal/x509/extension-errors.ts | 1 + src/internal/x509/extension-registry.ts | 166 ++++++++--- src/x509/extensions.ts | 244 +++++++++++------ test/helpers.ts | 18 +- test/internals.test.ts | 350 ++++++++++++++++++++++-- test/revocation.test.ts | 15 +- 7 files changed, 644 insertions(+), 158 deletions(-) diff --git a/src/internal/AGENTS.md b/src/internal/AGENTS.md index 42ec53f..b01437e 100644 --- a/src/internal/AGENTS.md +++ b/src/internal/AGENTS.md @@ -41,8 +41,12 @@ internal/ - Register new OIDs in `asn1/oids.json` under their registration arc; consume them as `OIDS.`. 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, and delegates to the encoder that owns the rule. + It runs only in builders, over a decoded `customExtensions` payload carrying a + known OID, so a raw value meets the same bar as the typed input. Parsing stays + tolerant and never calls it. - `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`. diff --git a/src/internal/x509/extension-errors.ts b/src/internal/x509/extension-errors.ts index df6dbc7..e038134 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -39,6 +39,7 @@ export type ExtensionEncoderErrorCode = | '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' diff --git a/src/internal/x509/extension-registry.ts b/src/internal/x509/extension-registry.ts index 6f29384..8363179 100644 --- a/src/internal/x509/extension-registry.ts +++ b/src/internal/x509/extension-registry.ts @@ -34,6 +34,9 @@ import type { SubjectAltName, } from '#micro509/x509/extensions'; import { + assertAuthorityInfoAccessProfile, + assertCrlDistributionPointsProfile, + assertNameConstraintsProfile, buildSubjectKeyIdentifier, encodeAuthorityInfoAccess, encodeBasicConstraints, @@ -130,10 +133,35 @@ export interface ExtensionDefinition { decode(valueDer: Uint8Array): TParsed; /** Encode a typed input into the extension's DER extnValue. */ encode(value: TInput): Uint8Array; + /** + * Apply the RFC 5280 profile rules this extension's encoder enforces to an + * already-decoded value, throwing a coded encoder error on violation. + * + * Decoding proves a payload's structure; it does not prove the payload obeys + * the profile, because the parser is deliberately tolerant. Builders route a + * `customExtensions` payload carrying a known OID through here so a raw value + * meets the same bar as the typed input for that extension. Implementations + * delegate to the encoder that owns the rule so the two cannot drift. + * + * Parsing never calls this. + */ + assertProfile(value: TParsed): void; /** Store a decoded value into the parse-time accumulator. */ applyParsed(accumulator: MutableKnownParsedExtensionAccumulator, value: TParsed): void; } +/** + * An {@linkcode ExtensionDefinition} after registration, carrying the + * decode-then-validate closure captured when `TParsed` was concrete. + */ +export type RegisteredExtensionDefinition = ExtensionDefinition< + TParsed, + TInput +> & { + /** Decode a DER extnValue and apply this extension's profile rules to it. */ + assertDerProfile(valueDer: Uint8Array): void; +}; + /** * Module-internal decode-and-apply closures, captured at definition time. * @@ -147,20 +175,23 @@ const extensionAppliers = new Map< >(); /** Registry entry for Basic Constraints (OID 2.5.29.19). Critical by default. */ -export const BASIC_CONSTRAINTS_EXTENSION_DEFINITION: ExtensionDefinition = +export const BASIC_CONSTRAINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinition = defineExtensionDefinition({ oid: OIDS.basicConstraints, contexts: ['certificate', 'csr'], defaultCritical: true, decode: (valueDer) => parseBasicConstraints(valueDer), encode: (value) => encodeBasicConstraints(value), + assertProfile: (value) => { + encodeBasicConstraints(value); + }, applyParsed: (accumulator, value) => { accumulator.basicConstraints = value; }, }); /** Registry entry for Key Usage (OID 2.5.29.15). Critical by default. */ -export const KEY_USAGE_EXTENSION_DEFINITION: ExtensionDefinition< +export const KEY_USAGE_EXTENSION_DEFINITION: RegisteredExtensionDefinition< ParsedBitFlags, readonly KeyUsage[] > = defineExtensionDefinition, readonly KeyUsage[]>({ @@ -169,13 +200,16 @@ export const KEY_USAGE_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: true, decode: (valueDer) => parseKeyUsage(valueDer), encode: (value) => encodeKeyUsage(value), + assertProfile: (value) => { + encodeKeyUsage(value.flags); + }, applyParsed: (accumulator, value) => { accumulator.keyUsage = value; }, }); /** Registry entry for Extended Key Usage (OID 2.5.29.37). Non-critical by default. */ -export const EXTENDED_KEY_USAGE_EXTENSION_DEFINITION: ExtensionDefinition< +export const EXTENDED_KEY_USAGE_EXTENSION_DEFINITION: RegisteredExtensionDefinition< readonly ExtendedKeyUsage[] > = defineExtensionDefinition({ oid: OIDS.extendedKeyUsage, @@ -183,39 +217,54 @@ export const EXTENDED_KEY_USAGE_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: false, decode: (valueDer) => parseExtendedKeyUsage(valueDer), encode: (value) => encodeExtendedKeyUsage(value), + assertProfile: (value) => { + encodeExtendedKeyUsage(value); + }, applyParsed: (accumulator, value) => { accumulator.extendedKeyUsage = value; }, }); /** Registry entry for Subject Alternative Name (OID 2.5.29.17). Non-critical by default. */ -export const SUBJECT_ALT_NAME_EXTENSION_DEFINITION: ExtensionDefinition = - defineExtensionDefinition({ - oid: OIDS.subjectAltName, - contexts: ['certificate', 'csr'], - defaultCritical: false, - decode: (valueDer) => parseSubjectAltNames(valueDer), - encode: (value) => sequence(value.map(encodeSubjectAltName)), - applyParsed: (accumulator, value) => { - accumulator.subjectAltNames = value; - }, - }); +export const SUBJECT_ALT_NAME_EXTENSION_DEFINITION: RegisteredExtensionDefinition< + readonly SubjectAltName[] +> = defineExtensionDefinition({ + oid: OIDS.subjectAltName, + contexts: ['certificate', 'csr'], + defaultCritical: false, + decode: (valueDer) => parseSubjectAltNames(valueDer), + encode: (value) => sequence(value.map(encodeSubjectAltName)), + assertProfile: (value) => { + for (const name of value) { + encodeSubjectAltName(name); + } + }, + applyParsed: (accumulator, value) => { + accumulator.subjectAltNames = value; + }, +}); /** Registry entry for Issuer Alternative Name (OID 2.5.29.18). Non-critical by default. */ -export const ISSUER_ALT_NAME_EXTENSION_DEFINITION: ExtensionDefinition = - defineExtensionDefinition({ - oid: OIDS.issuerAltName, - contexts: ['certificate'], - defaultCritical: false, - decode: (valueDer) => parseSubjectAltNames(valueDer, 'issuerAltName'), - encode: (value) => sequence(value.map(encodeSubjectAltName)), - applyParsed: (accumulator, value) => { - accumulator.issuerAltNames = value; - }, - }); +export const ISSUER_ALT_NAME_EXTENSION_DEFINITION: RegisteredExtensionDefinition< + readonly SubjectAltName[] +> = defineExtensionDefinition({ + oid: OIDS.issuerAltName, + contexts: ['certificate'], + defaultCritical: false, + decode: (valueDer) => parseSubjectAltNames(valueDer, 'issuerAltName'), + encode: (value) => sequence(value.map(encodeSubjectAltName)), + assertProfile: (value) => { + for (const name of value) { + encodeSubjectAltName(name); + } + }, + applyParsed: (accumulator, value) => { + accumulator.issuerAltNames = value; + }, +}); /** Registry entry for Name Constraints (OID 2.5.29.30). Critical by default. */ -export const NAME_CONSTRAINTS_EXTENSION_DEFINITION: ExtensionDefinition< +export const NAME_CONSTRAINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinition< NameConstraints, NameConstraints > = defineExtensionDefinition, NameConstraints>({ @@ -224,65 +273,80 @@ export const NAME_CONSTRAINTS_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: true, decode: (valueDer) => parseNameConstraints(valueDer), encode: (value) => encodeNameConstraints(value), + assertProfile: (value) => { + assertNameConstraintsProfile(value); + }, applyParsed: (accumulator, value) => { accumulator.nameConstraints = value; }, }); /** Registry entry for Certificate Policies (OID 2.5.29.32). Non-critical by default. */ -export const CERTIFICATE_POLICIES_EXTENSION_DEFINITION: ExtensionDefinition = +export const CERTIFICATE_POLICIES_EXTENSION_DEFINITION: RegisteredExtensionDefinition = defineExtensionDefinition({ oid: OIDS.certificatePolicies, contexts: ['certificate', 'csr'], defaultCritical: false, decode: (valueDer) => parseCertificatePolicies(valueDer), encode: (value) => encodeCertificatePolicies(value), + assertProfile: (value) => { + encodeCertificatePolicies(value); + }, applyParsed: (accumulator, value) => { accumulator.certificatePolicies = value; }, }); /** Registry entry for Policy Mappings (OID 2.5.29.33). Critical by default. */ -export const POLICY_MAPPINGS_EXTENSION_DEFINITION: ExtensionDefinition = +export const POLICY_MAPPINGS_EXTENSION_DEFINITION: RegisteredExtensionDefinition = defineExtensionDefinition({ oid: OIDS.policyMappings, contexts: ['certificate', 'csr'], defaultCritical: true, decode: (valueDer) => parsePolicyMappings(valueDer), encode: (value) => encodePolicyMappings(value), + assertProfile: (value) => { + encodePolicyMappings(value); + }, applyParsed: (accumulator, value) => { accumulator.policyMappings = value; }, }); /** Registry entry for Policy Constraints (OID 2.5.29.36). Critical by default. */ -export const POLICY_CONSTRAINTS_EXTENSION_DEFINITION: ExtensionDefinition = +export const POLICY_CONSTRAINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinition = defineExtensionDefinition({ oid: OIDS.policyConstraints, contexts: ['certificate', 'csr'], defaultCritical: true, decode: (valueDer) => parsePolicyConstraints(valueDer), encode: (value) => encodePolicyConstraints(value), + assertProfile: (value) => { + encodePolicyConstraints(value); + }, applyParsed: (accumulator, value) => { accumulator.policyConstraints = value; }, }); /** Registry entry for Inhibit anyPolicy (OID 2.5.29.54). Critical by default. */ -export const INHIBIT_ANY_POLICY_EXTENSION_DEFINITION: ExtensionDefinition = +export const INHIBIT_ANY_POLICY_EXTENSION_DEFINITION: RegisteredExtensionDefinition = defineExtensionDefinition({ oid: OIDS.inhibitAnyPolicy, contexts: ['certificate', 'csr'], defaultCritical: true, decode: (valueDer) => parseInhibitAnyPolicy(valueDer), encode: (value) => encodeInhibitAnyPolicy(value), + assertProfile: (value) => { + encodeInhibitAnyPolicy(value); + }, applyParsed: (accumulator, value) => { accumulator.inhibitAnyPolicy = value; }, }); /** Registry entry for Authority Information Access (OID 1.3.6.1.5.5.7.1.1). Non-critical. */ -export const AUTHORITY_INFO_ACCESS_EXTENSION_DEFINITION: ExtensionDefinition< +export const AUTHORITY_INFO_ACCESS_EXTENSION_DEFINITION: RegisteredExtensionDefinition< readonly AuthorityInformationAccess[], readonly AuthorityInformationAccessInput[] > = defineExtensionDefinition< @@ -294,13 +358,16 @@ export const AUTHORITY_INFO_ACCESS_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: false, decode: (valueDer) => parseAuthorityInfoAccess(valueDer), encode: (value) => encodeAuthorityInfoAccess(value), + assertProfile: (value) => { + assertAuthorityInfoAccessProfile(value); + }, applyParsed: (accumulator, value) => { accumulator.authorityInfoAccess = value; }, }); /** Registry entry for CRL Distribution Points (OID 2.5.29.31). Non-critical by default. */ -export const CRL_DISTRIBUTION_POINTS_EXTENSION_DEFINITION: ExtensionDefinition< +export const CRL_DISTRIBUTION_POINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinition< readonly ParsedDistributionPoint[], readonly DistributionPoint[] > = defineExtensionDefinition({ @@ -309,13 +376,16 @@ export const CRL_DISTRIBUTION_POINTS_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: false, decode: (valueDer) => parseCrlDistributionPoints(valueDer), encode: (value) => encodeCrlDistributionPoints(value), + assertProfile: (value) => { + assertCrlDistributionPointsProfile(value); + }, applyParsed: (accumulator, value) => { accumulator.crlDistributionPoints = value; }, }); /** Registry entry for Subject Key Identifier (OID 2.5.29.14). Auto-generated; non-critical. */ -export const SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< +export const SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION: RegisteredExtensionDefinition< string, string | Uint8Array > = defineExtensionDefinition({ @@ -325,13 +395,16 @@ export const SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< autoGenerated: true, decode: (valueDer) => decodeSubjectKeyIdentifier(valueDer), encode: (value) => octetString(normalizeKeyIdentifier(value)), + assertProfile: (value) => { + hexToBytes(value); + }, applyParsed: (accumulator, value) => { accumulator.subjectKeyIdentifier = value; }, }); /** Registry entry for Authority Key Identifier (OID 2.5.29.35). Auto-generated; non-critical. */ -export const AUTHORITY_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< +export const AUTHORITY_KEY_IDENTIFIER_EXTENSION_DEFINITION: RegisteredExtensionDefinition< string | undefined, string | Uint8Array > = defineExtensionDefinition({ @@ -341,6 +414,11 @@ export const AUTHORITY_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< autoGenerated: true, decode: (valueDer) => parseAuthorityKeyIdentifier(valueDer), encode: (value) => sequence([implicitPrimitiveContext(0, normalizeKeyIdentifier(value))]), + assertProfile: (value) => { + if (value !== undefined) { + hexToBytes(value); + } + }, applyParsed: (accumulator, value) => { if (value !== undefined) { accumulator.authorityKeyIdentifier = value; @@ -509,20 +587,26 @@ export function buildSubjectKeyIdentifierFromSubjectPublicKeyInfo( } /** - * Identity helper that narrows the type of an {@linkcode ExtensionDefinition} literal - * and captures a decode-and-apply closure in {@linkcode extensionAppliers}. + * Register an {@linkcode ExtensionDefinition} literal, capturing its + * decode-and-apply and decode-and-validate closures. * - * The closure is built here — where `TParsed` is concrete — so that - * `decodeAndApplyKnownExtension` can call it through the union-typed - * `KnownExtensionDefinition` without hitting TypeScript's correlated-union limitation. + * Both closures are built here, where `TParsed` is concrete, so that + * `decodeAndApplyKnownExtension` and `assertDerProfile` can be called through the + * union-typed `KnownExtensionDefinition` without hitting TypeScript's + * correlated-union limitation. */ function defineExtensionDefinition( definition: ExtensionDefinition, -): ExtensionDefinition { +): RegisteredExtensionDefinition { extensionAppliers.set(definition.oid, (accumulator, valueDer) => { definition.applyParsed(accumulator, definition.decode(valueDer)); }); - return definition; + return { + ...definition, + assertDerProfile: (valueDer) => { + definition.assertProfile(definition.decode(valueDer)); + }, + }; } /** Accept hex string or Uint8Array and return raw bytes. */ diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index 3242e2a..d995732 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -56,6 +56,7 @@ import { SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION, } from '#micro509/internal/x509/extension-registry'; import { GENERAL_NAME_WIRE_TAGS } from '#micro509/internal/x509/general-name-tags'; +import { isResultError } from '#micro509/result/result'; import type { RelativeDistinguishedNameInput } from '#micro509/x509/name'; import { encodeRelativeDistinguishedName } from '#micro509/x509/name'; @@ -867,12 +868,27 @@ function findCustomExtensionValue( return input?.customExtensions?.find((extension) => oidEquals(extension.oid, oid))?.value; } -/** The subset of a distribution point the RFC 5280 §4.2.1.13 cRLIssuer rules read. */ -interface CrlIssuerConstrainedPoint { - readonly distributionPoint?: { readonly relativeName?: unknown }; +/** The subset of a DistributionPointName the RFC 5280 §4.2.1.13 rules read. */ +interface ProfileDistributionPointName { + /** Absolute GeneralName(s) identifying the distribution point. */ + readonly fullName?: readonly GeneralName[]; + /** Name relative to the CRL issuer, in whichever form the caller holds. */ + readonly relativeName?: TRelativeName; +} + +/** The subset of a distribution point the RFC 5280 §4.2.1.13 rules read. */ +interface ProfileDistributionPoint { + /** Where to fetch the CRL. */ + readonly distributionPoint?: ProfileDistributionPointName; + /** Entity that signed the CRL, when different from the certificate issuer. */ readonly crlIssuer?: readonly GeneralName[]; } +/** The DistributionPointName alternative in use, once proven to be exactly one. */ +type DistributionPointNameChoice = + | { readonly kind: 'fullName'; readonly fullName: readonly GeneralName[] } + | { readonly kind: 'relativeName'; readonly relativeName: TRelativeName }; + /** * Rejects a custom extension carrying a known OID whose payload is not the DER * that OID's schema defines, and a known extension offered in the wrong context. @@ -885,8 +901,7 @@ function assertCustomExtensionsValid( context: ExtensionRegistryContext, ): void { for (const extension of input?.customExtensions ?? []) { - validateOid(extension.oid); - const oid = canonicalizeOid(extension.oid); + const oid = validateOid(extension.oid); const definition = getExtensionDefinition(oid); if (definition === undefined) { continue; @@ -897,25 +912,31 @@ function assertCustomExtensionsValid( `Extension ${extension.oid} is not supported in ${context} context`, ); } - const value = new Uint8Array(extension.value); - assertKnownExtensionDecodes(definition, value, extension.oid); - if (oid === OIDS.cRLDistributionPoints) { - for (const point of decodeCrlDistributionPoints(value)) { - assertCrlIssuerDistinguishedNames(point); - } - } + assertKnownExtensionPayload(definition, new Uint8Array(extension.value), extension.oid); } } -/** Reject a custom payload that is not the DER its known OID's schema defines. */ -function assertKnownExtensionDecodes( - definition: { readonly oid: string; decode(valueDer: Uint8Array): unknown }, +/** + * Decode a custom payload once through its known OID's definition and apply that + * extension's profile rules. + * + * A profile violation already carries its own code and propagates unchanged; a + * decode failure means the payload is not the DER the OID's schema defines. + */ +function assertKnownExtensionPayload( + definition: { + readonly oid: string; + assertDerProfile(valueDer: Uint8Array): void; + }, value: Uint8Array, submittedOid: string, ): void { try { - definition.decode(value); - } catch { + definition.assertDerProfile(value); + } catch (error) { + if (isResultError(error)) { + throw error; + } throwExtensionEncoderError( 'malformed_known_extension_value', `Custom extension ${submittedOid} does not decode as ${definition.oid}`, @@ -923,13 +944,6 @@ function assertKnownExtensionDecodes( } } -/** Decode a CRLDistributionPoints payload already known to be well-formed. */ -function decodeCrlDistributionPoints( - value: Uint8Array, -): ReturnType { - return CRL_DISTRIBUTION_POINTS_EXTENSION_DEFINITION.decode(value); -} - /** Effective basicConstraints across the typed field and any custom-known extension. */ function resolveEffectiveBasicConstraints( input: CertificateExtensionsInput | undefined, @@ -1106,6 +1120,7 @@ function pushKnownExtension( * @param critical Whether to mark the extension as critical. Default `false`. */ export function encodeExtension(oid: string, extnValue: Uint8Array, critical = false): Uint8Array { + validateOid(oid); const fields = [objectIdentifier(oid)]; if (critical) { fields.push(bool(true)); @@ -1127,7 +1142,10 @@ export function encodeBasicConstraints(input: BasicConstraints): Uint8Array { } if (input.pathLength !== undefined) { if (!input.ca) { - throw new Error('pathLength requires ca=true'); + throwExtensionEncoderError( + 'path_length_requires_ca', + 'basicConstraints pathLength requires ca = true', + ); } fields.push(integerFromNumber(input.pathLength)); } @@ -1222,27 +1240,42 @@ export function encodeExtendedKeyUsage(usages: readonly ExtendedKeyUsage[]): Uin export function encodeAuthorityInfoAccess( entries: readonly AuthorityInformationAccessInput[], ): Uint8Array { + assertAuthorityInfoAccessProfile(entries); + return sequence( + entries.map((entry) => + sequence([ + objectIdentifier(getAuthorityInfoAccessMethodOid(entry.method)), + encodeSubjectAltName(entry.location), + ]), + ), + ); +} + +/** + * @internal RFC 5280 §4.2.2.1 and RFC 6960 §3.1 rules for Authority Information + * Access: at least one entry, and an id-ad-ocsp location that is a URI. + * + * The method OID is resolved before the URI check so a custom-OID wrapper cannot + * smuggle a non-URI OCSP location past the input union. + */ +export function assertAuthorityInfoAccessProfile( + entries: readonly AuthorityInformationAccess[], +): void { if (entries.length === 0) { throwExtensionEncoderError( 'authority_info_access_empty', 'authorityInfoAccess must not be empty', ); } - return sequence( - entries.map((entry) => { - const methodOid = getAuthorityInfoAccessMethodOid(entry.method); - // RFC 6960 §3.1: the id-ad-ocsp location is a URI. Re-check the resolved - // OID so a custom-OID wrapper cannot smuggle a non-URI OCSP location past - // the input union. - if (methodOid === OIDS.ocspAccessMethod && entry.location.type !== 'uri') { - throwExtensionEncoderError( - 'authority_info_access_ocsp_not_uri', - 'authorityInfoAccess OCSP location must be a URI', - ); - } - return sequence([objectIdentifier(methodOid), encodeSubjectAltName(entry.location)]); - }), - ); + for (const entry of entries) { + const methodOid = getAuthorityInfoAccessMethodOid(entry.method); + if (methodOid === OIDS.ocspAccessMethod && entry.location.type !== 'uri') { + throwExtensionEncoderError( + 'authority_info_access_ocsp_not_uri', + 'authorityInfoAccess OCSP location must be a URI', + ); + } + } } /** @@ -1251,13 +1284,52 @@ export function encodeAuthorityInfoAccess( * @param points Distribution points to encode. */ export function encodeCrlDistributionPoints(points: readonly DistributionPoint[]): Uint8Array { + assertCrlDistributionPointsProfile(points); + return sequence(points.map((point) => sequence(encodeDistributionPoint(point)))); +} + +/** + * @internal RFC 5280 §4.2.1.13 profile rules for a CRLDistributionPoints value, + * stated over the fields a typed {@linkcode DistributionPoint} and a decoded + * `ParsedDistributionPoint` share. + * + * `TRelativeName` differs between the two: builder input carries an unencoded RDN, + * a decoded point carries the parsed one. The rules only read its presence. + */ +export function assertCrlDistributionPointsProfile( + points: readonly ProfileDistributionPoint[], +): void { if (points.length === 0) { throwExtensionEncoderError( 'crl_distribution_points_empty', 'cRLDistributionPoints must not be empty', ); } - return sequence(points.map((point) => sequence(encodeDistributionPoint(point)))); + for (const point of points) { + assertDistributionPointProfile(point); + } +} + +/** RFC 5280 §4.2.1.13 rules for one distribution point. */ +function assertDistributionPointProfile( + point: ProfileDistributionPoint, +): void { + if (point.crlIssuer !== undefined && point.crlIssuer.length === 0) { + throwExtensionEncoderError( + 'distribution_point_crl_issuer_empty', + 'DistributionPoint crlIssuer must not be empty', + ); + } + if (point.distributionPoint === undefined && point.crlIssuer === undefined) { + throwExtensionEncoderError( + 'distribution_point_empty', + 'DistributionPoint must contain distributionPoint or crlIssuer', + ); + } + assertCrlIssuerDistinguishedNames(point); + if (point.distributionPoint !== undefined) { + resolveDistributionPointNameChoice(point.distributionPoint); + } } /** @@ -1266,6 +1338,7 @@ export function encodeCrlDistributionPoints(points: readonly DistributionPoint[] * @param constraints Permitted and/or excluded subtrees. */ export function encodeNameConstraints(constraints: NameConstraints): Uint8Array { + assertNameConstraintsProfile(constraints); const parts: Uint8Array[] = []; if (constraints.permittedSubtrees !== undefined && constraints.permittedSubtrees.length > 0) { parts.push( @@ -1283,13 +1356,25 @@ export function encodeNameConstraints(constraints: NameConstraints): Uint8Array ), ); } - if (parts.length === 0) { + return sequence(parts); +} + +/** + * @internal RFC 5280 §4.2.1.10: a nameConstraints extension states at least one + * of permittedSubtrees and excludedSubtrees, and neither may be an empty set. + */ +export function assertNameConstraintsProfile(constraints: { + readonly permittedSubtrees?: readonly unknown[]; + readonly excludedSubtrees?: readonly unknown[]; +}): void { + const permitted = constraints.permittedSubtrees?.length ?? 0; + const excluded = constraints.excludedSubtrees?.length ?? 0; + if (permitted === 0 && excluded === 0) { throwExtensionEncoderError( 'name_constraints_empty', 'nameConstraints must set permittedSubtrees or excludedSubtrees', ); } - return sequence(parts); } /** @@ -1306,7 +1391,7 @@ export function encodeCertificatePolicies(policies: CertificatePolicies): Uint8A } const seen = new Set(); for (const policy of policies) { - const key = toHex(objectIdentifier(policy.policyIdentifier)); + const key = validatePolicyOid(policy.policyIdentifier); if (seen.has(key)) { throwExtensionEncoderError( 'duplicate_policy_oid', @@ -1475,7 +1560,9 @@ function encodeGeneralSubtree(subtree: GeneralSubtree): Uint8Array { * {@linkcode ParsedDistributionPoint} alike, so the rule holds whether the value * arrives through `crlDistributionPoints` or through `customExtensions`. */ -function assertCrlIssuerDistinguishedNames(point: CrlIssuerConstrainedPoint): void { +function assertCrlIssuerDistinguishedNames( + point: ProfileDistributionPoint, +): void { if (point.crlIssuer === undefined) { return; } @@ -1493,21 +1580,8 @@ function assertCrlIssuerDistinguishedNames(point: CrlIssuerConstrainedPoint): vo } } -/** DER-encode the fields of a single DistributionPoint. */ +/** DER-encode the fields of a single DistributionPoint, already profile-checked. */ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { - if (point.crlIssuer !== undefined && point.crlIssuer.length === 0) { - throwExtensionEncoderError( - 'distribution_point_crl_issuer_empty', - 'DistributionPoint crlIssuer must not be empty', - ); - } - if (point.distributionPoint === undefined && point.crlIssuer === undefined) { - throwExtensionEncoderError( - 'distribution_point_empty', - 'DistributionPoint must contain distributionPoint or crlIssuer', - ); - } - assertCrlIssuerDistinguishedNames(point); const fields: Uint8Array[] = []; if (point.distributionPoint !== undefined) { fields.push( @@ -1519,7 +1593,7 @@ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { implicitPrimitiveContext(1, encodeDistributionPointReasonFlagsContent(point.reasons)), ); } - if (point.crlIssuer !== undefined && point.crlIssuer.length > 0) { + if (point.crlIssuer !== undefined) { fields.push( implicitConstructedContext(2, concatBytes(point.crlIssuer.map(encodeSubjectAltName))), ); @@ -1527,8 +1601,13 @@ function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { return fields; } -/** DER-encode a DistributionPointName (fullName or relativeName). */ -function encodeDistributionPointName(name: DistributionPointName): Uint8Array { +/** + * RFC 5280 §4.2.1.13: a DistributionPointName holds exactly one of fullName and + * relativeName, and a fullName holds at least one GeneralName. + */ +function resolveDistributionPointNameChoice( + name: ProfileDistributionPointName, +): DistributionPointNameChoice { if (name.fullName !== undefined && name.relativeName !== undefined) { throwExtensionEncoderError( 'distribution_point_name_conflict', @@ -1542,15 +1621,10 @@ function encodeDistributionPointName(name: DistributionPointName): Uint8Array { 'DistributionPointName fullName must not be empty', ); } - return implicitConstructedContext(0, concatBytes(name.fullName.map(encodeSubjectAltName))); + return { kind: 'fullName', fullName: name.fullName }; } if (name.relativeName !== undefined) { - const relativeName = encodeRelativeDistinguishedName(name.relativeName); - const relativeNameElement = readElement(relativeName); - return implicitConstructedContext( - 1, - relativeName.slice(relativeNameElement.start, relativeNameElement.end), - ); + return { kind: 'relativeName', relativeName: name.relativeName }; } throwExtensionEncoderError( 'distribution_point_name_empty', @@ -1558,6 +1632,20 @@ function encodeDistributionPointName(name: DistributionPointName): Uint8Array { ); } +/** DER-encode a DistributionPointName (fullName or relativeName). */ +function encodeDistributionPointName(name: DistributionPointName): Uint8Array { + const choice = resolveDistributionPointNameChoice(name); + if (choice.kind === 'fullName') { + return implicitConstructedContext(0, concatBytes(choice.fullName.map(encodeSubjectAltName))); + } + const relativeName = encodeRelativeDistinguishedName(choice.relativeName); + const relativeNameElement = readElement(relativeName); + return implicitConstructedContext( + 1, + relativeName.slice(relativeNameElement.start, relativeNameElement.end), + ); +} + /** DER-encode a NameConstraintForm as an implicit-tagged {@linkcode GeneralName}. */ function encodeNameConstraintForm(form: NameConstraintForm): Uint8Array { switch (form.type) { @@ -1680,26 +1768,27 @@ export function buildSubjectKeyIdentifier(subjectPublicKeyInfo: Uint8Array): Uin } /** - * Throw if the string is not an encodable dotted-decimal OID. + * Throw if the string is not an encodable dotted-decimal OID, and return its + * canonical spelling. * - * Syntax alone is not enough: X.660 bounds the first arc to 0, 1, or 2 and the + * Syntax alone is not enough. X.660 bounds the first arc to 0, 1, or 2 and the * second to under 40 beneath arcs 0 and 1, so `3.1` and `1.40` parse as decimals * yet cannot be encoded. */ -function validateOid(oid: string): void { +function validateOid(oid: string): string { if (!/^\d+(?:\.\d+)+$/.test(oid)) { throwExtensionEncoderError('invalid_oid', `Invalid OID: ${oid}`); } try { - canonicalizeOid(oid); + return canonicalizeOid(oid); } catch { throwExtensionEncoderError('invalid_oid', `Invalid OID: ${oid}`); } } -/** Validate that a policy OID is syntactically valid. */ -function validatePolicyOid(oid: string): void { - validateOid(oid); +/** Validate a policy OID and return its canonical spelling. */ +function validatePolicyOid(oid: string): string { + return validateOid(oid); } /** @@ -1716,8 +1805,7 @@ function pushExtension( value: Uint8Array, critical = false, ): void { - validateOid(oid); - const identity = canonicalizeOid(oid); + const identity = validateOid(oid); if (seen.has(identity)) { throwExtensionEncoderError('duplicate_extension_oid', `Duplicate extension OID: ${oid}`); } diff --git a/test/helpers.ts b/test/helpers.ts index 503171d..342280a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -50,6 +50,8 @@ import { probeOpenSsl } from '#test/oracles/openssl'; export function encodeUncheckedCrlDistributionPoints( points: readonly { readonly fullNameUri?: string; + readonly relativeNameSetDer?: Uint8Array; + readonly crlIssuerDer?: Uint8Array; readonly crlIssuer?: readonly GeneralName[]; }[], ): Uint8Array { @@ -67,11 +69,23 @@ export function encodeUncheckedCrlDistributionPoints( ), ); } - if (point.crlIssuer !== undefined) { + if (point.relativeNameSetDer !== undefined) { + const set = readElement(point.relativeNameSetDer); fields.push( - implicitConstructedContext(2, concatBytes(point.crlIssuer.map(encodeSubjectAltName))), + implicitConstructedContext( + 0, + implicitConstructedContext(1, point.relativeNameSetDer.slice(set.start, set.end)), + ), ); } + const crlIssuerDer = + point.crlIssuerDer ?? + (point.crlIssuer === undefined + ? undefined + : concatBytes(point.crlIssuer.map(encodeSubjectAltName))); + if (crlIssuerDer !== undefined) { + fields.push(implicitConstructedContext(2, crlIssuerDer)); + } return sequence(fields); }), ); diff --git a/test/internals.test.ts b/test/internals.test.ts index 621edb5..a49c1a1 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -23,6 +23,7 @@ import { import { assertDerMaxDepth, bitString, + concatBytes, DEFAULT_MAX_DER_DEPTH, encodeLength, explicitContext, @@ -43,6 +44,7 @@ import { time, tlv, utcTime, + utf8String, } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; import { @@ -97,13 +99,17 @@ import { encodeCertificatePolicies, encodeCrlDistributionPoints, encodeExtendedKeyUsage, + encodeExtension, + encodeInhibitAnyPolicy, encodeKeyUsage, encodeName, encodeNameConstraints, + encodePolicyConstraints, encodePolicyMappings, encodeRelativeDistinguishedName, encodeSubjectAltName, } from '#micro509/x509'; +import { parseCrlDistributionPoints } from '#micro509/x509/parse'; import { childrenOf, encodeUncheckedCrlDistributionPoints } from '#test/helpers'; function expectEncoderErrorCode(fn: () => unknown, code: string): void { @@ -1026,49 +1032,333 @@ describe('extensions encoding', () => { }, ); - it('applies cRLIssuer constraints to a custom CRLDistributionPoints payload', () => { - const nonDirectoryName = encodeUncheckedCrlDistributionPoints([ - { - fullNameUri: 'http://crl.example/a.crl', - crlIssuer: [{ type: 'uri', value: 'http://crl.example/issuer' }], - }, - ]); - for (const build of [ + /** The same custom extension offered through both builder entry points. */ + function buildCustomBothWays(oid: string, value: Uint8Array): readonly (() => unknown)[] { + return [ () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, { - customExtensions: [{ oid: OIDS.cRLDistributionPoints, value: nonDirectoryName }], - }), - () => - buildRequestedExtensions({ - customExtensions: [{ oid: OIDS.cRLDistributionPoints, value: nonDirectoryName }], - }), - () => - buildRequestedExtensions({ - customExtensions: [{ oid: '2.5.029.31', value: nonDirectoryName }], + customExtensions: [{ oid, value }], }), - ]) { - expectEncoderErrorCode(build, 'distribution_point_crl_issuer_not_directory_name'); + () => buildRequestedExtensions({ customExtensions: [{ oid, value }] }), + ]; + } + + const CRL_DISTRIBUTION_POINTS_OIDS = [OIDS.cRLDistributionPoints, '2.5.029.31'] as const; + const crlIssuerDnA = encodeName({ commonName: 'CRL Issuer A' }); + const crlIssuerDnB = encodeName({ commonName: 'CRL Issuer B' }); + const crlRelativeName = encodeRelativeDistinguishedName([{ type: 'commonName', value: 'CRL42' }]); + + // Payloads that decode cleanly and then break RFC 5280 §4.2.1.13. + const PROFILE_INVALID_CRL_DISTRIBUTION_POINTS = [ + [ + 'a URI cRLIssuer', + encodeUncheckedCrlDistributionPoints([ + { + fullNameUri: 'http://crl.example/a.crl', + crlIssuer: [{ type: 'uri', value: 'http://crl.example/issuer' }], + }, + ]), + 'distribution_point_crl_issuer_not_directory_name', + ], + [ + 'a relativeName beside two wire-tagged cRLIssuer DNs', + encodeUncheckedCrlDistributionPoints([ + { + relativeNameSetDer: crlRelativeName, + crlIssuerDer: concatBytes([tlv(0xa4, crlIssuerDnA), tlv(0xa4, crlIssuerDnB)]), + }, + ]), + 'distribution_point_relative_name_multiple_crl_issuers', + ], + ] as const; + + // Payloads that are not CRLDistributionPoints DER at all. + const MALFORMED_CRL_DISTRIBUTION_POINTS = [ + ['a NULL where the SEQUENCE belongs', Uint8Array.of(0x05, 0x00)], + [ + 'an empty cRLIssuer', + encodeUncheckedCrlDistributionPoints([ + { fullNameUri: 'http://crl.example/a.crl', crlIssuerDer: new Uint8Array() }, + ]), + ], + ['an empty points SEQUENCE', sequence([])], + ] as const; + + // Payloads that satisfy the profile through the custom route. + const CONFORMANT_CRL_DISTRIBUTION_POINTS = [ + [ + 'a fullName URI', + encodeCrlDistributionPoints([ + { distributionPoint: { fullName: [{ type: 'uri', value: 'http://crl.example/a.crl' }] } }, + ]), + ], + [ + 'a relativeName beside one wire-tagged cRLIssuer DN', + encodeUncheckedCrlDistributionPoints([ + { relativeNameSetDer: crlRelativeName, crlIssuerDer: tlv(0xa4, crlIssuerDnA) }, + ]), + ], + [ + 'a relativeName with no cRLIssuer', + encodeUncheckedCrlDistributionPoints([{ relativeNameSetDer: crlRelativeName }]), + ], + [ + 'a fullName beside a wire-tagged cRLIssuer DN', + encodeUncheckedCrlDistributionPoints([ + { fullNameUri: 'http://crl.example/a.crl', crlIssuerDer: tlv(0xa4, crlIssuerDnA) }, + ]), + ], + ] as const; + + it.each(PROFILE_INVALID_CRL_DISTRIBUTION_POINTS)( + 'rejects a custom cRLDistributionPoints payload carrying %s', + (_label, value, code) => { + for (const oid of CRL_DISTRIBUTION_POINTS_OIDS) { + for (const build of buildCustomBothWays(oid, value)) { + expectEncoderErrorCode(build, code); + } + } + }, + ); + + it.each(MALFORMED_CRL_DISTRIBUTION_POINTS)( + 'rejects a custom cRLDistributionPoints payload carrying %s', + (_label, value) => { + for (const oid of CRL_DISTRIBUTION_POINTS_OIDS) { + for (const build of buildCustomBothWays(oid, value)) { + expectEncoderErrorCode(build, 'malformed_known_extension_value'); + } + } + }, + ); + + it.each(CONFORMANT_CRL_DISTRIBUTION_POINTS)( + 'accepts a custom cRLDistributionPoints payload carrying %s', + (_label, value) => { + for (const oid of CRL_DISTRIBUTION_POINTS_OIDS) { + for (const build of buildCustomBothWays(oid, value)) { + expect(build()).toBeInstanceOf(Array); + } + } + }, + ); + + it.each(PROFILE_INVALID_CRL_DISTRIBUTION_POINTS)( + 'still parses a cRLDistributionPoints value carrying %s', + (_label, value) => { + expect(parseCrlDistributionPoints(value).length).toBe(1); + }, + ); + + // Rules the encoder enforces that the tolerant parser does not, reachable only + // by handing the builder a raw payload under a known OID. + const KNOWN_EXTENSION_PROFILE_VIOLATIONS = [ + [ + 'certificatePolicies repeating a policy OID', + OIDS.certificatePolicies, + sequence([sequence([objectIdentifier('1.2.3.4')]), sequence([objectIdentifier('1.2.3.4')])]), + 'duplicate_policy_oid', + ], + [ + 'certificatePolicies with an explicitText over 200 characters', + OIDS.certificatePolicies, + sequence([ + sequence([ + objectIdentifier('1.2.3.4'), + sequence([ + sequence([ + objectIdentifier(OIDS.userNoticePolicyQualifier), + sequence([utf8String('a'.repeat(201))]), + ]), + ]), + ]), + ]), + 'display_text_out_of_range', + ], + [ + 'authorityInfoAccess with a dNSName OCSP location', + OIDS.authorityInfoAccess, + sequence([ + sequence([ + objectIdentifier(OIDS.ocspAccessMethod), + tlv(0x82, new TextEncoder().encode('ocsp.example.test')), + ]), + ]), + 'authority_info_access_ocsp_not_uri', + ], + [ + 'nameConstraints with neither subtree', + OIDS.nameConstraints, + sequence([]), + 'name_constraints_empty', + ], + ['keyUsage with no bit set', OIDS.keyUsage, bitString(new Uint8Array(), 0), 'key_usage_empty'], + ] as const; + + it.each(KNOWN_EXTENSION_PROFILE_VIOLATIONS)( + 'rejects a custom %s payload', + (_label, oid, value, code) => { + for (const build of buildCustomBothWays(oid, value)) { + expectEncoderErrorCode(build, code); + } + }, + ); + + // Rules where the decoder is already as strict as the encoder, so a custom + // payload breaking them never reaches the profile hook. + const DECODER_REJECTED_KNOWN_PAYLOADS = [ + [ + 'subjectAltName using a tag outside the GeneralName CHOICE', + OIDS.subjectAltName, + sequence([tlv(0x89, new TextEncoder().encode('x'))]), + ], + ['extendedKeyUsage with no purpose', OIDS.extendedKeyUsage, sequence([])], + [ + 'policyMappings naming anyPolicy', + OIDS.policyMappings, + sequence([sequence([objectIdentifier(OIDS.anyPolicy), objectIdentifier('1.2.3.4')])]), + ], + [ + 'basicConstraints with a pathLength but no cA bit', + OIDS.basicConstraints, + sequence([integerFromNumber(0)]), + ], + ['policyConstraints with neither field', OIDS.policyConstraints, sequence([])], + ] as const; + + it.each(DECODER_REJECTED_KNOWN_PAYLOADS)('rejects a custom %s payload', (_label, oid, value) => { + for (const build of buildCustomBothWays(oid, value)) { + expectEncoderErrorCode(build, 'malformed_known_extension_value'); } + }); - // A conformant custom payload still builds. + // Every known extension valid in both contexts, offered as a conformant custom + // payload, so each profile hook is exercised on its accepting path too. + const CONFORMANT_KNOWN_PAYLOADS = [ + ['keyUsage', OIDS.keyUsage, encodeKeyUsage(['digitalSignature'])], + ['extendedKeyUsage', OIDS.extendedKeyUsage, encodeExtendedKeyUsage(['serverAuth'])], + [ + 'subjectAltName', + OIDS.subjectAltName, + sequence([encodeSubjectAltName({ type: 'dns', value: 'san.example' })]), + ], + [ + 'nameConstraints', + OIDS.nameConstraints, + encodeNameConstraints({ permittedSubtrees: [{ base: { type: 'dns', value: 'example' } }] }), + ], + [ + 'certificatePolicies', + OIDS.certificatePolicies, + encodeCertificatePolicies([{ policyIdentifier: '1.2.3.4' }]), + ], + [ + 'policyMappings', + OIDS.policyMappings, + encodePolicyMappings([{ issuerDomainPolicy: '1.2.3.4', subjectDomainPolicy: '1.2.3.5' }]), + ], + [ + 'policyConstraints', + OIDS.policyConstraints, + encodePolicyConstraints({ requireExplicitPolicy: 0 }), + ], + ['inhibitAnyPolicy', OIDS.inhibitAnyPolicy, encodeInhibitAnyPolicy({ skipCerts: 0 })], + [ + 'authorityInfoAccess', + OIDS.authorityInfoAccess, + encodeAuthorityInfoAccess([ + { method: 'ocsp', location: { type: 'uri', value: 'http://ocsp.example.test' } }, + ]), + ], + ] as const; + + it.each(CONFORMANT_KNOWN_PAYLOADS)( + 'accepts a conformant custom %s payload', + (_label, oid, value) => { + for (const build of buildCustomBothWays(oid, value)) { + expect(build()).toBeInstanceOf(Array); + } + }, + ); + + it('accepts a conformant custom basicConstraints payload on the CSR path', () => { + // buildCertificateExtensions always emits basicConstraints, so a custom one + // can only reach the profile hook through a CSR. expect( buildRequestedExtensions({ customExtensions: [ - { - oid: OIDS.cRLDistributionPoints, - value: encodeCrlDistributionPoints([ - { - distributionPoint: { - fullName: [{ type: 'uri', value: 'http://crl.example/a.crl' }], - }, - }, - ]), - }, + { oid: OIDS.basicConstraints, value: encodeBasicConstraints({ ca: false }) }, ], }), ).toBeInstanceOf(Array); }); + it.each([ + ['subjectKeyIdentifier', OIDS.subjectKeyIdentifier, octetString(Uint8Array.of(1, 2, 3))], + [ + 'authorityKeyIdentifier', + OIDS.authorityKeyIdentifier, + sequence([implicitPrimitiveContext(0, Uint8Array.of(1, 2, 3))]), + ], + ])('validates a custom %s payload before rejecting it as a duplicate', (_label, oid, value) => { + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, subjectPublicKeyInfo, { + customExtensions: [{ oid, value }], + }), + 'duplicate_extension_oid', + ); + }); + + it('rejects a custom subjectKeyIdentifier that is not an OCTET STRING', () => { + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, subjectPublicKeyInfo, { + customExtensions: [{ oid: OIDS.subjectKeyIdentifier, value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + }); + + it('accepts a conformant custom issuerAltName only on the certificate path', () => { + const value = sequence([encodeSubjectAltName({ type: 'dns', value: 'ian.example' })]); + expect( + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + customExtensions: [{ oid: OIDS.issuerAltName, value }], + }), + ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => buildRequestedExtensions({ customExtensions: [{ oid: OIDS.issuerAltName, value }] }), + 'extension_not_supported_in_context', + ); + }); + + it.each(['3.1', '1.40', '2', '1.2.', 'not.an.oid'])( + 'encodeExtension rejects the unencodable OID %p with a coded error', + (oid) => { + expectEncoderErrorCode(() => encodeExtension(oid, Uint8Array.of(0x05, 0x00)), 'invalid_oid'); + }, + ); + + it.each(['3.1', '1.40'])( + 'encodeCertificatePolicies rejects the unencodable policy OID %p with a coded error', + (oid) => { + expectEncoderErrorCode( + () => encodeCertificatePolicies([{ policyIdentifier: oid }]), + 'invalid_oid', + ); + }, + ); + + it('rejects a basicConstraints pathLength without the cA bit (RFC 5280 §4.2.1.9)', () => { + // The BasicConstraints union already excludes this pairing, so reach the + // encoder guard the way an untyped caller would. + expectEncoderErrorCode( + () => Reflect.apply(encodeBasicConstraints, undefined, [{ ca: false, pathLength: 0 }]), + 'path_length_requires_ca', + ); + }); + it.each(['3.1', '1.40'])('rejects OID %s, which violates the X.660 arc bounds', (oid) => { expectEncoderErrorCode( () => diff --git a/test/revocation.test.ts b/test/revocation.test.ts index 2991c1e..df0c367 100644 --- a/test/revocation.test.ts +++ b/test/revocation.test.ts @@ -11,7 +11,12 @@ import { resolveOcspResponderCandidates, unwrap, } from '#micro509'; -import { addRevokedEntryCertificateIssuers, hexToBytes, issueChain } from '#test/helpers'; +import { + addRevokedEntryCertificateIssuers, + createCertificateWithRawExtensions, + hexToBytes, + issueChain, +} from '#test/helpers'; describe('revocation boundary', () => { it('returns unknown when no revocation evidence is provided', async () => { @@ -759,9 +764,9 @@ describe('revocation boundary', () => { extensions: { basicConstraints: { ca: true }, keyUsage: ['keyCertSign'] }, }); const leafKeys = await generateKeyPair(); - // An OCSP dNSName location is non-conformant (RFC 6960 §3.1); the typed - // builder rejects it, so encode the AIA as raw DER to exercise the discovery - // filter against a parsed certificate. + // An OCSP dNSName location is non-conformant (RFC 6960 §3.1). The builder + // rejects it through both the typed field and customExtensions, so splice the + // AIA into the signed certificate to exercise the discovery filter. const aiaDer = sequence([ sequence([ objectIdentifier(OIDS.ocspAccessMethod), @@ -772,7 +777,7 @@ describe('revocation boundary', () => { tlv(0x86, new TextEncoder().encode('http://ocsp.example.test')), ]), ]); - const leaf = await createCertificate({ + const leaf = await createCertificateWithRawExtensions({ issuer: { commonName: 'Raw AIA CA' }, subject: { commonName: 'raw-aia.example' }, publicKey: leafKeys.publicKey, From a749ca7d45704288cc2e8b8626327a05bd8e8035 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 01:28:24 +0200 Subject: [PATCH 09/14] fix(test): preserve explicit AGENT setting --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index db546a8..4e143cf 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "smoke:deno": "deno run --allow-read scripts/smoke.mjs", "smoke:node": "node scripts/smoke.mjs", "smoke:workerd": "node scripts/smoke-workerd.mjs", - "test": "BIN=\"${BIN:-bun}\"; AGENT=1 $BIN test --concurrent", + "test": "BIN=\"${BIN:-bun}\"; AGENT=\"${AGENT:-1}\" $BIN test --concurrent", "test:35433": "PR=\"35433\"; (command -v \"bun-${PR}\" || bunx bun-pr \"${PR}\") && BIN=\"bun-${PR}\" bun run test", "test:coverage": "run -q test --coverage", "test:differential": "BIN=\"${BIN:-bun}\"; $BIN test test/differential.test.ts test/differential-fuzz.test.ts", From a5070a8cfe14650eccf9fa7343da304e588fa08f Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 01:37:50 +0200 Subject: [PATCH 10/14] fix(x509): compare OIDs canonically and enforce criticality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getAuthorityInfoAccessMethodOid` and `getExtendedKeyUsageOid` returned the submitted OID, and `encodePolicyMappings` compared its input by string, so an alias that encodes to a forbidden OID dodged the rule. `1.3.6.1.5.5.7.048.1` accepted a dNSName OCSP location and `2.5.29.032.0` passed the anyPolicy check. All three now resolve through `validateOid`, which returns the canonical spelling. `assertProfile` had only the decoded payload, so a `customExtensions` entry could carry a criticality RFC 5280 forbids. It now also receives the flag, and nameConstraints (§4.2.1.10), policyConstraints (§4.2.1.11), and inhibitAnyPolicy (§4.2.1.14) require critical, while authorityKeyIdentifier (§4.2.1.1), subjectKeyIdentifier (§4.2.1.2), and authorityInfoAccess (§4.2.2.1) require non-critical. §4.2.1.9 conditions basicConstraints criticality on the certificate being a CA whose key signs certificates, which no single extension carries, so that check sits beside `assertPathLengthKeyUsage` where the effective keyUsage is already resolved. The name-constraints tolerance fixture emitted a non-critical extension through the builder and moves to the raw-extension helper. --- src/internal/AGENTS.md | 15 +- src/internal/x509/extension-errors.ts | 2 + src/internal/x509/extension-registry.ts | 27 +-- src/x509/extensions.ts | 78 +++++++-- test/internals.test.ts | 210 +++++++++++++++++++----- test/verify.test.ts | 5 +- 6 files changed, 267 insertions(+), 70 deletions(-) diff --git a/src/internal/AGENTS.md b/src/internal/AGENTS.md index b01437e..580b3b2 100644 --- a/src/internal/AGENTS.md +++ b/src/internal/AGENTS.md @@ -43,10 +43,17 @@ internal/ - New certificate extensions get an `ExtensionDefinition` in `x509/extension-registry.ts` (decode/encode/assertProfile/applyParsed + accumulator field), not ad-hoc decoding at call sites. -- `assertProfile` is required, and delegates to the encoder that owns the rule. - It runs only in builders, over a decoded `customExtensions` payload carrying a - known OID, so a raw value meets the same bar as the typed input. Parsing stays - tolerant and never calls it. +- `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`. diff --git a/src/internal/x509/extension-errors.ts b/src/internal/x509/extension-errors.ts index e038134..45bba57 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -31,6 +31,8 @@ export type ExtensionEncoderErrorCode = | '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' diff --git a/src/internal/x509/extension-registry.ts b/src/internal/x509/extension-registry.ts index 8363179..06cdd54 100644 --- a/src/internal/x509/extension-registry.ts +++ b/src/internal/x509/extension-registry.ts @@ -36,6 +36,7 @@ import type { import { assertAuthorityInfoAccessProfile, assertCrlDistributionPointsProfile, + assertExtensionCriticality, assertNameConstraintsProfile, buildSubjectKeyIdentifier, encodeAuthorityInfoAccess, @@ -145,7 +146,7 @@ export interface ExtensionDefinition { * * Parsing never calls this. */ - assertProfile(value: TParsed): void; + assertProfile(value: TParsed, critical: boolean): void; /** Store a decoded value into the parse-time accumulator. */ applyParsed(accumulator: MutableKnownParsedExtensionAccumulator, value: TParsed): void; } @@ -159,7 +160,7 @@ export type RegisteredExtensionDefinition = Extension TInput > & { /** Decode a DER extnValue and apply this extension's profile rules to it. */ - assertDerProfile(valueDer: Uint8Array): void; + assertDerProfile(valueDer: Uint8Array, critical: boolean): void; }; /** @@ -273,8 +274,9 @@ export const NAME_CONSTRAINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinitio defaultCritical: true, decode: (valueDer) => parseNameConstraints(valueDer), encode: (value) => encodeNameConstraints(value), - assertProfile: (value) => { + assertProfile: (value, critical) => { assertNameConstraintsProfile(value); + assertExtensionCriticality('nameConstraints', true, critical); }, applyParsed: (accumulator, value) => { accumulator.nameConstraints = value; @@ -321,8 +323,9 @@ export const POLICY_CONSTRAINTS_EXTENSION_DEFINITION: RegisteredExtensionDefinit defaultCritical: true, decode: (valueDer) => parsePolicyConstraints(valueDer), encode: (value) => encodePolicyConstraints(value), - assertProfile: (value) => { + assertProfile: (value, critical) => { encodePolicyConstraints(value); + assertExtensionCriticality('policyConstraints', true, critical); }, applyParsed: (accumulator, value) => { accumulator.policyConstraints = value; @@ -337,8 +340,9 @@ export const INHIBIT_ANY_POLICY_EXTENSION_DEFINITION: RegisteredExtensionDefinit defaultCritical: true, decode: (valueDer) => parseInhibitAnyPolicy(valueDer), encode: (value) => encodeInhibitAnyPolicy(value), - assertProfile: (value) => { + assertProfile: (value, critical) => { encodeInhibitAnyPolicy(value); + assertExtensionCriticality('inhibitAnyPolicy', true, critical); }, applyParsed: (accumulator, value) => { accumulator.inhibitAnyPolicy = value; @@ -358,8 +362,9 @@ export const AUTHORITY_INFO_ACCESS_EXTENSION_DEFINITION: RegisteredExtensionDefi defaultCritical: false, decode: (valueDer) => parseAuthorityInfoAccess(valueDer), encode: (value) => encodeAuthorityInfoAccess(value), - assertProfile: (value) => { + assertProfile: (value, critical) => { assertAuthorityInfoAccessProfile(value); + assertExtensionCriticality('authorityInfoAccess', false, critical); }, applyParsed: (accumulator, value) => { accumulator.authorityInfoAccess = value; @@ -395,8 +400,9 @@ export const SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION: RegisteredExtensionDef autoGenerated: true, decode: (valueDer) => decodeSubjectKeyIdentifier(valueDer), encode: (value) => octetString(normalizeKeyIdentifier(value)), - assertProfile: (value) => { + assertProfile: (value, critical) => { hexToBytes(value); + assertExtensionCriticality('subjectKeyIdentifier', false, critical); }, applyParsed: (accumulator, value) => { accumulator.subjectKeyIdentifier = value; @@ -414,10 +420,11 @@ export const AUTHORITY_KEY_IDENTIFIER_EXTENSION_DEFINITION: RegisteredExtensionD autoGenerated: true, decode: (valueDer) => parseAuthorityKeyIdentifier(valueDer), encode: (value) => sequence([implicitPrimitiveContext(0, normalizeKeyIdentifier(value))]), - assertProfile: (value) => { + assertProfile: (value, critical) => { if (value !== undefined) { hexToBytes(value); } + assertExtensionCriticality('authorityKeyIdentifier', false, critical); }, applyParsed: (accumulator, value) => { if (value !== undefined) { @@ -603,8 +610,8 @@ function defineExtensionDefinition( }); return { ...definition, - assertDerProfile: (valueDer) => { - definition.assertProfile(definition.decode(valueDer)); + assertDerProfile: (valueDer, critical) => { + definition.assertProfile(definition.decode(valueDer), critical); }, }; } diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index d995732..c0a285f 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -825,6 +825,30 @@ function assertPathLengthKeyUsage(input: CertificateExtensionsInput | undefined) } } +/** + * RFC 5280 §4.2.1.9: basicConstraints is critical in a CA certificate whose key + * validates signatures on certificates. Both conjuncts live in other extensions, + * so the rule sits here rather than in the basicConstraints profile hook. + * + * Only a custom extension can carry the wrong criticality; the typed field is + * always emitted with the registry default. + */ +function assertCaBasicConstraintsCritical(input: CertificateExtensionsInput | undefined): void { + const custom = input?.customExtensions?.find((extension) => + oidEquals(extension.oid, OIDS.basicConstraints), + ); + if (custom === undefined || custom.critical === true) { + return; + } + const basicConstraints = BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode( + new Uint8Array(custom.value), + ); + if (!basicConstraints.ca || resolveEffectiveKeyUsage(input)?.includes('keyCertSign') !== true) { + return; + } + assertExtensionCriticality('basicConstraints', true, false); +} + /** * RFC 5280 §4.2.1.6: an empty subject DN requires a subjectAltName extension * present, marked critical, and carrying at least one non-empty GeneralName. @@ -912,13 +936,18 @@ function assertCustomExtensionsValid( `Extension ${extension.oid} is not supported in ${context} context`, ); } - assertKnownExtensionPayload(definition, new Uint8Array(extension.value), extension.oid); + assertKnownExtensionPayload( + definition, + new Uint8Array(extension.value), + extension.critical ?? false, + extension.oid, + ); } } /** * Decode a custom payload once through its known OID's definition and apply that - * extension's profile rules. + * extension's profile rules to the decoded value and its criticality. * * A profile violation already carries its own code and propagates unchanged; a * decode failure means the payload is not the DER the OID's schema defines. @@ -926,13 +955,14 @@ function assertCustomExtensionsValid( function assertKnownExtensionPayload( definition: { readonly oid: string; - assertDerProfile(valueDer: Uint8Array): void; + assertDerProfile(valueDer: Uint8Array, critical: boolean): void; }, value: Uint8Array, + critical: boolean, submittedOid: string, ): void { try { - definition.assertDerProfile(value); + definition.assertDerProfile(value, critical); } catch (error) { if (isResultError(error)) { throw error; @@ -983,6 +1013,7 @@ function appendConstraintExtensions( includeBasicConstraints: boolean, ): void { assertPathLengthKeyUsage(input); + assertCaBasicConstraintsCritical(input); if (includeBasicConstraints && input.basicConstraints !== undefined) { pushKnownExtension( encoded, @@ -1359,6 +1390,30 @@ export function encodeNameConstraints(constraints: NameConstraints): Uint8Array return sequence(parts); } +/** + * @internal RFC 5280 fixes the criticality of several extensions: nameConstraints + * (§4.2.1.10), policyConstraints (§4.2.1.11), and inhibitAnyPolicy (§4.2.1.14) are + * critical; authorityKeyIdentifier (§4.2.1.1), subjectKeyIdentifier (§4.2.1.2), + * and authorityInfoAccess (§4.2.2.1) are not. + * + * @param label Extension name quoted in the diagnostic. + * @param required The criticality RFC 5280 mandates. + * @param critical The criticality the caller asked for. + */ +export function assertExtensionCriticality( + label: string, + required: boolean, + critical: boolean, +): void { + if (critical === required) { + return; + } + throwExtensionEncoderError( + required ? 'extension_must_be_critical' : 'extension_must_be_non_critical', + `The ${label} extension must be marked ${required ? 'critical' : 'non-critical'}`, + ); +} + /** * @internal RFC 5280 §4.2.1.10: a nameConstraints extension states at least one * of permittedSubtrees and excludedSubtrees, and neither may be an empty set. @@ -1414,12 +1469,9 @@ export function encodePolicyMappings(mappings: PolicyMappings): Uint8Array { } return sequence( mappings.map((mapping) => { - validatePolicyOid(mapping.issuerDomainPolicy); - validatePolicyOid(mapping.subjectDomainPolicy); - if ( - mapping.issuerDomainPolicy === OIDS.anyPolicy || - mapping.subjectDomainPolicy === OIDS.anyPolicy - ) { + const issuerDomainPolicy = validatePolicyOid(mapping.issuerDomainPolicy); + const subjectDomainPolicy = validatePolicyOid(mapping.subjectDomainPolicy); + if (issuerDomainPolicy === OIDS.anyPolicy || subjectDomainPolicy === OIDS.anyPolicy) { throwExtensionEncoderError( 'policy_mappings_any_policy', 'policyMappings must not use anyPolicy', @@ -1696,8 +1748,7 @@ export function getExtendedKeyUsageOid(usage: ExtendedKeyUsage): string { if (typeof usage === 'string') { return EXTENDED_KEY_USAGE_OIDS[usage]; } - validateOid(usage.value); - return usage.value; + return validateOid(usage.value); } /** @@ -1732,8 +1783,7 @@ export function getAuthorityInfoAccessMethodOid(method: AuthorityInfoAccessMetho if (typeof method === 'string') { return AUTHORITY_INFO_ACCESS_METHOD_OIDS[method]; } - validateOid(method.value); - return method.value; + return validateOid(method.value); } /** diff --git a/test/internals.test.ts b/test/internals.test.ts index a49c1a1..9cbc010 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -90,6 +90,7 @@ import { parseDistributionPointReasonFlagsContent, parseKeyUsageExtension, } from '#micro509/internal/x509/extension-bits'; +import { listExtensionDefinitions } from '#micro509/internal/x509/extension-registry'; import { createPkcs12MacData, parsePkcs12MacDataOrThrow } from '#micro509/pkcs'; import { buildCertificateExtensions, @@ -108,6 +109,8 @@ import { encodePolicyMappings, encodeRelativeDistinguishedName, encodeSubjectAltName, + getAuthorityInfoAccessMethodOid, + getExtendedKeyUsageOid, } from '#micro509/x509'; import { parseCrlDistributionPoints } from '#micro509/x509/parse'; import { childrenOf, encodeUncheckedCrlDistributionPoints } from '#test/helpers'; @@ -986,6 +989,7 @@ describe('extensions encoding', () => { { oid: OIDS.basicConstraints, value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, }, ], }), @@ -998,6 +1002,7 @@ describe('extensions encoding', () => { { oid: OIDS.basicConstraints, value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, }, ], }), @@ -1033,13 +1038,17 @@ describe('extensions encoding', () => { ); /** The same custom extension offered through both builder entry points. */ - function buildCustomBothWays(oid: string, value: Uint8Array): readonly (() => unknown)[] { + function buildCustomBothWays( + oid: string, + value: Uint8Array, + critical = false, + ): readonly (() => unknown)[] { return [ () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, { - customExtensions: [{ oid, value }], + customExtensions: [{ oid, value, critical }], }), - () => buildRequestedExtensions({ customExtensions: [{ oid, value }] }), + () => buildRequestedExtensions({ customExtensions: [{ oid, value, critical }] }), ]; } @@ -1232,50 +1241,64 @@ describe('extensions encoding', () => { } }); - // Every known extension valid in both contexts, offered as a conformant custom - // payload, so each profile hook is exercised on its accepting path too. - const CONFORMANT_KNOWN_PAYLOADS = [ - ['keyUsage', OIDS.keyUsage, encodeKeyUsage(['digitalSignature'])], - ['extendedKeyUsage', OIDS.extendedKeyUsage, encodeExtendedKeyUsage(['serverAuth'])], - [ - 'subjectAltName', - OIDS.subjectAltName, - sequence([encodeSubjectAltName({ type: 'dns', value: 'san.example' })]), - ], + // One conformant payload per registered extension. Drives both the accepting + // path of each profile hook and the proof that every registry default carries + // the criticality its own hook demands. + const CONFORMANT_KNOWN_PAYLOADS = new Map([ + [OIDS.basicConstraints, encodeBasicConstraints({ ca: false })], + [OIDS.keyUsage, encodeKeyUsage(['digitalSignature'])], + [OIDS.extendedKeyUsage, encodeExtendedKeyUsage(['serverAuth'])], + [OIDS.subjectAltName, sequence([encodeSubjectAltName({ type: 'dns', value: 'san.example' })])], + [OIDS.issuerAltName, sequence([encodeSubjectAltName({ type: 'dns', value: 'ian.example' })])], [ - 'nameConstraints', OIDS.nameConstraints, encodeNameConstraints({ permittedSubtrees: [{ base: { type: 'dns', value: 'example' } }] }), ], + [OIDS.certificatePolicies, encodeCertificatePolicies([{ policyIdentifier: '1.2.3.4' }])], [ - 'certificatePolicies', - OIDS.certificatePolicies, - encodeCertificatePolicies([{ policyIdentifier: '1.2.3.4' }]), - ], - [ - 'policyMappings', OIDS.policyMappings, encodePolicyMappings([{ issuerDomainPolicy: '1.2.3.4', subjectDomainPolicy: '1.2.3.5' }]), ], + [OIDS.policyConstraints, encodePolicyConstraints({ requireExplicitPolicy: 0 })], + [OIDS.inhibitAnyPolicy, encodeInhibitAnyPolicy({ skipCerts: 0 })], [ - 'policyConstraints', - OIDS.policyConstraints, - encodePolicyConstraints({ requireExplicitPolicy: 0 }), - ], - ['inhibitAnyPolicy', OIDS.inhibitAnyPolicy, encodeInhibitAnyPolicy({ skipCerts: 0 })], - [ - 'authorityInfoAccess', OIDS.authorityInfoAccess, encodeAuthorityInfoAccess([ { method: 'ocsp', location: { type: 'uri', value: 'http://ocsp.example.test' } }, ]), ], - ] as const; + [ + OIDS.cRLDistributionPoints, + encodeCrlDistributionPoints([ + { distributionPoint: { fullName: [{ type: 'uri', value: 'http://crl.example/a.crl' }] } }, + ]), + ], + [OIDS.subjectKeyIdentifier, octetString(Uint8Array.of(1, 2, 3))], + [OIDS.authorityKeyIdentifier, sequence([implicitPrimitiveContext(0, Uint8Array.of(1, 2, 3))])], + ]); - it.each(CONFORMANT_KNOWN_PAYLOADS)( - 'accepts a conformant custom %s payload', - (_label, oid, value) => { - for (const build of buildCustomBothWays(oid, value)) { + it('emits every registered extension at the criticality its own profile demands', () => { + for (const definition of listExtensionDefinitions()) { + const payload = CONFORMANT_KNOWN_PAYLOADS.get(definition.oid); + expect(payload).toBeInstanceOf(Uint8Array); + if (payload !== undefined) { + definition.assertDerProfile(payload, definition.defaultCritical); + } + } + }); + + // basicConstraints is auto-emitted on the certificate path, so a custom copy + // always collides there; every other CSR-context extension takes both paths. + const CUSTOM_ACCEPTED_IN_BOTH_CONTEXTS = listExtensionDefinitions('csr') + .filter((definition) => definition.oid !== OIDS.basicConstraints) + .map((definition) => [definition.oid, definition.defaultCritical] as const); + + it.each(CUSTOM_ACCEPTED_IN_BOTH_CONTEXTS)( + 'accepts a conformant custom %s payload on both builder paths', + (oid, critical) => { + const value = CONFORMANT_KNOWN_PAYLOADS.get(oid); + expect(value).toBeInstanceOf(Uint8Array); + for (const build of buildCustomBothWays(oid, value ?? new Uint8Array(), critical)) { expect(build()).toBeInstanceOf(Array); } }, @@ -1294,13 +1317,10 @@ describe('extensions encoding', () => { }); it.each([ - ['subjectKeyIdentifier', OIDS.subjectKeyIdentifier, octetString(Uint8Array.of(1, 2, 3))], - [ - 'authorityKeyIdentifier', - OIDS.authorityKeyIdentifier, - sequence([implicitPrimitiveContext(0, Uint8Array.of(1, 2, 3))]), - ], - ])('validates a custom %s payload before rejecting it as a duplicate', (_label, oid, value) => { + ['subjectKeyIdentifier', OIDS.subjectKeyIdentifier], + ['authorityKeyIdentifier', OIDS.authorityKeyIdentifier], + ])('validates a custom %s payload before rejecting it as a duplicate', (_label, oid) => { + const value = CONFORMANT_KNOWN_PAYLOADS.get(oid) ?? new Uint8Array(); expectEncoderErrorCode( () => buildCertificateExtensions(subjectPublicKeyInfo, subjectPublicKeyInfo, { @@ -1320,6 +1340,106 @@ describe('extensions encoding', () => { ); }); + // RFC 5280 fixes the criticality of these extensions, and only a custom + // extension can carry the wrong one; typed input has no criticality knob. + const CRITICALITY_VIOLATIONS = [ + ['nameConstraints', OIDS.nameConstraints, false, 'extension_must_be_critical'], + ['policyConstraints', OIDS.policyConstraints, false, 'extension_must_be_critical'], + ['inhibitAnyPolicy', OIDS.inhibitAnyPolicy, false, 'extension_must_be_critical'], + ['authorityInfoAccess', OIDS.authorityInfoAccess, true, 'extension_must_be_non_critical'], + ] as const; + + it.each(CRITICALITY_VIOLATIONS)( + 'rejects a custom %s marked with the wrong criticality', + (_label, oid, critical, code) => { + const value = CONFORMANT_KNOWN_PAYLOADS.get(oid) ?? new Uint8Array(); + for (const build of buildCustomBothWays(oid, value, critical)) { + expectEncoderErrorCode(build, code); + } + }, + ); + + it.each([ + ['subjectKeyIdentifier', OIDS.subjectKeyIdentifier], + ['authorityKeyIdentifier', OIDS.authorityKeyIdentifier], + ])('rejects a critical custom %s (RFC 5280 §4.2.1.1, §4.2.1.2)', (_label, oid) => { + const value = CONFORMANT_KNOWN_PAYLOADS.get(oid) ?? new Uint8Array(); + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, subjectPublicKeyInfo, { + customExtensions: [{ oid, value, critical: true }], + }), + 'extension_must_be_non_critical', + ); + }); + + it('requires a CA certificate basicConstraints to be critical (RFC 5280 §4.2.1.9)', () => { + const value = encodeBasicConstraints({ ca: true }); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + keyUsage: ['keyCertSign'], + customExtensions: [{ oid: OIDS.basicConstraints, value }], + }), + 'extension_must_be_critical', + ); + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + keyUsage: ['keyCertSign'], + customExtensions: [{ oid: '2.5.029.19', value }], + }), + 'extension_must_be_critical', + ); + expect( + buildRequestedExtensions({ + keyUsage: ['keyCertSign'], + customExtensions: [{ oid: OIDS.basicConstraints, value, critical: true }], + }), + ).toBeInstanceOf(Array); + // Without keyCertSign the certificate is not one §4.2.1.9 constrains. + expect( + buildRequestedExtensions({ + keyUsage: ['digitalSignature'], + customExtensions: [{ oid: OIDS.basicConstraints, value }], + }), + ).toBeInstanceOf(Array); + }); + + it('resolves access-method and EKU OIDs canonically', () => { + // 1.3.6.1.5.5.7.048.1 and 2.5.29.032.0 encode to id-ad-ocsp and anyPolicy. + expect(getAuthorityInfoAccessMethodOid({ type: 'oid', value: '1.3.6.1.5.5.7.048.1' })).toBe( + OIDS.ocspAccessMethod, + ); + expect(getExtendedKeyUsageOid({ type: 'oid', value: '1.3.6.1.5.5.7.3.01' })).toBe( + OIDS.serverAuth, + ); + expectEncoderErrorCode( + () => + encodeAuthorityInfoAccess([ + { + method: { type: 'oid', value: '1.3.6.1.5.5.7.048.1' }, + location: { type: 'dns', value: 'ocsp.example.test' }, + }, + ]), + 'authority_info_access_ocsp_not_uri', + ); + expectEncoderErrorCode( + () => + encodePolicyMappings([ + { issuerDomainPolicy: '2.5.29.032.0', subjectDomainPolicy: '1.2.3.4' }, + ]), + 'policy_mappings_any_policy', + ); + expectEncoderErrorCode( + () => + encodePolicyMappings([ + { issuerDomainPolicy: '1.2.3.4', subjectDomainPolicy: '2.5.29.032.0' }, + ]), + 'policy_mappings_any_policy', + ); + }); + it('accepts a conformant custom issuerAltName only on the certificate path', () => { const value = sequence([encodeSubjectAltName({ type: 'dns', value: 'ian.example' })]); expect( @@ -1375,7 +1495,11 @@ describe('extensions encoding', () => { buildRequestedExtensions({ keyUsage: ['keyCertSign'], customExtensions: [ - { oid: '2.5.029.19', value: encodeBasicConstraints({ ca: true, pathLength: 0 }) }, + { + oid: '2.5.029.19', + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, ], }), ).toBeInstanceOf(Array); @@ -1384,7 +1508,11 @@ describe('extensions encoding', () => { buildRequestedExtensions({ keyUsage: ['digitalSignature'], customExtensions: [ - { oid: '2.5.029.19', value: encodeBasicConstraints({ ca: true, pathLength: 0 }) }, + { + oid: '2.5.029.19', + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, ], }), 'path_length_requires_key_cert_sign', diff --git a/test/verify.test.ts b/test/verify.test.ts index 7670edd..4c2ce28 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -35,6 +35,7 @@ import { encodeSubjectAltName } from '#micro509/x509'; import { parseNameConstraints } from '#micro509/x509/parse'; import { createCertificateWithRawExtensions, + createSelfSignedCertificateWithRawExtensions, importRsaPrivateKeyWithScheme, issueChain, replaceCertificateSignatureAlgorithm, @@ -2260,8 +2261,10 @@ describe('chain verification', () => { } /** Root CA whose nameConstraints extension is supplied as raw DER. */ + // RFC 5280 §4.2.1.10 requires a critical nameConstraints, so the non-critical + // tolerance fixture splices the extension into the signed certificate. function createConstrainedRoot(constraintDer: Uint8Array, critical: boolean) { - return createSelfSignedCertificate({ + return createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'Unsupported NC Root' }, extensions: { basicConstraints: { ca: true }, From 19968fc453dbdfee8ec24ced53008c52565497c7 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 01:40:55 +0200 Subject: [PATCH 11/14] fix(test): silence Bun PR test setup --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e143cf..4e09744 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "smoke:node": "node scripts/smoke.mjs", "smoke:workerd": "node scripts/smoke-workerd.mjs", "test": "BIN=\"${BIN:-bun}\"; AGENT=\"${AGENT:-1}\" $BIN test --concurrent", - "test:35433": "PR=\"35433\"; (command -v \"bun-${PR}\" || bunx bun-pr \"${PR}\") && BIN=\"bun-${PR}\" bun run test", + "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": "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", From e3a42ca24045970bb14ef8049cc4b5ee7628d27f Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 02:17:18 +0200 Subject: [PATCH 12/14] docs(test): normalize Bun code fences --- test/AGENTS.md | 54 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/test/AGENTS.md b/test/AGENTS.md index 6706cbe..27b151f 100644 --- a/test/AGENTS.md +++ b/test/AGENTS.md @@ -72,7 +72,7 @@ Define tests with a Jest-like API imported from the built-in `bun:test` module. To define a test: -```ts title="math.test.ts" +```ts import { expect, test } from 'bun:test'; test('2 + 2', () => { @@ -84,7 +84,7 @@ test('2 + 2', () => { Group tests into suites with `describe`. -```ts title="math.test.ts" +```ts import { expect, test, describe } from 'bun:test'; describe('arithmetic', () => { @@ -102,7 +102,7 @@ describe('arithmetic', () => { Tests can be async. -```ts title="math.test.ts" +```ts import { expect, test } from 'bun:test'; test('2 * 2', async () => { @@ -113,7 +113,7 @@ test('2 * 2', async () => { Alternatively, use the `done` callback to signal completion. If your test function takes a `done` parameter, you must call it or the test hangs. -```ts title="math.test.ts" +```ts import { expect, test } from 'bun:test'; test('2 * 2', (done) => { @@ -128,7 +128,7 @@ test('2 * 2', (done) => { Optionally specify a per-test timeout in milliseconds by passing a number as the third argument to `test`. -```ts title="math.test.ts" +```ts import { test } from 'bun:test'; test('wat', async () => { @@ -147,7 +147,7 @@ The default timeout for each test is 5000ms (5 seconds) if not overridden by thi Use the `retry` option to automatically retry a flaky test when it fails. The test passes if it succeeds within the specified number of attempts. -```ts title="example.test.ts" +```ts import { test } from 'bun:test'; test( @@ -164,7 +164,7 @@ test( Use the `repeats` option to run a test multiple times regardless of pass/fail status; the test fails if any iteration fails. Use it to detect flaky tests or for stress testing. `repeats: N` runs the test N+1 times total (1 initial run + N repeats). -```ts title="example.test.ts" +```ts import { test } from 'bun:test'; test( @@ -188,7 +188,7 @@ When a test times out, Bun kills any processes spawned in it with `Bun.spawn`, ` Skip individual tests with `test.skip`. These tests are not run. -```ts title="math.test.ts" +```ts import { expect, test } from 'bun:test'; test.skip('wat', () => { @@ -201,7 +201,7 @@ test.skip('wat', () => { Mark a test as a todo with `test.todo`. These tests are not run. -```ts title="math.test.ts" +```ts import { expect, test } from 'bun:test'; test.todo('fix this', () => { @@ -231,7 +231,7 @@ With this flag, failing todo tests do not cause an error, but todo tests that pa To run a particular test or suite of tests, use `test.only()` or `describe.only()`. -```ts title="example.test.ts" +```ts import { test, describe } from 'bun:test'; test('test #1', () => { @@ -259,7 +259,7 @@ bun test --only To run a test conditionally, use `test.if()`. The test runs if the condition is truthy. Use it for tests that should only run on a specific architecture or operating system. -```ts title="example.test.ts" +```ts test.if(Math.random() > 0.5)('runs half the time', () => { // ... }); @@ -274,7 +274,7 @@ test.if(macOS)('runs on macOS', () => { To instead skip a test based on some condition, use `test.skipIf()` or `describe.skipIf()`. -```ts title="example.test.ts" +```ts const macOS = process.platform === 'darwin'; test.skipIf(macOS)('runs on non-macOS', () => { @@ -286,7 +286,7 @@ test.skipIf(macOS)('runs on non-macOS', () => { To mark the test as TODO instead, use `test.todoIf()` or `describe.todoIf()`. The choice between `skipIf` and `todoIf` signals intent: "invalid for this target" versus "planned but not implemented yet." -```ts title="example.test.ts" +```ts const macOS = process.platform === 'darwin'; // TODO: we've only implemented this for Linux so far. @@ -302,7 +302,7 @@ Use `test.failing()` when you know a test is failing but you want to track it an - A failing test marked with `.failing()` passes - A passing test marked with `.failing()` fails, with a message that it now passes and should be fixed -```ts math.test.ts +```ts // This will pass because the test is failing as expected test.failing('math is broken', () => { expect(0.1 + 0.2).toBe(0.3); // fails due to floating point precision @@ -320,7 +320,7 @@ Use it to track known bugs you plan to fix later, or for test-driven development The conditional modifiers `.if()`, `.skipIf()`, and `.todoIf()` also work on `describe` blocks, affecting all tests in the suite: -```ts title="example.test.ts" +```ts const isMacOS = process.platform === 'darwin'; // Only runs the entire suite on macOS @@ -355,7 +355,7 @@ describe.todoIf(process.platform === 'linux')('Upcoming Linux support', () => { To run the same test with multiple sets of data, use `test.each`. This creates a parametrized test that runs once for each test case provided. -```ts title="math.test.ts" +```ts const cases = [ [1, 2, 3], [3, 4, 7], @@ -368,7 +368,7 @@ test.each(cases)('%p + %p should be %p', (a, b, expected) => { `describe.each` creates a parametrized suite that runs once for each test case: -```ts title="sum.test.ts" +```ts describe.each([ [1, 2, 3], [3, 4, 7], @@ -391,7 +391,7 @@ How arguments are passed to your test function depends on the structure of your - If a table row is an array (like `[1, 2, 3]`), each element is passed as an individual argument - If a row is not an array (like an object), it's passed as a single argument -```ts title="example.test.ts" +```ts // Array items passed as individual arguments test.each([ [1, 2, 3], @@ -427,7 +427,7 @@ Use these specifiers to format the test title: #### Examples -```ts title="example.test.ts" +```ts // Basic specifiers test.each([ ['hello', 123], @@ -461,7 +461,7 @@ Bun supports verifying that a specific number of assertions were called during a Use `expect.hasAssertions()` to verify that at least one assertion is called during a test: -```ts title="example.test.ts" +```ts test('async work calls assertions', async () => { expect.hasAssertions(); // Will fail if no assertions are called @@ -476,7 +476,7 @@ This is especially useful in async tests, to make sure your assertions run. Use `expect.assertions(count)` to verify that a specific number of assertions are called during a test: -```ts title="example.test.ts" +```ts test('exactly two assertions', () => { expect.assertions(2); // Will fail if not exactly 2 assertions are called @@ -500,7 +500,7 @@ The `expectTypeOf` function provides type-level assertions that are checked by T 1. Write your type assertions using `expectTypeOf` 2. Run `bunx tsc --noEmit` to check that your types are correct -```ts title="example.test.ts" +```ts import { expectTypeOf } from 'bun:test'; // Basic type assertions @@ -642,7 +642,7 @@ Bun implements the following matchers. Full Jest compatibility is planned; see t ### Use Descriptive Test Names -```ts title="example.test.ts" +```ts // Good test('should calculate total price including tax for multiple items', () => { // test implementation @@ -656,7 +656,7 @@ test('price calculation', () => { ### Group Related Tests -```ts title="auth.test.ts" +```ts describe('User authentication', () => { describe('with valid credentials', () => { test('should return user data', () => { @@ -678,7 +678,7 @@ describe('User authentication', () => { ### Use Appropriate Matchers -```ts title="auth.test.ts" +```ts // Good: Use specific matchers expect(users).toHaveLength(3); expect(user.email).toContain('@'); @@ -692,7 +692,7 @@ expect(response.status >= 200).toBe(true); ### Test Error Conditions -```ts title="example.test.ts" +```ts test('should throw error for invalid input', () => { expect(() => { validateEmail('not-an-email'); @@ -708,7 +708,7 @@ test('should handle async errors', async () => { ### Use Setup and Teardown -```ts title="example.test.ts" +```ts import { beforeEach, afterEach, test } from 'bun:test'; let testUser; From 702d82c3078f6cb86fd1a7fcacfb56d2eb65068c Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 02:20:27 +0200 Subject: [PATCH 13/14] fix(x509): widen the CA basicConstraints criticality rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 5280 §4.2.1.9 covers any CA certificate whose key may validate signatures on certificates, and §4.2.1.3 restricts a key only through a keyUsage extension that reaches the wire. An absent keyUsage therefore leaves the key free to validate certificate signatures, as does an empty one, which the builder omits. Both were accepting a non-critical `cA=true` custom basicConstraints. Only an emitted keyUsage lacking keyCertSign takes the certificate out of the clause's scope. --- src/x509/extensions.ts | 14 ++++++-- test/internals.test.ts | 74 +++++++++++++++++++++++++++--------------- 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/x509/extensions.ts b/src/x509/extensions.ts index c0a285f..3eb2a43 100644 --- a/src/x509/extensions.ts +++ b/src/x509/extensions.ts @@ -830,6 +830,11 @@ function assertPathLengthKeyUsage(input: CertificateExtensionsInput | undefined) * validates signatures on certificates. Both conjuncts live in other extensions, * so the rule sits here rather than in the basicConstraints profile hook. * + * §4.2.1.3 restricts a key only through a keyUsage extension that reaches the + * wire, so an absent keyUsage, and an empty one the builder therefore omits, + * leave the key free to validate certificate signatures. Only an emitted keyUsage + * without keyCertSign takes the certificate out of the clause's scope. + * * Only a custom extension can carry the wrong criticality; the typed field is * always emitted with the registry default. */ @@ -843,10 +848,15 @@ function assertCaBasicConstraintsCritical(input: CertificateExtensionsInput | un const basicConstraints = BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode( new Uint8Array(custom.value), ); - if (!basicConstraints.ca || resolveEffectiveKeyUsage(input)?.includes('keyCertSign') !== true) { + if (!basicConstraints.ca) { return; } - assertExtensionCriticality('basicConstraints', true, false); + const keyUsage = resolveEffectiveKeyUsage(input); + const mayValidateCertificates = + keyUsage === undefined || keyUsage.length === 0 || keyUsage.includes('keyCertSign'); + if (mayValidateCertificates) { + assertExtensionCriticality('basicConstraints', true, false); + } } /** diff --git a/test/internals.test.ts b/test/internals.test.ts index 9cbc010..436f940 100644 --- a/test/internals.test.ts +++ b/test/internals.test.ts @@ -1373,35 +1373,55 @@ describe('extensions encoding', () => { ); }); - it('requires a CA certificate basicConstraints to be critical (RFC 5280 §4.2.1.9)', () => { - const value = encodeBasicConstraints({ ca: true }); - expectEncoderErrorCode( - () => - buildRequestedExtensions({ - keyUsage: ['keyCertSign'], - customExtensions: [{ oid: OIDS.basicConstraints, value }], - }), - 'extension_must_be_critical', - ); - expectEncoderErrorCode( - () => - buildCertificateExtensions(subjectPublicKeyInfo, undefined, { - keyUsage: ['keyCertSign'], - customExtensions: [{ oid: '2.5.029.19', value }], - }), - 'extension_must_be_critical', - ); - expect( - buildRequestedExtensions({ - keyUsage: ['keyCertSign'], - customExtensions: [{ oid: OIDS.basicConstraints, value, critical: true }], - }), - ).toBeInstanceOf(Array); - // Without keyCertSign the certificate is not one §4.2.1.9 constrains. + // RFC 5280 §4.2.1.9 covers any CA certificate whose key may validate signatures + // on certificates. §4.2.1.3 restricts the key only through a keyUsage that + // reaches the wire, so absent and empty leave the key unrestricted, and the + // builder omits an empty keyUsage. + const CA_KEY_USAGE_CASES = [ + ['an absent keyUsage', undefined, true], + ['an empty keyUsage', [], true], + ['a keyCertSign keyUsage', ['keyCertSign'], true], + ['a keyUsage without keyCertSign', ['digitalSignature'], false], + ] as const; + + it.each(CA_KEY_USAGE_CASES)( + 'applies the §4.2.1.9 basicConstraints criticality rule under %s', + (_label, keyUsage, constrained) => { + const caBasicConstraints = encodeBasicConstraints({ ca: true }); + const input = (critical: boolean, oid: string) => ({ + ...(keyUsage === undefined ? {} : { keyUsage }), + customExtensions: [{ oid, value: caBasicConstraints, critical }], + }); + for (const oid of [OIDS.basicConstraints, '2.5.029.19']) { + if (constrained) { + expectEncoderErrorCode( + () => buildRequestedExtensions(input(false, oid)), + 'extension_must_be_critical', + ); + expectEncoderErrorCode( + () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, input(false, oid)), + 'extension_must_be_critical', + ); + } else { + expect(buildRequestedExtensions(input(false, oid))).toBeInstanceOf(Array); + // The certificate path always emits its own basicConstraints, so a + // silent criticality rule surfaces as the duplicate instead. + expectEncoderErrorCode( + () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, input(false, oid)), + 'duplicate_extension_oid', + ); + } + expect(buildRequestedExtensions(input(true, oid))).toBeInstanceOf(Array); + } + }, + ); + + it('leaves a non-CA custom basicConstraints non-critical (RFC 5280 §4.2.1.9)', () => { expect( buildRequestedExtensions({ - keyUsage: ['digitalSignature'], - customExtensions: [{ oid: OIDS.basicConstraints, value }], + customExtensions: [ + { oid: OIDS.basicConstraints, value: encodeBasicConstraints({ ca: false }) }, + ], }), ).toBeInstanceOf(Array); }); From 517fc1ca0a5728a94fceb9126dfa70d3709d3516 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sat, 25 Jul 2026 02:46:48 +0200 Subject: [PATCH 14/14] test: share the rejection helper and keep the signing profile `expectRejectedErrorCode` existed byte-identically in three suites; it moves to `test/helpers.ts`. The raw-extension helpers re-signed with `getSignatureAlgorithm(key)` and no profile, so a fixture built with `signature: { kind: 'rsa-pss' }` came back signed PKCS#1 v1.5 without complaint. `appendCertificateExtensions` takes the profile, and both certificate wrappers plus the CSR wrapper forward `input.signature`. The duplicate-extension builder assertions in `parse.test.ts` matched the message rather than `duplicate_extension_oid`, and neither awaited its `.rejects` chain. --- test/AGENTS.md | 8 +++--- test/certificate.test.ts | 55 ++++++++++++++++++++++++++++++++-------- test/crl.test.ts | 13 +--------- test/csr.test.ts | 13 +--------- test/helpers.ts | 31 +++++++++++++++++++--- test/parse.test.ts | 11 +++++--- 6 files changed, 85 insertions(+), 46 deletions(-) diff --git a/test/AGENTS.md b/test/AGENTS.md index 27b151f..41d08f9 100644 --- a/test/AGENTS.md +++ b/test/AGENTS.md @@ -593,10 +593,10 @@ Bun implements the following matchers. Full Jest compatibility is planned; see t ### Promise Matchers -| Status | Matcher | -| ------ | ------------- | -| ✅ | `.resolves()` | -| ✅ | `.rejects()` | +| Status | Matcher | +| ------ | ----------- | +| ✅ | `.resolves` | +| ✅ | `.rejects` | ### Mock Function Matchers diff --git a/test/certificate.test.ts b/test/certificate.test.ts index 087d51a..ff358bf 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -8,6 +8,7 @@ import { isResultError, parseCertificateDer, parseCertificatePem, + parseCertificateSigningRequestPem, unwrap, verifyCertificateChain, } from '#micro509'; @@ -18,23 +19,15 @@ import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/inter import { encodeName, encodeSubjectAltName } from '#micro509/x509'; import { childrenOf, + createCertificateWithRawExtensions, + createCsrWithRawExtensions, createSelfSignedCertificateWithRawExtensions, decodeObjectIdentifier, encodeUncheckedCrlDistributionPoints, + expectRejectedErrorCode, hasExtensionOid, } from '#test/helpers'; -async function expectRejectedErrorCode(promise: Promise, code: string): Promise { - try { - await promise; - } catch (error) { - expect(isResultError(error)).toBe(true); - expect(isResultError(error) ? error.code : undefined).toBe(code); - return; - } - throw new Error(`expected a ResultError with code '${code}', but the promise resolved`); -} - function expectThrownErrorCode(fn: () => unknown, code: string): void { try { fn(); @@ -190,6 +183,46 @@ describe('certificate', () => { serviceIdentity: { type: 'dns', value: 'rsa-pss-leaf.example' }, }), ).toMatchObject({ ok: true }); + + // The raw-extension helpers re-sign what the builder produced, so they carry + // the same signature profile rather than falling back to the key's default. + const splicedLeaf = await createCertificateWithRawExtensions({ + issuer: { commonName: 'RSA-PSS Root CA' }, + subject: { commonName: 'rsa-pss-spliced.example' }, + publicKey: leafKeys.publicKey, + signerPrivateKey: root.keyPair.privateKey, + issuerPublicKey: root.keyPair.publicKey, + signature: { kind: 'rsa-pss', saltLength: 48 }, + extensions: { + customExtensions: [ + { + oid: OIDS.subjectAltName, + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'spliced.example' })]), + }, + ], + }, + }); + expect(unwrap(parseCertificatePem(splicedLeaf.pem))).toMatchObject({ + signatureAlgorithmOid: OIDS.rsassaPss, + signatureAlgorithmParametersDer: expectedParameters, + }); + const splicedCsr = await createCsrWithRawExtensions({ + subject: { commonName: 'rsa-pss-spliced-csr.example' }, + publicKey: root.keyPair.publicKey, + signerPrivateKey: root.keyPair.privateKey, + signature: { kind: 'rsa-pss', saltLength: 48 }, + extensions: { + customExtensions: [ + { + oid: OIDS.subjectAltName, + value: sequence([encodeSubjectAltName({ type: 'dns', value: 'spliced-csr.example' })]), + }, + ], + }, + }); + expect(unwrap(parseCertificateSigningRequestPem(splicedCsr.pem))).toMatchObject({ + signatureAlgorithmOid: OIDS.rsassaPss, + }); }); it('creates P-521-signed certificates with ECDSA SHA-512', async () => { diff --git a/test/crl.test.ts b/test/crl.test.ts index 85dfb1e..c4784bb 100644 --- a/test/crl.test.ts +++ b/test/crl.test.ts @@ -6,7 +6,6 @@ import { createSelfSignedCertificate, generateKeyPair, isCertificateRevoked, - isResultError, parseCertificatePem, parseCertificateRevocationListDer, parseCertificateRevocationListDerOrThrow, @@ -37,21 +36,11 @@ import { createCertificateWithRawExtensions, decodeObjectIdentifier, encodeUncheckedCrlDistributionPoints, + expectRejectedErrorCode, hexToBytes, sliceElement, } from '#test/helpers'; -async function expectRejectedErrorCode(promise: Promise, code: string): Promise { - try { - await promise; - } catch (error) { - expect(isResultError(error)).toBe(true); - expect(isResultError(error) ? error.code : undefined).toBe(code); - return; - } - throw new Error(`expected a ResultError with code '${code}', but the promise resolved`); -} - describe('crl', () => { it('creates, parses, and verifies CRLs', async () => { const issuer = await createSelfSignedCertificate({ diff --git a/test/csr.test.ts b/test/csr.test.ts index b88f22a..6035af7 100644 --- a/test/csr.test.ts +++ b/test/csr.test.ts @@ -3,7 +3,6 @@ import { createCertificateSigningRequest, findExtension, generateKeyPair, - isResultError, parseCertificateSigningRequestPem, unwrap, verifyCertificateSigningRequest, @@ -14,22 +13,12 @@ import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/inter import { childrenOf, decodeObjectIdentifier, + expectRejectedErrorCode, importRsaPrivateKeyWithScheme, replaceCsrSignatureAlgorithm, rewriteCsrSignatureAsRsaPss, } from '#test/helpers'; -async function expectRejectedErrorCode(promise: Promise, code: string): Promise { - try { - await promise; - } catch (error) { - expect(isResultError(error)).toBe(true); - expect(isResultError(error) ? error.code : undefined).toBe(code); - return; - } - throw new Error(`expected a ResultError with code '${code}', but the promise resolved`); -} - describe('csr', () => { it('includes basicConstraints and customExtensions in CSR requested extensions', async () => { const keyPair = await generateKeyPair({ kind: 'ed25519' }); diff --git a/test/helpers.ts b/test/helpers.ts index 342280a..adebfe6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,3 +1,4 @@ +import { expect } from 'bun:test'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { toArrayBuffer } from '#micro509/internal/asn1/asn1'; @@ -19,13 +20,14 @@ import { tlv, } from '#micro509/internal/asn1/der'; import { OIDS } from '#micro509/internal/asn1/oids'; +import type { SignatureProfileInput } from '#micro509/internal/crypto/signing'; import { encodeAlgorithmIdentifier, getSignatureAlgorithm, signBytes, } from '#micro509/internal/crypto/signing'; import { exportPkcs8Der, generateKeyPair, importPkcs8Der } from '#micro509/keys'; -import { unwrap } from '#micro509/result'; +import { isResultError, unwrap } from '#micro509/result'; import type { BasicConstraints, CertificateMaterial, @@ -42,6 +44,26 @@ import { } from '#micro509/x509'; import { probeOpenSsl } from '#test/oracles/openssl'; +/** + * Await a builder promise and assert it rejected with a specific `ResultError` code. + * + * Builder input validation throws rather than returning a `Result`, so the code is + * the stable contract; the message is not. + */ +export async function expectRejectedErrorCode( + promise: Promise, + code: string, +): Promise { + try { + await promise; + } catch (error) { + expect(isResultError(error)).toBe(true); + expect(isResultError(error) ? error.code : undefined).toBe(code); + return; + } + throw new Error(`expected a ResultError with code '${code}', but the promise resolved`); +} + /** * Encode a CRLDistributionPoints value with an arbitrary cRLIssuer, bypassing the * builder's RFC 5280 §4.2.1.13 directoryName validation. Feeds the parser and @@ -404,6 +426,7 @@ export async function createSelfSignedCertificateWithRawExtensions( customExtensions.map((extension) => encodeExtension(extension.oid, new Uint8Array(extension.value), extension.critical ?? false), ), + input.signature, ); const base64 = Buffer.from(der).toString('base64'); return { @@ -417,6 +440,7 @@ export async function appendCertificateExtensions( certificateDer: Uint8Array, signerPrivateKey: CryptoKey, extensionDers: readonly Uint8Array[], + signature?: SignatureProfileInput, ): Promise { const top = readSequenceChildren(certificateDer); const tbsCertificate = top[0]; @@ -447,7 +471,7 @@ export async function appendCertificateExtensions( : sliceElement(tbsDer, child), ), ); - const signatureAlgorithm = getSignatureAlgorithm(signerPrivateKey); + const signatureAlgorithm = getSignatureAlgorithm(signerPrivateKey, signature); const signatureValue = await signBytes(signerPrivateKey, signatureAlgorithm, rebuiltTbsDer); return sequence([ rebuiltTbsDer, @@ -474,6 +498,7 @@ export async function createCertificateWithRawExtensions( customExtensions.map((extension) => encodeExtension(extension.oid, new Uint8Array(extension.value), extension.critical ?? false), ), + input.signature, ); return { der, base64: base64Of(der), pem: toPemBlock('CERTIFICATE', der) }; } @@ -512,7 +537,7 @@ export async function createCsrWithRawExtensions( concatBytes(withExtensionRequest(criDer, attributesElement, encoded)), ), ]); - const signatureAlgorithm = getSignatureAlgorithm(input.signerPrivateKey); + const signatureAlgorithm = getSignatureAlgorithm(input.signerPrivateKey, input.signature); const signature = await signBytes(input.signerPrivateKey, signatureAlgorithm, rebuiltCriDer); const der = sequence([ rebuiltCriDer, diff --git a/test/parse.test.ts b/test/parse.test.ts index 148aa96..0f80791 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -47,6 +47,7 @@ import { childrenOf, createCsrWithRawExtensions, createSelfSignedCertificateWithRawExtensions, + expectRejectedErrorCode, importRsaPrivateKeyWithScheme, replaceCertificateSignatureAlgorithm, rewriteCertificateSignatureAsRsaPss, @@ -126,7 +127,7 @@ describe('parse', () => { { oid: '1.2.3.4.201', critical: false, value: 'non-critical' }, ]); - expect( + await expectRejectedErrorCode( createSelfSignedCertificate({ subject: { commonName: 'dup-ext.example' }, extensions: { @@ -134,10 +135,11 @@ describe('parse', () => { customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['digitalSignature']) }], }, }), - ).rejects.toThrow('Duplicate extension OID'); + 'duplicate_extension_oid', + ); // 2.5.029.17 encodes to the same OID as 2.5.29.17, so it is the same extension. - expect( + await expectRejectedErrorCode( createSelfSignedCertificate({ subject: { commonName: 'dup-alias-ext.example' }, extensions: { @@ -150,7 +152,8 @@ describe('parse', () => { ], }, }), - ).rejects.toThrow('Duplicate extension OID: 2.5.029.17'); + 'duplicate_extension_oid', + ); }); it('rejects duplicate extension OIDs during certificate parse', async () => {