diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d84bd..0de0127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Certificate and CSR builders reject RFC 5280 MUST-NOT constructions with coded + throws. `pathLenConstraint` requires the keyUsage extension to assert + `keyCertSign`; absent, empty, or `keyCertSign`-less keyUsage is rejected + (§4.2.1.9, `path_length_requires_key_cert_sign`). An empty subject DN requires + a critical subjectAltName carrying at least one non-empty GeneralName; an empty + typed value (`{ type: 'dns', value: '' }`), an empty `subjectAltNames` array, + and a critical `customExtensions` SAN whose value holds no usable GeneralName + are all rejected (§4.2.1.6, `empty_subject_requires_subject_alt_name`), so + `subject: {}` can no longer sign a certificate with no identity. Encoding a + GeneralName with an empty `dNSName`, `rfc822Name`, URI, or SRV value is + rejected (§4.2.1.6, `empty_general_name_value`). A `cRLIssuer`, when present, + may only contain `directoryName` entries, rejecting a non-DN entry or a + directoryName smuggled through an `unknown` general name; a + `nameRelativeToCRLIssuer` distribution point additionally permits only one + (§4.2.1.13, `distribution_point_crl_issuer_not_directory_name`, + `distribution_point_relative_name_multiple_crl_issuers`). Known extensions + supplied through `customExtensions` participate in these cross-field checks. + A `customExtensions` entry carrying a known OID must decode as that extension, + rather than reaching the wire as opaque bytes the parser then rejects + (`malformed_known_extension_value`). Extension OIDs resolve by their encoded + value, so a non-canonical spelling such as `2.5.029.17` is the same extension + as `2.5.29.17` for registry lookup, certificate-versus-CSR context + restrictions, and duplicate detection; the diagnostic still quotes the OID as + submitted. A custom `cRLDistributionPoints` payload runs the same §4.2.1.13 + cRLIssuer checks as the typed field, since decoding proves structure but not + the profile the builder promises. `validateOid` also rejects an OID that parses + as decimals but breaks the X.660 arc bounds (`3.1`, `1.40`) with `invalid_oid` + rather than an uncoded `Error`. + (https://github.com/kjanat/micro509/pull/88) +- Parsing rejects a zero-length `dNSName`, `rfc822Name`, or + `uniformResourceIdentifier` GeneralName, which RFC 5280 §4.2.1.6 forbids. An + external certificate could previously carry an empty subjectAltName value and + parse, leaving chain verification to accept a certificate with no usable + identity when no identity match was requested. Certificate and CRL parsing + share the decoder, so this covers subjectAltName, issuerAltName, + authorityInfoAccess locations, CRL distribution points, `cRLIssuer`, the + issuing distribution point, and `certificateIssuer`. Name constraints keep + their own decoder, where an empty base is meaningful. + (https://github.com/kjanat/micro509/pull/88) - CRL applicability follows the RFC 5280 §6.3.3 relying-party algorithm in three places it diverged. A certificate without a CRLDP extension accepts a CRL whose issuing distribution point names the certificate issuer or one of 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..4e09744 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=\"${AGENT:-1}\" $BIN test --concurrent", + "test:35433": "PR=\"35433\"; (command -v \"bun-${PR}\" >/dev/null || bunx bun-pr \"${PR}\") && BIN=\"bun-${PR}\" bun --silent run test", "test:coverage": "run -q test --coverage", - "test:differential": "bun test test/differential.test.ts test/differential-fuzz.test.ts", - "test:pkits": "bun test test/pkits.test.ts", - "test:watch": "AGENT=1 bun test --watch --concurrent", + "test:differential": "BIN=\"${BIN:-bun}\"; $BIN test test/differential.test.ts test/differential-fuzz.test.ts", + "test:pkits": "BIN=\"${BIN:-bun}\"; $BIN test test/pkits.test.ts", + "test:watch": "BIN=\"${BIN:-bun}\"; $BIN run test --watch", "typecheck": "run -pk typecheck:src typecheck:other typecheck:regular site:typecheck", - "typecheck:other": "tsc --noEmit -p tsconfig.other.json", - "typecheck:regular": "tsc --noEmit -p tsconfig.json", - "typecheck:src": "tsc --noEmit -p tsconfig.src.json", + "typecheck:other": "run typescript-7 --noEmit -p tsconfig.other.json", + "typecheck:regular": "run typescript-7 --noEmit -p tsconfig.json", + "typecheck:src": "run typescript-7 --noEmit -p tsconfig.src.json", "wrangler:build": "wrangler build", "wrangler:deploy": "wrangler deploy", "wrangler:deploy:versions": "wrangler versions upload", @@ -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/AGENTS.md b/src/internal/AGENTS.md index 42ec53f..580b3b2 100644 --- a/src/internal/AGENTS.md +++ b/src/internal/AGENTS.md @@ -41,8 +41,19 @@ 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. 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/asn1/asn1.ts b/src/internal/asn1/asn1.ts index 25b8248..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(''); @@ -285,7 +297,7 @@ export function hexToBytes(value: string): Uint8Array { return out; } -/** Decodes a DER BOOLEAN value: any non-zero first byte is `true`. */ +/** Decodes a DER BOOLEAN value. X.690 clause 11.1 restricts TRUE to an all-ones octet, so only `0xff` and `0x00` are accepted. */ export function decodeBoolean(bytes: Uint8Array): boolean { if (bytes.length !== 1) { throw new Error('BOOLEAN must contain exactly one octet'); 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..45bba57 100644 --- a/src/internal/x509/extension-errors.ts +++ b/src/internal/x509/extension-errors.ts @@ -20,20 +20,29 @@ export type ExtensionEncoderErrorCode = | 'directory_name_not_sequence' | 'display_text_out_of_range' | 'distribution_point_crl_issuer_empty' + | 'distribution_point_crl_issuer_not_directory_name' | 'distribution_point_empty' | 'distribution_point_full_name_empty' | 'distribution_point_name_conflict' | 'distribution_point_name_empty' + | 'distribution_point_relative_name_multiple_crl_issuers' | 'duplicate_extension_oid' | 'duplicate_policy_oid' + | 'empty_general_name_value' + | 'empty_subject_requires_subject_alt_name' | 'extended_key_usage_empty' + | 'extension_must_be_critical' + | 'extension_must_be_non_critical' | 'extension_not_supported_in_context' | 'invalid_general_name_tag' | 'invalid_ia5_string' | 'invalid_ip_name_constraint' | 'invalid_oid' | 'key_usage_empty' + | 'malformed_known_extension_value' | 'name_constraints_empty' + | 'path_length_requires_ca' + | 'path_length_requires_key_cert_sign' | 'policy_constraints_empty' | 'policy_mappings_any_policy' | 'policy_mappings_empty' diff --git a/src/internal/x509/extension-registry.ts b/src/internal/x509/extension-registry.ts index 6f29384..06cdd54 100644 --- a/src/internal/x509/extension-registry.ts +++ b/src/internal/x509/extension-registry.ts @@ -34,6 +34,10 @@ import type { SubjectAltName, } from '#micro509/x509/extensions'; import { + assertAuthorityInfoAccessProfile, + assertCrlDistributionPointsProfile, + assertExtensionCriticality, + assertNameConstraintsProfile, buildSubjectKeyIdentifier, encodeAuthorityInfoAccess, encodeBasicConstraints, @@ -130,10 +134,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, critical: boolean): 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, critical: boolean): void; +}; + /** * Module-internal decode-and-apply closures, captured at definition time. * @@ -147,20 +176,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 +201,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 +218,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 +274,83 @@ export const NAME_CONSTRAINTS_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: true, decode: (valueDer) => parseNameConstraints(valueDer), encode: (value) => encodeNameConstraints(value), + assertProfile: (value, critical) => { + assertNameConstraintsProfile(value); + assertExtensionCriticality('nameConstraints', true, critical); + }, 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, critical) => { + encodePolicyConstraints(value); + assertExtensionCriticality('policyConstraints', true, critical); + }, 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, critical) => { + encodeInhibitAnyPolicy(value); + assertExtensionCriticality('inhibitAnyPolicy', true, critical); + }, 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 +362,17 @@ export const AUTHORITY_INFO_ACCESS_EXTENSION_DEFINITION: ExtensionDefinition< defaultCritical: false, decode: (valueDer) => parseAuthorityInfoAccess(valueDer), encode: (value) => encodeAuthorityInfoAccess(value), + assertProfile: (value, critical) => { + assertAuthorityInfoAccessProfile(value); + assertExtensionCriticality('authorityInfoAccess', false, critical); + }, 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 +381,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 +400,17 @@ export const SUBJECT_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< autoGenerated: true, decode: (valueDer) => decodeSubjectKeyIdentifier(valueDer), encode: (value) => octetString(normalizeKeyIdentifier(value)), + assertProfile: (value, critical) => { + hexToBytes(value); + assertExtensionCriticality('subjectKeyIdentifier', false, critical); + }, 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 +420,12 @@ export const AUTHORITY_KEY_IDENTIFIER_EXTENSION_DEFINITION: ExtensionDefinition< autoGenerated: true, decode: (valueDer) => parseAuthorityKeyIdentifier(valueDer), encode: (value) => sequence([implicitPrimitiveContext(0, normalizeKeyIdentifier(value))]), + assertProfile: (value, critical) => { + if (value !== undefined) { + hexToBytes(value); + } + assertExtensionCriticality('authorityKeyIdentifier', false, critical); + }, applyParsed: (accumulator, value) => { if (value !== undefined) { accumulator.authorityKeyIdentifier = value; @@ -509,20 +594,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, critical) => { + definition.assertProfile(definition.decode(valueDer), critical); + }, + }; } /** Accept hex string or Uint8Array and return raw bytes. */ 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 8d4a77b..3eb2a43 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, @@ -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'; @@ -72,7 +73,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 +727,8 @@ const AUTHORITY_INFO_ACCESS_METHOD_OIDS: Record(); const basicConstraints = input?.basicConstraints ?? { ca: false }; @@ -753,7 +760,7 @@ export function buildCertificateExtensions( buildSubjectKeyIdentifier(issuerPublicKeyInfo), ); } - appendConfiguredExtensions(extensions, seen, input, 'certificate', { + appendConfiguredExtensions(extensions, seen, input, { includeBasicConstraints: false, subjectIsEmpty, }); @@ -771,9 +778,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; } @@ -782,7 +790,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. */ @@ -796,7 +803,217 @@ 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); +} + +/** + * 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(input: CertificateExtensionsInput | undefined): void { + const basicConstraints = resolveEffectiveBasicConstraints(input); + if (basicConstraints?.pathLength === undefined) { + return; + } + 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', + ); + } +} + +/** + * 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. + * + * §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. + */ +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) { + return; + } + const keyUsage = resolveEffectiveKeyUsage(input); + const mayValidateCertificates = + keyUsage === undefined || keyUsage.length === 0 || keyUsage.includes('keyCertSign'); + if (mayValidateCertificates) { + 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. + */ +function assertEmptySubjectHasCriticalSubjectAltName( + input: CertificateExtensionsInput | undefined, +): void { + if (input?.subjectAltNames?.some(subjectAltNameHasIdentity) === true) { + return; + } + const criticalCustomSan = input?.customExtensions?.some( + (extension) => + oidEquals(extension.oid, OIDS.subjectAltName) && + extension.critical === true && + customSubjectAltNameHasIdentity(extension.value), + ); + if (criticalCustomSan === true) { + return; + } + throwExtensionEncoderError( + 'empty_subject_requires_subject_alt_name', + 'An empty subject requires a critical subjectAltName extension', + ); +} + +/** + * 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 { + return candidate === known || canonicalizeOid(candidate) === canonicalizeOid(known); +} + +/** 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; +} + +/** 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. + * + * 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 ?? []) { + const oid = validateOid(extension.oid); + const definition = getExtensionDefinition(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`, + ); + } + 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 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. + */ +function assertKnownExtensionPayload( + definition: { + readonly oid: string; + assertDerProfile(valueDer: Uint8Array, critical: boolean): void; + }, + value: Uint8Array, + critical: boolean, + submittedOid: string, +): void { + try { + definition.assertDerProfile(value, critical); + } catch (error) { + if (isResultError(error)) { + throw error; + } + throwExtensionEncoderError( + 'malformed_known_extension_value', + `Custom extension ${submittedOid} does not decode as ${definition.oid}`, + ); + } +} + +/** 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); + return custom === undefined ? undefined : BASIC_CONSTRAINTS_EXTENSION_DEFINITION.decode(custom); +} + +/** 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); + return custom === undefined ? undefined : KEY_USAGE_EXTENSION_DEFINITION.decode(custom).flags; +} + +/** Whether a typed GeneralName carries a non-empty identity value. */ +function subjectAltNameHasIdentity(name: SubjectAltName): boolean { + 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. */ +function customSubjectAltNameHasIdentity(value: Uint8Array): boolean { + return SUBJECT_ALT_NAME_EXTENSION_DEFINITION.decode(value).some(subjectAltNameHasIdentity); } function appendConstraintExtensions( @@ -805,6 +1022,8 @@ function appendConstraintExtensions( input: CertificateExtensionsInput, includeBasicConstraints: boolean, ): void { + assertPathLengthKeyUsage(input); + assertCaBasicConstraintsCritical(input); if (includeBasicConstraints && input.basicConstraints !== undefined) { pushKnownExtension( encoded, @@ -906,29 +1125,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, + ); } } @@ -951,6 +1161,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)); @@ -972,7 +1183,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)); } @@ -999,6 +1213,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. * @@ -1007,17 +1229,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': @@ -1059,27 +1281,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', + ); + } + } } /** @@ -1088,13 +1325,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); + } } /** @@ -1103,6 +1379,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( @@ -1120,13 +1397,49 @@ export function encodeNameConstraints(constraints: NameConstraints): Uint8Array ), ); } - if (parts.length === 0) { + 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. + */ +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); } /** @@ -1143,7 +1456,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', @@ -1166,12 +1479,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', @@ -1304,20 +1614,36 @@ function encodeGeneralSubtree(subtree: GeneralSubtree): Uint8Array { return sequence([encodeNameConstraintForm(subtree.base)]); } -/** DER-encode the fields of a single DistributionPoint. */ -function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { - if (point.crlIssuer !== undefined && point.crlIssuer.length === 0) { +/** + * 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: ProfileDistributionPoint, +): void { + if (point.crlIssuer === undefined) { + return; + } + if (point.crlIssuer.some((name) => name.type !== 'directoryName')) { throwExtensionEncoderError( - 'distribution_point_crl_issuer_empty', - 'DistributionPoint crlIssuer must not be empty', + 'distribution_point_crl_issuer_not_directory_name', + 'DistributionPoint cRLIssuer must only contain directoryName entries', ); } - if (point.distributionPoint === undefined && point.crlIssuer === undefined) { + if (point.distributionPoint?.relativeName !== undefined && point.crlIssuer.length > 1) { throwExtensionEncoderError( - 'distribution_point_empty', - 'DistributionPoint must contain distributionPoint or crlIssuer', + 'distribution_point_relative_name_multiple_crl_issuers', + 'DistributionPointName relativeName requires at most one cRLIssuer distinguished name', ); } +} + +/** DER-encode the fields of a single DistributionPoint, already profile-checked. */ +function encodeDistributionPoint(point: DistributionPoint): Uint8Array[] { const fields: Uint8Array[] = []; if (point.distributionPoint !== undefined) { fields.push( @@ -1329,7 +1655,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))), ); @@ -1337,8 +1663,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', @@ -1352,15 +1683,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', @@ -1368,6 +1694,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) { @@ -1418,8 +1758,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); } /** @@ -1454,8 +1793,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); } /** @@ -1489,19 +1827,37 @@ export function buildSubjectKeyIdentifier(subjectPublicKeyInfo: Uint8Array): Uin return sha1(publicKeyBytes); } -/** Throw if the string is not a valid dotted-decimal OID. */ -function validateOid(oid: string): void { +/** + * 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 + * 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): string { if (!/^\d+(?:\.\d+)+$/.test(oid)) { throwExtensionEncoderError('invalid_oid', `Invalid OID: ${oid}`); } + try { + 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); } -/** 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, @@ -1509,10 +1865,10 @@ function pushExtension( value: Uint8Array, critical = false, ): void { - validateOid(oid); - if (seen.has(oid)) { + const identity = validateOid(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/AGENTS.md b/test/AGENTS.md index 37cc43d..41d08f9 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 +import { expect, test } from 'bun:test'; + +test('2 + 2', () => { + expect(2 + 2).toBe(4); +}); +``` + +### Grouping Tests + +Group tests into suites with `describe`. + +```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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +// 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 +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 +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 +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 +// 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 +// 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 +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 +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 +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 +// Good +test('should calculate total price including tax for multiple items', () => { + // test implementation +}); + +// Avoid +test('price calculation', () => { + // test implementation +}); +``` + +### Group Related Tests + +```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 +// 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 +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 +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" diff --git a/test/certificate.test.ts b/test/certificate.test.ts index f4bf8ff..ff358bf 100644 --- a/test/certificate.test.ts +++ b/test/certificate.test.ts @@ -8,25 +8,25 @@ import { isResultError, parseCertificateDer, parseCertificatePem, + parseCertificateSigningRequestPem, unwrap, verifyCertificateChain, } from '#micro509'; -import { readElement } from '#micro509/internal/asn1/der'; +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'; -import { encodeName } from '#micro509/x509'; -import { childrenOf, decodeObjectIdentifier, 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`); -} +import { encodeName, encodeSubjectAltName } from '#micro509/x509'; +import { + childrenOf, + createCertificateWithRawExtensions, + createCsrWithRawExtensions, + createSelfSignedCertificateWithRawExtensions, + decodeObjectIdentifier, + encodeUncheckedCrlDistributionPoints, + expectRejectedErrorCode, + hasExtensionOid, +} from '#test/helpers'; function expectThrownErrorCode(fn: () => unknown, code: string): void { try { @@ -183,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 () => { @@ -237,6 +277,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 +295,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 +322,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: { @@ -299,17 +334,21 @@ describe('certificate', () => { }); }); - it('roundtrips CRL distribution points that only name an alternate CRL issuer', async () => { - const { certificate } = await createSelfSignedCertificate({ + it('parses issuer-only CRL distribution points that name a non-DN CRL issuer', async () => { + const { certificate } = await createSelfSignedCertificateWithRawExtensions({ 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' }, + ], + }, + ]), }, ], }, @@ -318,7 +357,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' }, @@ -327,6 +365,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' }, @@ -479,6 +536,85 @@ 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', + ); + 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 With CertSign' }, + extensions: { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['keyCertSign'], + }, + }); + 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/crl.test.ts b/test/crl.test.ts index 8a5f0b9..c4784bb 100644 --- a/test/crl.test.ts +++ b/test/crl.test.ts @@ -6,7 +6,6 @@ import { createSelfSignedCertificate, generateKeyPair, isCertificateRevoked, - isResultError, parseCertificatePem, parseCertificateRevocationListDer, parseCertificateRevocationListDerOrThrow, @@ -34,22 +33,14 @@ import { encodeSubjectAltName } from '#micro509/x509'; import { addRevokedEntryCertificateIssuers, childrenOf, + 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({ @@ -172,6 +163,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, @@ -195,7 +187,7 @@ describe('crl', () => { ], }, reasons: ['cACompromise'], - crlIssuer: [{ type: 'dns', value: 'delta-issuer.example.test' }], + crlIssuer: [{ type: 'directoryName', derHex: deltaIssuerDnHex }], }, { distributionPoint: { @@ -227,7 +219,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: { @@ -615,19 +607,22 @@ 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, 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' }], + }, + ]), }, ], }, @@ -1079,109 +1074,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', () => { @@ -2045,19 +2031,22 @@ 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, 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/csr.test.ts b/test/csr.test.ts index ede13d6..6035af7 100644 --- a/test/csr.test.ts +++ b/test/csr.test.ts @@ -13,6 +13,7 @@ import { encodeRsaPssParameters, rsaPssParametersForHash } from '#micro509/inter import { childrenOf, decodeObjectIdentifier, + expectRejectedErrorCode, importRsaPrivateKeyWithScheme, replaceCsrSignatureAlgorithm, rewriteCsrSignatureAsRsaPss, @@ -27,6 +28,7 @@ describe('csr', () => { signerPrivateKey: keyPair.privateKey, extensions: { basicConstraints: { ca: true, pathLength: 2 }, + keyUsage: ['keyCertSign'], customExtensions: [ { oid: '1.2.3.4.999', @@ -43,6 +45,22 @@ 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' }); + 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 () => { const rsaKeys = await generateKeyPair({ kind: 'rsa', diff --git a/test/helpers.ts b/test/helpers.ts index 62f8579..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'; @@ -6,6 +7,7 @@ import { bool, concatBytes, explicitContext, + implicitConstructedContext, integer, integerFromNumber, nullValue, @@ -18,21 +20,99 @@ 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 type { BasicConstraints, GeneralName, ParsedCertificate } from '#micro509/x509'; +import { isResultError, unwrap } from '#micro509/result'; +import type { + BasicConstraints, + CertificateMaterial, + CsrMaterial, + GeneralName, + ParsedCertificate, + SelfSignedCertificateResult, +} from '#micro509/x509'; import { createCertificate, + createCertificateSigningRequest, createSelfSignedCertificate, encodeSubjectAltName, } 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 + * revocation scanner the non-conformant inputs the conformant builder refuses to emit. + */ +export function encodeUncheckedCrlDistributionPoints( + points: readonly { + readonly fullNameUri?: string; + readonly relativeNameSetDer?: Uint8Array; + readonly crlIssuerDer?: Uint8Array; + 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.relativeNameSetDer !== undefined) { + const set = readElement(point.relativeNameSetDer); + fields.push( + 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); + }), + ); +} + export function childrenOf( source: Uint8Array, parent: { readonly start: number; readonly end: number }, @@ -320,6 +400,210 @@ 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), + ), + input.signature, + ); + 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[], + signature?: SignatureProfileInput, +): 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, signature); + 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), + ), + input.signature, + ); + 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, input.signature); + 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 04e4596..436f940 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 { @@ -88,20 +90,30 @@ 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, + buildRequestedExtensions, encodeAuthorityInfoAccess, + encodeBasicConstraints, encodeCertificatePolicies, encodeCrlDistributionPoints, encodeExtendedKeyUsage, + encodeExtension, + encodeInhibitAnyPolicy, encodeKeyUsage, + encodeName, encodeNameConstraints, + encodePolicyConstraints, encodePolicyMappings, encodeRelativeDistinguishedName, encodeSubjectAltName, + getAuthorityInfoAccessMethodOid, + getExtendedKeyUsageOid, } from '#micro509/x509'; -import { childrenOf } from '#test/helpers'; +import { parseCrlDistributionPoints } from '#micro509/x509/parse'; +import { childrenOf, encodeUncheckedCrlDistributionPoints } from '#test/helpers'; function expectEncoderErrorCode(fn: () => unknown, code: string): void { try { @@ -861,6 +873,819 @@ 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' })); + 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', + ); + 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([ + sequence([objectIdentifier(OIDS.rsaEncryption), nullValue()]), + bitString(Uint8Array.of(0x01, 0x02, 0x03)), + ]); + + it('rejects pathLenConstraint without an effective keyCertSign keyUsage (RFC 5280 §4.2.1.9)', () => { + expectEncoderErrorCode( + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['digitalSignature'], + }), + '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 }, + keyUsage: ['keyCertSign'], + }), + ).toBeInstanceOf(Array); + expect( + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + basicConstraints: { ca: true, pathLength: 0 }, + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['keyCertSign']) }], + }), + ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + keyUsage: ['digitalSignature'], + customExtensions: [ + { + oid: OIDS.basicConstraints, + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, + ], + }), + 'path_length_requires_key_cert_sign', + ); + expect( + buildRequestedExtensions({ + keyUsage: ['keyCertSign'], + customExtensions: [ + { + oid: OIDS.basicConstraints, + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, + ], + }), + ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + basicConstraints: { ca: true, pathLength: 0 }, + customExtensions: [{ oid: OIDS.keyUsage, value: encodeKeyUsage(['digitalSignature']) }], + }), + 'path_length_requires_key_cert_sign', + ); + }); + + 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, { + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'malformed_known_extension_value', + ); + }, + ); + + /** The same custom extension offered through both builder entry points. */ + function buildCustomBothWays( + oid: string, + value: Uint8Array, + critical = false, + ): readonly (() => unknown)[] { + return [ + () => + buildCertificateExtensions(subjectPublicKeyInfo, undefined, { + customExtensions: [{ oid, value, critical }], + }), + () => buildRequestedExtensions({ customExtensions: [{ oid, value, critical }] }), + ]; + } + + 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'); + } + }); + + // 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' })])], + [ + OIDS.nameConstraints, + encodeNameConstraints({ permittedSubtrees: [{ base: { type: 'dns', value: 'example' } }] }), + ], + [OIDS.certificatePolicies, encodeCertificatePolicies([{ policyIdentifier: '1.2.3.4' }])], + [ + OIDS.policyMappings, + encodePolicyMappings([{ issuerDomainPolicy: '1.2.3.4', subjectDomainPolicy: '1.2.3.5' }]), + ], + [OIDS.policyConstraints, encodePolicyConstraints({ requireExplicitPolicy: 0 })], + [OIDS.inhibitAnyPolicy, encodeInhibitAnyPolicy({ skipCerts: 0 })], + [ + OIDS.authorityInfoAccess, + encodeAuthorityInfoAccess([ + { method: 'ocsp', location: { type: 'uri', value: 'http://ocsp.example.test' } }, + ]), + ], + [ + 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('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); + } + }, + ); + + 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.basicConstraints, value: encodeBasicConstraints({ ca: false }) }, + ], + }), + ).toBeInstanceOf(Array); + }); + + it.each([ + ['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, { + 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', + ); + }); + + // 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', + ); + }); + + // 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({ + customExtensions: [ + { oid: OIDS.basicConstraints, value: encodeBasicConstraints({ ca: false }) }, + ], + }), + ).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( + 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( + () => + buildRequestedExtensions({ + customExtensions: [{ oid, value: Uint8Array.of(0x05, 0x00) }], + }), + 'invalid_oid', + ); + }); + + 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({ + keyUsage: ['keyCertSign'], + customExtensions: [ + { + oid: '2.5.029.19', + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, + ], + }), + ).toBeInstanceOf(Array); + expectEncoderErrorCode( + () => + buildRequestedExtensions({ + keyUsage: ['digitalSignature'], + customExtensions: [ + { + oid: '2.5.029.19', + value: encodeBasicConstraints({ ca: true, pathLength: 0 }), + critical: true, + }, + ], + }), + '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)', () => { + expectEncoderErrorCode( + () => buildCertificateExtensions(subjectPublicKeyInfo, undefined, undefined, true), + 'empty_subject_requires_subject_alt_name', + ); + expectEncoderErrorCode( + () => + 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, + ), + '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( + buildCertificateExtensions( + subjectPublicKeyInfo, + undefined, + { subjectAltNames: [{ type: 'dns', value: 'empty-subject.example' }] }, + 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('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( () => 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 8f2e8c7..0f80791 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,9 @@ import { } from '#micro509/x509/parse'; import { childrenOf, + createCsrWithRawExtensions, + createSelfSignedCertificateWithRawExtensions, + expectRejectedErrorCode, importRsaPrivateKeyWithScheme, replaceCertificateSignatureAlgorithm, rewriteCertificateSignatureAsRsaPss, @@ -60,7 +63,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 +78,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: [ @@ -124,15 +127,33 @@ describe('parse', () => { { oid: '1.2.3.4.201', critical: false, value: 'non-critical' }, ]); - expect( + await expectRejectedErrorCode( createSelfSignedCertificate({ 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'); + 'duplicate_extension_oid', + ); + + // 2.5.029.17 encodes to the same OID as 2.5.29.17, so it is the same extension. + await expectRejectedErrorCode( + 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' })]), + }, + ], + }, + }), + 'duplicate_extension_oid', + ); }); it('rejects duplicate extension OIDs during certificate parse', async () => { @@ -267,6 +288,7 @@ describe('parse', () => { subject: { commonName: 'bad-basic-constraints.example' }, extensions: { basicConstraints: { ca: true, pathLength: 0 }, + keyUsage: ['keyCertSign'], }, }); @@ -356,7 +378,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: [ @@ -390,7 +412,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: [ @@ -426,7 +448,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: [ @@ -796,7 +818,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) }], @@ -1283,7 +1305,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([]) }], @@ -1299,7 +1321,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: [ @@ -1323,7 +1345,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: [ @@ -1352,7 +1374,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: [ @@ -1383,7 +1405,7 @@ describe('parse', () => { } } - const emptyQualifiers = await createSelfSignedCertificate({ + const emptyQualifiers = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-policy-empty-qualifiers.example' }, extensions: { customExtensions: [ @@ -1405,7 +1427,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: [ @@ -1438,7 +1460,7 @@ describe('parse', () => { } } - const duplicateNoticeRef = await createSelfSignedCertificate({ + const duplicateNoticeRef = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-user-notice-ref.example' }, extensions: { customExtensions: [ @@ -1479,7 +1501,7 @@ describe('parse', () => { } } - const duplicateExplicitText = await createSelfSignedCertificate({ + const duplicateExplicitText = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-user-notice-text.example' }, extensions: { customExtensions: [ @@ -1670,7 +1692,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: [ @@ -1690,7 +1712,7 @@ describe('parse', () => { } } - const badDisplayText = await createSelfSignedCertificate({ + const badDisplayText = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-display-text.example' }, extensions: { customExtensions: [ @@ -1722,7 +1744,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: [ @@ -1738,7 +1760,7 @@ describe('parse', () => { } } - const emptyFullName = await createSelfSignedCertificate({ + const emptyFullName = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-empty-fullname.example' }, extensions: { customExtensions: [ @@ -1760,7 +1782,7 @@ describe('parse', () => { } } - const setWrappedDistributionPoint = await createSelfSignedCertificate({ + const setWrappedDistributionPoint = await createSelfSignedCertificateWithRawExtensions({ subject: { commonName: 'bad-set-dp.example' }, extensions: { customExtensions: [ @@ -1806,7 +1828,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: [ @@ -1840,7 +1862,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: [ @@ -1872,7 +1894,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: [ @@ -1905,7 +1927,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: [ @@ -1927,7 +1949,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: [ @@ -2283,7 +2305,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 }], @@ -2303,7 +2325,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 }] }, }); @@ -2317,7 +2339,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 }] }, }); @@ -2358,7 +2380,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: [ @@ -2382,7 +2404,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: [ @@ -2656,13 +2678,46 @@ describe('subjectAltName and extendedKeyUsage parse strictness', () => { ).toThrow(); }); + // 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 () => { 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 }] }, }); @@ -2671,7 +2726,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/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, diff --git a/test/verify.test.ts b/test/verify.test.ts index 0ae345b..4c2ce28 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -34,6 +34,8 @@ import { import { encodeSubjectAltName } from '#micro509/x509'; import { parseNameConstraints } from '#micro509/x509/parse'; import { + createCertificateWithRawExtensions, + createSelfSignedCertificateWithRawExtensions, importRsaPrivateKeyWithScheme, issueChain, replaceCertificateSignatureAlgorithm, @@ -92,7 +94,7 @@ describe('chain verification', () => { signerPrivateKey: root.keyPair.privateKey, issuerPublicKey: root.keyPair.publicKey, extensions: { - basicConstraints: { ca: true, pathLength: 0 }, + basicConstraints: { ca: true }, keyUsage: ['digitalSignature'], }, }); @@ -260,7 +262,7 @@ describe('chain verification', () => { const noKeyCertSignChain = await issueChain({ intermediateExtensions: { - basicConstraints: { ca: true, pathLength: 0 }, + basicConstraints: { ca: true }, keyUsage: ['digitalSignature'], }, }); @@ -892,7 +894,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, @@ -2259,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 }, @@ -3742,7 +3746,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'], }, });