@noz-ele/edgca 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,40 +2,43 @@
2
2
 
3
3
  > [日本語](https://github.com/noz-ele/EdgCA/blob/main/docs/jp/README.md) | English
4
4
 
5
- EdgCA is a small TypeScript library for issuing mTLS client certificates and document-signing certificates from a self-managed CA on Cloudflare Workers-compatible runtimes.
5
+ EdgCA is a small TypeScript library for issuing mTLS client certificates and document-signing certificates from a self-managed CA, and for bounded certificate validation against explicit trust anchors, on Cloudflare Workers-compatible runtimes.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **WebCrypto-only, zero runtime dependencies.** All cryptographic operations go through `globalThis.crypto.subtle`. The same code runs on Cloudflare Workers, Node.js 20+, and modern browsers without polyfills or bundler shims.
10
- - **Lightweight.** v0.5.2 — tarball **45.2 kB** · unpacked **167.3 kB** · 73 files. No transitive dependencies; the CLI uses only `node:util.parseArgs`. (Re-measured on every release.)
10
+ - **Lightweight.** v0.6.0 — tarball **49.8 kB** · unpacked **192.8 kB** · 76 files. No transitive dependencies; the CLI uses only `node:util.parseArgs`. (Re-measured on every release.)
11
11
  - **CA hierarchy (two-level).** Create a self-signed root CA and, optionally, issue an intermediate CA from it. Three or more levels of intermediates are intentionally out of scope.
12
12
  - **PFX (PKCS#12) bundling.** Wrap a cert + private key (and optional chain) into a password-protected `.pfx` / `.p12` for OS keystore import (Win11+, macOS 15+, iOS/iPadOS 18+, modern Linux). Algorithm-agnostic — accepts arbitrary PKCS#8 DER bytes (ECDSA, RSA, Ed25519, …).
13
13
  - **mTLS client certificate issuance.** Issue a leaf with internal key generation, or from a caller-managed key via the CSR path below.
14
14
  - **PKCS#10 CSR support.** Build a CSR (with proof-of-possession), parse a received CSR (subject, requested SAN, public key, raw extensions/attributes), verify its POP signature, and issue a cert from a CSR's public key without ever handling the private key.
15
15
  - **Document-signing certificates (RFC 9336).** Issue a leaf with EKU `id-kp-documentSigning`, usable as the signer cert for CAdES / CMS / ASiC tooling (containers themselves are built separately).
16
- - **Issuance check.** Decide whether a received client certificate was issued by your own CA (issuer-identity match — not full mTLS verification).
16
+ - **Issuance check.** Decide whether a received client certificate was issued by your own CA (issuer-identity match — not full mTLS verification).
17
+ - **Bounded chain validation.** Validate a caller-ordered `leaf → intermediate → trusted root` chain, including signatures, validity, CA constraints, and target purpose. Automatic PKI path building and revocation are intentionally out of scope.
17
18
  - **PEM/DER encode/decode** for certificates and PKCS#10 CSRs.
18
19
  - **Secret key hygiene at the API boundary.** Private keys flow through the public API only as `CryptoKey` (issuance path) or `Uint8Array` PKCS#8 bytes (`exportPkcs12`); never as `string`. JS strings are immutable and stay on the heap until GC, so they cannot be wiped — secret material must not be held in that form. PEM ↔ CryptoKey conversion is the caller's job (so the lifetime of any string representation stays under caller control).
19
20
 
20
21
  ### Supported algorithms
21
22
 
22
- - **Issuance layer**: ECDSA on NIST P-256 / P-384 / P-521 (with the standard SHA-256 / SHA-384 / SHA-512 pairings). RSA, EdDSA, and other curves are intentionally out of scope at issuance.
23
+ - **Issuance layer**: ECDSA on NIST P-256 / P-384 / P-521 (with the standard SHA-256 / SHA-384 / SHA-512 pairings). RSA, EdDSA, and other curves are intentionally out of scope at issuance.
24
+ - **Verification layer**: the same ECDSA NIST P-256 / P-384 / P-521 set. It is intentionally not a general algorithm verifier.
23
25
  - **PFX bundling (`exportPkcs12`)**: algorithm-agnostic — accepts any PKCS#8 DER bytes verbatim.
24
26
 
25
- > ⚠ **Not a PKI runtime.** EdgCA is an issuance toolkit, not a general-purpose PKI library or runtime. It does **not** provide chain validation, revocation (CRL/OCSP), key storage, or rotation. `verifyClientCertificateIssuedBy` is **not** mTLS verification and does **not** authenticate the presenter — see [Verify](#verify-cloudflare-worker) below. Operating a CA safely is the caller's responsibility. Full list: [docs/en/NON_GOALS.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/NON_GOALS.md).
27
+ > ⚠ **Not a general PKI runtime.** Chain validation is limited to a caller-ordered hierarchy no deeper than `root → intermediate → leaf`, with explicit trusted roots. EdgCA does not provide automatic path building, AIA fetching, OS trust-store access, revocation (CRL/OCSP), key storage, or rotation. Certificate validation also does not prove that the presenter holds the private key — see [Verify](#verify-cloudflare-worker) below. Full list: [docs/en/NON_GOALS.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/NON_GOALS.md).
26
28
 
27
29
  ## Contents
28
30
 
29
31
  - [CLI](#cli) — `npx edgca …` one-liners for the four most common tasks
30
32
  - [Quick Start](#quick-start) — root → intermediate → client cert (incl. PFX bundling)
31
33
  - [Issue a document-signing certificate](#issue-a-document-signing-certificate) — RFC 9336 `id-kp-documentSigning` leaf
32
- - [Verify on Cloudflare Worker](#verify-cloudflare-worker) — confirm a cert was issued by your CA
34
+ - [Verify on Cloudflare Worker](#verify-cloudflare-worker) — confirm a cert was issued by your CA
35
+ - [Certificate chain verification](#certificate-chain-verification) — validate an explicit chain to a trusted root
33
36
  - [Issue from a CSR](#issue-from-a-csr) — accept a caller-managed key via PKCS#10 + POP
34
37
  - [Subject](#subject) · [Scope](#scope) · [Key Handling](#key-handling) · [Development](#development) · [API Documentation](#api-documentation)
35
38
 
36
39
  ## Status
37
40
 
38
- EdgCA is in **v0.3.x — early stabilization**. The author is currently validating the library against real Cloudflare Workers deployments, and the API surface may still shift. To keep that validation focused, **external Issues and PRs are temporarily restricted** and will be re-opened once the API settles. Reading, cloning, forking, and `npm install` are unaffected.
41
+ EdgCA is in **v0.6.x — early stabilization**. The author is currently validating the library against real Cloudflare Workers deployments, and the API surface may still shift. To keep that validation focused, **external Issues and PRs are temporarily restricted** and will be re-opened once the API settles. Reading, cloning, forking, and `npm install` are unaffected.
39
42
 
40
43
  ## Install
41
44
 
@@ -43,7 +46,19 @@ EdgCA is in **v0.3.x — early stabilization**. The author is currently validati
43
46
  npm install @noz-ele/edgca
44
47
  ```
45
48
 
46
- ESM-only (`"type": "module"`). Runs on any runtime where `globalThis.crypto.subtle` is available (Cloudflare Workers, Node.js 20+, modern browsers, etc.). CommonJS `require` is not supported.
49
+ ESM-only (`"type": "module"`). Runs on any runtime where `globalThis.crypto.subtle` is available (Cloudflare Workers, Node.js 20+, modern browsers, etc.). CommonJS `require` is not supported.
50
+
51
+ ### Package entry points
52
+
53
+ The root `@noz-ele/edgca` entry point remains an aggregate surface for compatibility. Use a purpose-specific subpath when you want an issuance-only bundle to avoid statically importing the verification implementation regardless of tree shaking:
54
+
55
+ ```ts
56
+ import { createRootCA, issueClientCert } from "@noz-ele/edgca/issuer";
57
+ import { verifyCertificateChain } from "@noz-ele/edgca/verify";
58
+ import { exportPkcs12 } from "@noz-ele/edgca/pkcs12";
59
+ ```
60
+
61
+ `./issuer` owns CA creation/import and intermediate/leaf issuance. `./verify` validates direct issuers and chains using public certificates only; it does not statically import the issuer module. The package remains `sideEffects: false`.
47
62
 
48
63
  ## CLI
49
64
 
@@ -241,7 +256,7 @@ There is no `issueDocumentSigningCertForPublicKey` (CSR variant) in v1, and EdgC
241
256
  >
242
257
  > Also out of scope (not checked by this function): `BasicConstraints CA=false`, `EKU clientAuth`, revocation, and chain walking.
243
258
 
244
- This section assumes a deployment where **Cloudflare has already extracted the client certificate** and exposes it to your application via `request.cf.tlsClientAuth`. EdgCA participates in neither the TLS handshake nor DER parsing of the cert; it consumes the values Cloudflare hands you and performs the issuance check above.
259
+ This section assumes a deployment where **Cloudflare has already extracted the client certificate** and exposes it to your application via `request.cf.tlsClientAuth`. EdgCA does not participate in the TLS handshake; it parses the certificate fields needed for the issuance check above.
245
260
 
246
261
  ### Formats Cloudflare exposes after extraction
247
262
 
@@ -323,7 +338,36 @@ export default {
323
338
 
324
339
  - Omitting `validity` performs only the identity check (issuer DN + AKI/SKI + signature). If you instead inline the time check as two comparisons in the application, the result is equivalent.
325
340
  - "Not issued by us" and "outside the validity window" return `false`; malformed PEM/DER throws. The two error categories are deliberately split.
326
- - Pass the **direct issuer (one cert)** as `ca`. Verifying a leaf issued via an intermediate against the root will return `false` — chain walking is not performed.
341
+ - Pass the **direct issuer (one cert)** as `ca`. Verifying a leaf issued via an intermediate against the root will return `false` — chain walking is not performed.
342
+
343
+ ## Certificate chain verification
344
+
345
+ The `@noz-ele/edgca/verify` surface validates a direct issuer or a bounded chain using public certificates only. No CA private key is required.
346
+
347
+ ```ts
348
+ import { verifyCertificateChain } from "@noz-ele/edgca/verify";
349
+
350
+ const result = await verifyCertificateChain({
351
+ certificatePem: clientPem,
352
+ // Direct issuer first. EdgCA accepts zero or one intermediate.
353
+ intermediateCertificatesPem: [intermediatePem],
354
+ // No OS trust store is consulted; trust anchors are explicit.
355
+ trustedRootCertificatesPem: [rootPem],
356
+ purpose: "clientAuth"
357
+ });
358
+
359
+ if (!result.valid) {
360
+ throw new Error(
361
+ `certificate chain rejected at ${result.certificateIndex}: ${result.reason}`
362
+ );
363
+ }
364
+ ```
365
+
366
+ The verifier checks each child/issuer DN, AKI/SKI, and signature; DER validity times; issuer `BasicConstraints`, `keyCertSign`, and `pathLenConstraint`; the requested target purpose; duplicate and unsupported critical extensions; and inner/outer signature-algorithm consistency. The terminal issuer must be one of the explicitly supplied trusted-root certificates.
367
+
368
+ This is not an automatic PKI path builder. The caller supplies intermediates in order, and the supported maximum is `root → intermediate → leaf`. EdgCA does not fetch AIA URLs, consult an OS trust store, check CRL/OCSP, verify TLS proof-of-possession, or perform server hostname matching. A valid chain does not prove that the presenter holds the leaf private key.
369
+
370
+ The existing `verifyClientCertificateIssuedBy` remains unchanged for compatibility. New code should use `verifyCertificateIssuedBy` for one direct link or `verifyCertificateChain` to reach an explicit trusted root.
327
371
 
328
372
  ## Issue from a CSR
329
373
 
@@ -393,7 +437,10 @@ In scope:
393
437
  - mTLS client certificate issuance (with internal key generation, or from a caller-provided public key).
394
438
  - Document-signing certificate issuance with EKU `id-kp-documentSigning` (RFC 9336) and `keyUsage digitalSignature, contentCommitment` — internal key generation only, no SAN.
395
439
  - CSR (PKCS#10) parsing and proof-of-possession signature verification.
396
- - Identity check that a cert was issued by your own CA (`verifyClientCertificateIssuedBy`, with optional time-validity check).
440
+ - Identity check that a cert was issued by your own CA (`verifyClientCertificateIssuedBy`, with optional time-validity check).
441
+ - Direct public-certificate issuer validation (`verifyCertificateIssuedBy`).
442
+ - Caller-ordered chain validation up to `root → intermediate → leaf` (`verifyCertificateChain`), including DER validity, CA/Key Usage/EKU/path-length constraints, and critical-extension policy.
443
+ - Purpose-specific entry points (`@noz-ele/edgca/issuer` and `@noz-ele/edgca/verify`).
397
444
  - PEM/DER helpers (certificates only — keys are exchanged as `CryptoKey`).
398
445
  - PFX (PKCS#12) export of an issued cert + private key with PBES2 (PBKDF2-HMAC-SHA-256 + AES-256-CBC) and HMAC-SHA-256 MAC, scoped to modern consumers (Win11+, Server 2019+, macOS 15+, iOS/iPadOS 18+).
399
446
  - Basic Constraints, Key Usage, Extended Key Usage, Subject Alternative Name, SKI, AKI.
@@ -404,9 +451,12 @@ Intentionally out of scope:
404
451
  - Document-signing certificate issuance from a caller-provided public key (`issueDocumentSigningCertForPublicKey`) — not in v1.
405
452
  - SAN (`dnsNames` / `ipAddresses` / `emailAddresses`) on document-signing leaves.
406
453
  - CAdES / CMS / PAdES / XAdES / ASiC document signing or container building. EdgCA only issues the signing certificate; producing a signed document or container is a separate concern.
407
- - Public chain-validation APIs.
408
- - Extracting time fields from a cert. `verifyClientCertificateIssuedBy`'s `validity` option performs the time check, but the `notBefore` / `notAfter` values are passed in by the caller from `cf.tlsClientAuth`.
409
- - CRL, OCSP, revocation databases, revocation checks.
454
+ - Automatic PKI path building from unordered certificates, issuer discovery, or AIA fetching.
455
+ - OS/runtime trust-store access; trusted roots must be supplied explicitly.
456
+ - Chains with two or more intermediate CAs.
457
+ - Parsing Cloudflare-specific textual times. The verification module reads DER certificate times; the legacy API still accepts caller-supplied validity values.
458
+ - CRL, OCSP, revocation databases, revocation checks.
459
+ - TLS-handshake proof-of-possession and server hostname/SAN identity verification.
410
460
  - Key storage, encryption-at-rest, rotation-state persistence, and integration with KV/D1/R2/Secrets.
411
461
  - RSA, EdDSA, other elliptic curves (CSRs signed with these algorithms are rejected at parse time).
412
462
  - Legacy PKCS#12 algorithms (3DES, RC2, SHA-1 PBE), PBMAC1, empty passwords, crlBag / secretBag / nested safeContents, and consumers older than the modern targets above are intentionally not produced or supported by `exportPkcs12`.
@@ -423,13 +473,16 @@ EdgCA only handles key generation, signing, and SPKI export of public keys. Wher
423
473
 
424
474
  ### Bringing your own CA key (recommended)
425
475
 
426
- Root and intermediate CAs are long-lived. To keep key management on the caller's side, `createRootCA` and `issueIntermediateCA` accept an existing `keyPair: CryptoKeyPair`. This lets the caller's key-management infrastructure handle the full key lifecycle (generation, storage, rotation) consistently — including the choice of persistence format — which is the recommended path.
476
+ Root and intermediate CAs are long-lived. To keep key management on the caller's side, `createRootCA` and `issueIntermediateCA` accept an existing `keyPair: CryptoKeyPair`. This lets the caller's key-management infrastructure handle the full key lifecycle (generation, storage, rotation) consistently — including the choice of persistence format — which is the recommended path.
477
+
478
+ EdgCA verifies a supplied public/private pair with a sign/verify round trip before issuance. If the private key is imported with `extractable: false`, its public key cannot be reconstructed by exporting that private key; persist and import the public SPKI separately.
427
479
 
428
480
  ```ts
429
481
  // Restore a CryptoKeyPair from whatever persistence format you use.
430
- // Below is one example that converts PKCS#8 PEM stored in a vault.
431
- async function loadKeyPair(label: string): Promise<CryptoKeyPair> {
432
- const pkcs8 = pemToDer(loadFromVault(`${label}-private-pem`));
482
+ // Below is one example that imports separately stored PKCS#8 and SPKI PEM.
483
+ async function loadKeyPair(label: string): Promise<CryptoKeyPair> {
484
+ const pkcs8 = pemToDer(loadFromVault(`${label}-private-pem`));
485
+ const spki = pemToDer(loadFromVault(`${label}-public-pem`));
433
486
  const privateKey = await crypto.subtle.importKey(
434
487
  "pkcs8",
435
488
  pkcs8,
@@ -437,14 +490,9 @@ async function loadKeyPair(label: string): Promise<CryptoKeyPair> {
437
490
  /* extractable */ false,
438
491
  ["sign"]
439
492
  );
440
- // Derive the matching public key. If you also persist the public key as
441
- // SPKI, import that directly instead of round-tripping through JWK.
442
- const jwk = await crypto.subtle.exportKey("jwk", privateKey);
443
- delete jwk.d;
444
- jwk.key_ops = ["verify"];
445
- const publicKey = await crypto.subtle.importKey(
446
- "jwk",
447
- jwk,
493
+ const publicKey = await crypto.subtle.importKey(
494
+ "spki",
495
+ spki,
448
496
  { name: "ECDSA", namedCurve: "P-256" },
449
497
  true,
450
498
  ["verify"]
package/SECURITY.md CHANGED
@@ -10,16 +10,17 @@ Do not open public issues for security reports. We will respond on a best-effort
10
10
 
11
11
  ## Scope
12
12
 
13
- EdgCA is a stateless certificate-issuance toolkit for Cloudflare Workers-compatible runtimes. The following are **in scope** for security reports:
13
+ EdgCA is a stateless certificate-issuance and bounded-validation toolkit for Cloudflare Workers-compatible runtimes. The following are **in scope** for security reports:
14
14
 
15
15
  - Incorrect ASN.1 / DER encoding of issued certificates that could mislead a verifier.
16
16
  - Cryptographic signature or KDF misuse, including incorrect use of `globalThis.crypto.subtle`.
17
17
  - Memory-safety problems, infinite loops, or unbounded allocations triggered by malformed PEM/DER input to public functions.
18
18
  - Public-API surface that allows a caller to produce a certificate that violates the documented invariants (e.g., `issueIntermediateCA` producing `pathLenConstraint > 0`).
19
+ - Incorrect acceptance or rejection by the documented direct-issuer and bounded-chain validation policies.
19
20
 
20
21
  The following are **out of scope** (see [docs/en/NON_GOALS.md](docs/en/NON_GOALS.md) for the full list and rationale):
21
22
 
22
- - Chain validation, revocation (CRL/OCSP), key storage, rotation — EdgCA does not provide them.
23
+ - Automatic PKI path building, AIA fetching, OS trust-store access, revocation (CRL/OCSP), key storage, and rotation — EdgCA does not provide them.
23
24
  - Operational misuse of issued material (leaked private keys, logging secrets, weak storage).
24
25
  - Caller-controlled inputs producing wrong outputs by design ("garbage in, garbage out" behavior is documented; e.g., `importCertificateAuthority` does not cryptographically validate that `issuerChainPem` actually issued `certPem`).
25
26
  - `verifyClientCertificateIssuedBy` not authenticating the presenter — by design, this function only verifies issuance, not proof-of-possession of the private key. See the Verify section of the README for details.
package/dist/ca.js CHANGED
@@ -185,6 +185,7 @@ function assertIssuerChainPem(chainPem) {
185
185
  }
186
186
  async function resolveKeyPair(provided) {
187
187
  if (provided !== undefined) {
188
+ await assertKeyPairMatches(provided.privateKey, provided.publicKey);
188
189
  return provided;
189
190
  }
190
191
  return generateKeyPair();
package/dist/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export { certificateToPem, csrToPem, encodePem, pemToDer, pemToDerWithLabel, spl
4
4
  export { generateKeyPair, type SupportedCurve } from "./crypto.js";
5
5
  export { arrayBufferFromBytes, bytesEqual } from "./bytes.js";
6
6
  export { exportPkcs12, type ExportPkcs12Input } from "./pkcs12.js";
7
- export { verifyClientCertificateIssuedBy, type VerifyClientCertificateIssuedByOptions, type VerifyClientCertificateValidity } from "./verify.js";
7
+ export { verifyCertificateChain, verifyCertificateIssuedBy, verifyClientCertificateIssuedBy, type CertificateChainVerificationResult, type CertificateVerificationFailureReason, type CertificateVerificationPurpose, type VerifyCertificateChainOptions, type VerifyCertificateIssuedByOptions, type VerifyClientCertificateIssuedByOptions, type VerifyClientCertificateValidity } from "./verify.js";
8
8
  export type { CertificateAuthority, CreateRootCAOptions, ImportCertificateAuthorityOptions, IssueClientCertForPublicKeyOptions, IssueClientCertOptions, IssueDocumentSigningCertOptions, IssueIntermediateCAOptions, IssuedClientCertificate, IssuedClientCertificateForPublicKey, IssuedDocumentSigningCertificate, SerialNumber, ShortSubjectAttributeType, Subject, SubjectAttribute, SubjectAttributeType } from "./types.js";
9
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,0BAA0B,EAC1B,eAAe,EACf,2BAA2B,EAC3B,wBAAwB,EACxB,mBAAmB,EACpB,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,EAC9B,wCAAwC,EACxC,KAAK,kCAAkC,EACvC,KAAK,kCAAkC,EACvC,KAAK,oCAAoC,EACzC,KAAK,gCAAgC,EACrC,KAAK,+BAA+B,EACrC,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,gBAAgB,EAChB,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,iBAAiB,EACjB,cAAc,EACf,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EACL,+BAA+B,EAC/B,KAAK,sCAAsC,EAC3C,KAAK,+BAA+B,EACrC,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,oBAAoB,EACpB,mBAAmB,EACnB,iCAAiC,EACjC,kCAAkC,EAClC,sBAAsB,EACtB,+BAA+B,EAC/B,0BAA0B,EAC1B,uBAAuB,EACvB,mCAAmC,EACnC,gCAAgC,EAChC,YAAY,EACZ,yBAAyB,EACzB,OAAO,EACP,gBAAgB,EAChB,oBAAoB,EACrB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,0BAA0B,EAC1B,eAAe,EACf,2BAA2B,EAC3B,wBAAwB,EACxB,mBAAmB,EACpB,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,+BAA+B,EAC/B,8BAA8B,EAC9B,wCAAwC,EACxC,KAAK,kCAAkC,EACvC,KAAK,kCAAkC,EACvC,KAAK,oCAAoC,EACzC,KAAK,gCAAgC,EACrC,KAAK,+BAA+B,EACrC,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,gBAAgB,EAChB,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,iBAAiB,EACjB,cAAc,EACf,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACnE,OAAO,EACL,sBAAsB,EACtB,yBAAyB,EACzB,+BAA+B,EAC/B,KAAK,kCAAkC,EACvC,KAAK,oCAAoC,EACzC,KAAK,8BAA8B,EACnC,KAAK,6BAA6B,EAClC,KAAK,gCAAgC,EACrC,KAAK,sCAAsC,EAC3C,KAAK,+BAA+B,EACrC,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,oBAAoB,EACpB,mBAAmB,EACnB,iCAAiC,EACjC,kCAAkC,EAClC,sBAAsB,EACtB,+BAA+B,EAC/B,0BAA0B,EAC1B,uBAAuB,EACvB,mCAAmC,EACnC,gCAAgC,EAChC,YAAY,EACZ,yBAAyB,EACzB,OAAO,EACP,gBAAgB,EAChB,oBAAoB,EACrB,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -4,4 +4,4 @@ export { certificateToPem, csrToPem, encodePem, pemToDer, pemToDerWithLabel, spl
4
4
  export { generateKeyPair } from "./crypto.js";
5
5
  export { arrayBufferFromBytes, bytesEqual } from "./bytes.js";
6
6
  export { exportPkcs12 } from "./pkcs12.js";
7
- export { verifyClientCertificateIssuedBy } from "./verify.js";
7
+ export { verifyCertificateChain, verifyCertificateIssuedBy, verifyClientCertificateIssuedBy } from "./verify.js";
@@ -0,0 +1,3 @@
1
+ export { createRootCA, importCertificateAuthority, issueClientCert, issueClientCertForPublicKey, issueDocumentSigningCert, issueIntermediateCA } from "./ca.js";
2
+ export type { CertificateAuthority, CreateRootCAOptions, ImportCertificateAuthorityOptions, IssueClientCertForPublicKeyOptions, IssueClientCertOptions, IssueDocumentSigningCertOptions, IssueIntermediateCAOptions, IssuedClientCertificate, IssuedClientCertificateForPublicKey, IssuedDocumentSigningCertificate, SerialNumber, ShortSubjectAttributeType, Subject, SubjectAttribute, SubjectAttributeType } from "./types.js";
3
+ //# sourceMappingURL=issuer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issuer.d.ts","sourceRoot":"","sources":["../src/issuer.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,0BAA0B,EAC1B,eAAe,EACf,2BAA2B,EAC3B,wBAAwB,EACxB,mBAAmB,EACpB,MAAM,SAAS,CAAC;AAEjB,YAAY,EACV,oBAAoB,EACpB,mBAAmB,EACnB,iCAAiC,EACjC,kCAAkC,EAClC,sBAAsB,EACtB,+BAA+B,EAC/B,0BAA0B,EAC1B,uBAAuB,EACvB,mCAAmC,EACnC,gCAAgC,EAChC,YAAY,EACZ,yBAAyB,EACzB,OAAO,EACP,gBAAgB,EAChB,oBAAoB,EACrB,MAAM,YAAY,CAAC"}
package/dist/issuer.js ADDED
@@ -0,0 +1 @@
1
+ export { createRootCA, importCertificateAuthority, issueClientCert, issueClientCertForPublicKey, issueDocumentSigningCert, issueIntermediateCA } from "./ca.js";
package/dist/parser.d.ts CHANGED
@@ -1,18 +1,41 @@
1
+ export interface ParsedKeyUsage {
2
+ digitalSignature: boolean;
3
+ contentCommitment: boolean;
4
+ keyCertSign: boolean;
5
+ cRLSign: boolean;
6
+ }
1
7
  export interface ParsedCertificate {
2
8
  der: Uint8Array;
3
9
  tbsCertificateDer: Uint8Array;
4
10
  signatureDer: Uint8Array;
11
+ signatureAlgorithmOid: string;
12
+ tbsSignatureAlgorithmOid: string;
13
+ signatureAlgorithmMatches: boolean;
5
14
  issuerNameDer: Uint8Array;
6
15
  subjectNameDer: Uint8Array;
7
16
  subjectPublicKeyInfoDer: Uint8Array;
8
17
  publicKey: CryptoKey;
18
+ notBeforeMs?: number;
19
+ notAfterMs?: number;
20
+ basicConstraintsPresent: boolean;
9
21
  isCA: boolean;
10
22
  pathLenConstraint?: number;
23
+ keyUsagePresent: boolean;
24
+ keyUsage: ParsedKeyUsage;
11
25
  keyCertSign: boolean;
26
+ extendedKeyUsagePresent: boolean;
27
+ extendedKeyUsageOids: readonly string[];
12
28
  subjectKeyIdentifier?: Uint8Array;
13
29
  authorityKeyIdentifier?: Uint8Array;
30
+ duplicateExtensionOids: readonly string[];
31
+ unsupportedCriticalExtensionOids: readonly string[];
32
+ }
33
+ export interface ParsedCertificateForVerification extends ParsedCertificate {
34
+ notBeforeMs: number;
35
+ notAfterMs: number;
14
36
  }
15
37
  export declare function extractCertificateSpkiDer(der: Uint8Array): Uint8Array;
16
38
  export declare function parseCertificateDer(der: Uint8Array): Promise<ParsedCertificate>;
39
+ export declare function parseCertificateDerForVerification(der: Uint8Array): Promise<ParsedCertificateForVerification>;
17
40
  export declare function assertIssuerSubjectMatches(issuer: ParsedCertificate, issued: ParsedCertificate): void;
18
41
  //# sourceMappingURL=parser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,UAAU,CAAC;IAChB,iBAAiB,EAAE,UAAU,CAAC;IAC9B,YAAY,EAAE,UAAU,CAAC;IACzB,aAAa,EAAE,UAAU,CAAC;IAC1B,cAAc,EAAE,UAAU,CAAC;IAC3B,uBAAuB,EAAE,UAAU,CAAC;IACpC,SAAS,EAAE,SAAS,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,WAAW,EAAE,OAAO,CAAC;IACrB,oBAAoB,CAAC,EAAE,UAAU,CAAC;IAClC,sBAAsB,CAAC,EAAE,UAAU,CAAC;CACrC;AAQD,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CA+BrE;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAkErF;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAIrG"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,cAAc;IAC7B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iBAAiB,EAAE,OAAO,CAAC;IAC3B,WAAW,EAAE,OAAO,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,UAAU,CAAC;IAChB,iBAAiB,EAAE,UAAU,CAAC;IAC9B,YAAY,EAAE,UAAU,CAAC;IACzB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,wBAAwB,EAAE,MAAM,CAAC;IACjC,yBAAyB,EAAE,OAAO,CAAC;IACnC,aAAa,EAAE,UAAU,CAAC;IAC1B,cAAc,EAAE,UAAU,CAAC;IAC3B,uBAAuB,EAAE,UAAU,CAAC;IACpC,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uBAAuB,EAAE,OAAO,CAAC;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,QAAQ,EAAE,cAAc,CAAC;IACzB,WAAW,EAAE,OAAO,CAAC;IACrB,uBAAuB,EAAE,OAAO,CAAC;IACjC,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,oBAAoB,CAAC,EAAE,UAAU,CAAC;IAClC,sBAAsB,CAAC,EAAE,UAAU,CAAC;IACpC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,gCAAgC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrD;AAED,MAAM,WAAW,gCAAiC,SAAQ,iBAAiB;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB;AAoBD,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,UAAU,GAAG,UAAU,CAyBrE;AAID,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAKrF;AAKD,wBAAsB,kCAAkC,CACtD,GAAG,EAAE,UAAU,GACd,OAAO,CAAC,gCAAgC,CAAC,CAM3C;AAkGD,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAIrG"}
package/dist/parser.js CHANGED
@@ -1,13 +1,23 @@
1
1
  import { bytesEqual } from "./bytes.js";
2
- import { decodeInteger, decodeOid, readChildren, readElement, readSequenceChildren, TAG } from "./der.js";
2
+ import { decodeInteger, decodeOid, readElement, readSequenceChildren, TAG } from "./der.js";
3
3
  import { importPublicKeySpki } from "./crypto.js";
4
4
  import { OID } from "./oids.js";
5
+ const EMPTY_KEY_USAGE = {
6
+ digitalSignature: false,
7
+ contentCommitment: false,
8
+ keyCertSign: false,
9
+ cRLSign: false
10
+ };
11
+ const KNOWN_EXTENSION_OIDS = new Set([
12
+ OID.basicConstraints,
13
+ OID.keyUsage,
14
+ OID.extendedKeyUsage,
15
+ OID.subjectKeyIdentifier,
16
+ OID.authorityKeyIdentifier
17
+ ]);
5
18
  // Read just the SubjectPublicKeyInfo DER from a v3 X.509 certificate without
6
19
  // importing the public key. Used by exportPkcs12, which needs the SPKI for
7
- // localKeyId computation (SHA-1 of the BIT STRING value) regardless of the
8
- // inner key algorithm. parseCertificateDer below additionally imports the
9
- // SPKI as an ECDSA CryptoKey for the issuance/verify paths; that import is
10
- // EC-only and must not be on the pkcs12 path.
20
+ // localKeyId computation regardless of the inner key algorithm.
11
21
  export function extractCertificateSpkiDer(der) {
12
22
  const certificate = readElement(der);
13
23
  if (certificate.tag !== TAG.SEQUENCE || certificate.end !== der.length) {
@@ -18,14 +28,7 @@ export function extractCertificateSpkiDer(der) {
18
28
  throw new Error("Invalid certificate structure");
19
29
  }
20
30
  const tbsChildren = readSequenceChildren(tbsCertificate);
21
- const versionTag = tbsChildren[0];
22
- if (!versionTag || versionTag.tag !== 0xa0) {
23
- throw new Error("Unsupported X.509 version (only v3 is supported)");
24
- }
25
- const versionInner = readElement(versionTag.value);
26
- if (versionInner.tag !== TAG.INTEGER || decodeInteger(versionInner.value) !== 2n) {
27
- throw new Error("Unsupported X.509 version (only v3 is supported)");
28
- }
31
+ assertV3(tbsChildren[0]);
29
32
  // tbsCertificate (v3) layout:
30
33
  // [0] version [0] EXPLICIT INTEGER 2
31
34
  // [1] serialNumber
@@ -40,54 +43,96 @@ export function extractCertificateSpkiDer(der) {
40
43
  }
41
44
  return subjectPublicKeyInfo.raw;
42
45
  }
46
+ // Compatibility parser used by issuance/import paths. It preserves the
47
+ // historical behavior for unusual-but-parseable imported certificates.
43
48
  export async function parseCertificateDer(der) {
49
+ // Await here (rather than returning the inner promise directly) so runtimes
50
+ // with eager unhandled-rejection tracking observe this wrapper as the
51
+ // rejection owner for synchronous parse failures.
52
+ return await parseCertificateDerInternal(der, false);
53
+ }
54
+ // Strict parser used only by the public verification surface. It reads the
55
+ // certificate's own validity and rejects malformed X.509 structures while
56
+ // reporting policy failures (duplicates/unknown critical extensions) as data.
57
+ export async function parseCertificateDerForVerification(der) {
58
+ const parsed = await parseCertificateDerInternal(der, true);
59
+ if (parsed.notBeforeMs === undefined || parsed.notAfterMs === undefined) {
60
+ throw new Error("Invalid certificate validity");
61
+ }
62
+ return parsed;
63
+ }
64
+ async function parseCertificateDerInternal(der, strict) {
44
65
  const certificate = readElement(der);
45
66
  if (certificate.tag !== TAG.SEQUENCE || certificate.end !== der.length) {
46
67
  throw new Error("Invalid certificate DER");
47
68
  }
48
- const [tbsCertificate, signatureAlgorithm, signatureValue] = readSequenceChildren(certificate);
69
+ const certificateChildren = readSequenceChildren(certificate);
70
+ if (strict && certificateChildren.length !== 3) {
71
+ throw new Error("Invalid certificate structure");
72
+ }
73
+ const [tbsCertificate, signatureAlgorithm, signatureValue] = certificateChildren;
49
74
  if (!tbsCertificate || !signatureAlgorithm || !signatureValue) {
50
75
  throw new Error("Invalid certificate structure");
51
76
  }
52
- if (signatureValue.tag !== TAG.BIT_STRING || signatureValue.value[0] !== 0) {
77
+ if (tbsCertificate.tag !== TAG.SEQUENCE || signatureAlgorithm.tag !== TAG.SEQUENCE) {
78
+ throw new Error("Invalid certificate structure");
79
+ }
80
+ if (signatureValue.tag !== TAG.BIT_STRING || signatureValue.value.length < 1 || signatureValue.value[0] !== 0) {
53
81
  throw new Error("Invalid certificate signature value");
54
82
  }
55
83
  const tbsChildren = readSequenceChildren(tbsCertificate);
56
- // RFC 5280 §4.1.2.1: version is [0] EXPLICIT INTEGER. EdgCA only emits and
57
- // accepts v3 (= INTEGER 2); v1 (field omitted) and v2 (INTEGER 1) are rejected.
58
- // See docs/NON_GOALS.md §4.
59
- const versionTag = tbsChildren[0];
60
- if (!versionTag || versionTag.tag !== 0xa0) {
61
- throw new Error("Unsupported X.509 version (only v3 is supported)");
62
- }
63
- const versionInner = readElement(versionTag.value);
64
- if (versionInner.tag !== TAG.INTEGER || decodeInteger(versionInner.value) !== 2n) {
65
- throw new Error("Unsupported X.509 version (only v3 is supported)");
84
+ assertV3(tbsChildren[0]);
85
+ if (strict && tbsChildren.length < 7) {
86
+ throw new Error("Invalid certificate TBSCertificate structure");
66
87
  }
67
- let index = 1;
68
- index += 1; // serialNumber
69
- index += 1; // signature
70
- const issuer = tbsChildren[index++];
71
- index += 1; // validity
72
- const subject = tbsChildren[index++];
73
- const subjectPublicKeyInfo = tbsChildren[index++];
74
- if (!issuer || !subject || !subjectPublicKeyInfo) {
88
+ const tbsSignatureAlgorithm = tbsChildren[2];
89
+ const issuer = tbsChildren[3];
90
+ const validity = tbsChildren[4];
91
+ const subject = tbsChildren[5];
92
+ const subjectPublicKeyInfo = tbsChildren[6];
93
+ if (!tbsSignatureAlgorithm || tbsSignatureAlgorithm.tag !== TAG.SEQUENCE ||
94
+ !issuer || issuer.tag !== TAG.SEQUENCE ||
95
+ !validity || validity.tag !== TAG.SEQUENCE ||
96
+ !subject || subject.tag !== TAG.SEQUENCE ||
97
+ !subjectPublicKeyInfo || subjectPublicKeyInfo.tag !== TAG.SEQUENCE) {
75
98
  throw new Error("Invalid certificate TBSCertificate structure");
76
99
  }
77
- const extensions = tbsChildren.find((element) => element.tag === 0xa3);
78
- const parsedExtensions = extensions ? parseExtensions(extensions.value) : {};
100
+ const signatureAlgorithmOid = parseAlgorithmIdentifier(signatureAlgorithm, strict);
101
+ const tbsSignatureAlgorithmOid = parseAlgorithmIdentifier(tbsSignatureAlgorithm, strict);
102
+ const extensionFields = tbsChildren.filter((element) => element.tag === 0xa3);
103
+ if (strict && extensionFields.length > 1) {
104
+ throw new Error("Invalid certificate: duplicate extensions field");
105
+ }
106
+ const parsedExtensions = extensionFields[0]
107
+ ? parseExtensions(extensionFields[0].value, strict)
108
+ : emptyParsedExtensions();
79
109
  const publicKey = await importPublicKeySpki(subjectPublicKeyInfo.raw);
110
+ const parsedValidity = strict ? parseValidity(validity) : undefined;
80
111
  const parsed = {
81
112
  der,
82
113
  tbsCertificateDer: tbsCertificate.raw,
83
114
  signatureDer: signatureValue.value.subarray(1),
115
+ signatureAlgorithmOid,
116
+ tbsSignatureAlgorithmOid,
117
+ signatureAlgorithmMatches: bytesEqual(signatureAlgorithm.raw, tbsSignatureAlgorithm.raw),
84
118
  issuerNameDer: issuer.raw,
85
119
  subjectNameDer: subject.raw,
86
120
  subjectPublicKeyInfoDer: subjectPublicKeyInfo.raw,
87
121
  publicKey,
122
+ basicConstraintsPresent: parsedExtensions.basicConstraintsPresent,
88
123
  isCA: parsedExtensions.isCA ?? false,
89
- keyCertSign: parsedExtensions.keyCertSign ?? false
124
+ keyUsagePresent: parsedExtensions.keyUsagePresent,
125
+ keyUsage: parsedExtensions.keyUsage ?? { ...EMPTY_KEY_USAGE },
126
+ keyCertSign: parsedExtensions.keyUsage?.keyCertSign ?? false,
127
+ extendedKeyUsagePresent: parsedExtensions.extendedKeyUsagePresent,
128
+ extendedKeyUsageOids: parsedExtensions.extendedKeyUsageOids ?? [],
129
+ duplicateExtensionOids: parsedExtensions.duplicateExtensionOids,
130
+ unsupportedCriticalExtensionOids: parsedExtensions.unsupportedCriticalExtensionOids
90
131
  };
132
+ if (parsedValidity !== undefined) {
133
+ parsed.notBeforeMs = parsedValidity.notBeforeMs;
134
+ parsed.notAfterMs = parsedValidity.notAfterMs;
135
+ }
91
136
  if (parsedExtensions.pathLenConstraint !== undefined) {
92
137
  parsed.pathLenConstraint = parsedExtensions.pathLenConstraint;
93
138
  }
@@ -104,12 +149,22 @@ export function assertIssuerSubjectMatches(issuer, issued) {
104
149
  throw new Error("Issued certificate issuer does not match CA subject");
105
150
  }
106
151
  }
107
- function parseExtensions(explicitValue) {
152
+ function emptyParsedExtensions() {
153
+ return {
154
+ basicConstraintsPresent: false,
155
+ keyUsagePresent: false,
156
+ extendedKeyUsagePresent: false,
157
+ duplicateExtensionOids: [],
158
+ unsupportedCriticalExtensionOids: []
159
+ };
160
+ }
161
+ function parseExtensions(explicitValue, strict) {
108
162
  const outer = readElement(explicitValue);
109
- if (outer.tag !== TAG.SEQUENCE) {
163
+ if (outer.tag !== TAG.SEQUENCE || (strict && outer.end !== explicitValue.length)) {
110
164
  throw new Error("Invalid extensions structure");
111
165
  }
112
- const parsed = {};
166
+ const parsed = emptyParsedExtensions();
167
+ const seen = new Set();
113
168
  for (const extension of readSequenceChildren(outer)) {
114
169
  const children = readSequenceChildren(extension);
115
170
  const oidElement = children[0];
@@ -117,25 +172,40 @@ function parseExtensions(explicitValue) {
117
172
  throw new Error("Invalid extension OID");
118
173
  }
119
174
  let valueIndex = 1;
175
+ let critical = false;
120
176
  if (children[valueIndex]?.tag === TAG.BOOLEAN) {
177
+ critical = parseBoolean(children[valueIndex], strict);
121
178
  valueIndex += 1;
122
179
  }
123
180
  const value = children[valueIndex];
124
- if (!value || value.tag !== TAG.OCTET_STRING) {
181
+ if (!value || value.tag !== TAG.OCTET_STRING || (strict && children.length !== valueIndex + 1)) {
125
182
  throw new Error("Invalid extension value");
126
183
  }
127
184
  const extensionOid = decodeOid(oidElement.value);
185
+ if (seen.has(extensionOid) && !parsed.duplicateExtensionOids.includes(extensionOid)) {
186
+ parsed.duplicateExtensionOids.push(extensionOid);
187
+ }
188
+ seen.add(extensionOid);
189
+ if (critical && !KNOWN_EXTENSION_OIDS.has(extensionOid)) {
190
+ parsed.unsupportedCriticalExtensionOids.push(extensionOid);
191
+ }
128
192
  if (extensionOid === OID.basicConstraints) {
129
- Object.assign(parsed, parseBasicConstraints(value.value));
193
+ parsed.basicConstraintsPresent = true;
194
+ Object.assign(parsed, parseBasicConstraints(value.value, strict));
130
195
  }
131
196
  else if (extensionOid === OID.keyUsage) {
132
- parsed.keyCertSign = parseKeyUsage(value.value).keyCertSign;
197
+ parsed.keyUsagePresent = true;
198
+ parsed.keyUsage = parseKeyUsage(value.value, strict);
199
+ }
200
+ else if (extensionOid === OID.extendedKeyUsage) {
201
+ parsed.extendedKeyUsagePresent = true;
202
+ parsed.extendedKeyUsageOids = parseExtendedKeyUsage(value.value, strict);
133
203
  }
134
204
  else if (extensionOid === OID.subjectKeyIdentifier) {
135
- parsed.subjectKeyIdentifier = parseOctetString(value.value);
205
+ parsed.subjectKeyIdentifier = parseOctetString(value.value, strict);
136
206
  }
137
207
  else if (extensionOid === OID.authorityKeyIdentifier) {
138
- const keyIdentifier = parseAuthorityKeyIdentifier(value.value);
208
+ const keyIdentifier = parseAuthorityKeyIdentifier(value.value, strict);
139
209
  if (keyIdentifier !== undefined) {
140
210
  parsed.authorityKeyIdentifier = keyIdentifier;
141
211
  }
@@ -143,42 +213,187 @@ function parseExtensions(explicitValue) {
143
213
  }
144
214
  return parsed;
145
215
  }
146
- function parseBasicConstraints(value) {
216
+ function parseAlgorithmIdentifier(element, strict) {
217
+ const children = readSequenceChildren(element);
218
+ const oidElement = children[0];
219
+ if (!oidElement || oidElement.tag !== TAG.OBJECT_IDENTIFIER) {
220
+ throw new Error("Invalid signature AlgorithmIdentifier");
221
+ }
222
+ if (strict && children.length !== 1) {
223
+ throw new Error("ECDSA signature AlgorithmIdentifier must not have parameters");
224
+ }
225
+ return decodeOid(oidElement.value);
226
+ }
227
+ function parseValidity(validity) {
228
+ const children = readSequenceChildren(validity);
229
+ if (children.length !== 2 || !children[0] || !children[1]) {
230
+ throw new Error("Invalid certificate validity");
231
+ }
232
+ const notBeforeMs = parseDerTime(children[0]);
233
+ const notAfterMs = parseDerTime(children[1]);
234
+ if (notBeforeMs > notAfterMs) {
235
+ throw new Error("Invalid certificate validity range");
236
+ }
237
+ return { notBeforeMs, notAfterMs };
238
+ }
239
+ function parseDerTime(element) {
240
+ const text = asciiString(element.value, "certificate time");
241
+ let year;
242
+ let offset;
243
+ if (element.tag === TAG.UTC_TIME) {
244
+ if (!/^\d{12}Z$/.test(text)) {
245
+ throw new Error("Invalid UTCTime");
246
+ }
247
+ const shortYear = Number(text.slice(0, 2));
248
+ year = shortYear >= 50 ? 1900 + shortYear : 2000 + shortYear;
249
+ offset = 2;
250
+ }
251
+ else if (element.tag === TAG.GENERALIZED_TIME) {
252
+ if (!/^\d{14}Z$/.test(text)) {
253
+ throw new Error("Invalid GeneralizedTime");
254
+ }
255
+ year = Number(text.slice(0, 4));
256
+ if (year < 1 || year > 9999) {
257
+ throw new Error("Invalid GeneralizedTime year");
258
+ }
259
+ offset = 4;
260
+ }
261
+ else {
262
+ throw new Error("Unsupported certificate time type");
263
+ }
264
+ const month = Number(text.slice(offset, offset + 2));
265
+ const day = Number(text.slice(offset + 2, offset + 4));
266
+ const hour = Number(text.slice(offset + 4, offset + 6));
267
+ const minute = Number(text.slice(offset + 6, offset + 8));
268
+ const second = Number(text.slice(offset + 8, offset + 10));
269
+ const date = new Date(0);
270
+ date.setUTCFullYear(year, month - 1, day);
271
+ date.setUTCHours(hour, minute, second, 0);
272
+ if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59 ||
273
+ date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 ||
274
+ date.getUTCDate() !== day || date.getUTCHours() !== hour ||
275
+ date.getUTCMinutes() !== minute || date.getUTCSeconds() !== second) {
276
+ throw new Error("Invalid certificate time value");
277
+ }
278
+ return date.getTime();
279
+ }
280
+ function parseBasicConstraints(value, strict) {
147
281
  const root = readElement(value);
282
+ if (root.tag !== TAG.SEQUENCE || (strict && root.end !== value.length)) {
283
+ throw new Error("Invalid basicConstraints extension");
284
+ }
148
285
  const children = readSequenceChildren(root);
149
286
  const result = { isCA: false };
150
- if (children[0]?.tag === TAG.BOOLEAN) {
151
- result.isCA = children[0].value[0] !== 0;
287
+ let index = 0;
288
+ if (children[index]?.tag === TAG.BOOLEAN) {
289
+ result.isCA = parseBoolean(children[index], strict);
290
+ index += 1;
291
+ }
292
+ if (children[index]?.tag === TAG.INTEGER) {
293
+ const decoded = decodeInteger(children[index].value);
294
+ if (decoded > BigInt(Number.MAX_SAFE_INTEGER)) {
295
+ throw new Error("basicConstraints pathLenConstraint is too large");
296
+ }
297
+ result.pathLenConstraint = Number(decoded);
298
+ index += 1;
299
+ }
300
+ if (strict && index !== children.length) {
301
+ throw new Error("Invalid basicConstraints extension");
152
302
  }
153
- const pathLen = children.find((child) => child.tag === TAG.INTEGER);
154
- if (pathLen) {
155
- result.pathLenConstraint = Number(decodeInteger(pathLen.value));
303
+ if (strict && result.pathLenConstraint !== undefined && result.isCA !== true) {
304
+ throw new Error("basicConstraints pathLenConstraint requires CA=true");
156
305
  }
157
306
  return result;
158
307
  }
159
- function parseKeyUsage(value) {
308
+ function parseKeyUsage(value, strict) {
160
309
  const root = readElement(value);
161
- if (root.tag !== TAG.BIT_STRING || root.value.length < 2) {
310
+ if (root.tag !== TAG.BIT_STRING || root.value.length < 2 || (strict && root.end !== value.length)) {
162
311
  throw new Error("Invalid keyUsage extension");
163
312
  }
313
+ const unusedBits = root.value[0];
164
314
  const bytes = root.value.subarray(1);
315
+ if (unusedBits > 7) {
316
+ throw new Error("Invalid keyUsage unused bits");
317
+ }
318
+ if (strict && unusedBits > 0 && (bytes[bytes.length - 1] & ((1 << unusedBits) - 1)) !== 0) {
319
+ throw new Error("Invalid keyUsage padding bits");
320
+ }
165
321
  return {
166
- keyCertSign: (bytes[0] & 0x04) !== 0
322
+ digitalSignature: keyUsageBit(bytes, 0),
323
+ contentCommitment: keyUsageBit(bytes, 1),
324
+ keyCertSign: keyUsageBit(bytes, 5),
325
+ cRLSign: keyUsageBit(bytes, 6)
167
326
  };
168
327
  }
169
- function parseOctetString(value) {
328
+ function keyUsageBit(bytes, bit) {
329
+ const byte = bytes[Math.floor(bit / 8)];
330
+ return byte !== undefined && (byte & (0x80 >> (bit % 8))) !== 0;
331
+ }
332
+ function parseExtendedKeyUsage(value, strict) {
170
333
  const root = readElement(value);
171
- if (root.tag !== TAG.OCTET_STRING) {
334
+ if (root.tag !== TAG.SEQUENCE || (strict && root.end !== value.length)) {
335
+ throw new Error("Invalid extendedKeyUsage extension");
336
+ }
337
+ const children = readSequenceChildren(root);
338
+ if (strict && children.length === 0) {
339
+ throw new Error("extendedKeyUsage must contain at least one purpose");
340
+ }
341
+ return children.map((child) => {
342
+ if (child.tag !== TAG.OBJECT_IDENTIFIER) {
343
+ throw new Error("Invalid extendedKeyUsage purpose");
344
+ }
345
+ return decodeOid(child.value);
346
+ });
347
+ }
348
+ function parseOctetString(value, strict) {
349
+ const root = readElement(value);
350
+ if (root.tag !== TAG.OCTET_STRING || (strict && root.end !== value.length)) {
172
351
  throw new Error("Invalid OCTET STRING extension payload");
173
352
  }
174
353
  return root.value;
175
354
  }
176
- function parseAuthorityKeyIdentifier(value) {
355
+ function parseAuthorityKeyIdentifier(value, strict) {
177
356
  const root = readElement(value);
357
+ if (root.tag !== TAG.SEQUENCE || (strict && root.end !== value.length)) {
358
+ throw new Error("Invalid authorityKeyIdentifier extension");
359
+ }
360
+ let keyIdentifier;
178
361
  for (const child of readSequenceChildren(root)) {
179
362
  if (child.tag === 0x80) {
180
- return child.value;
363
+ if (strict && keyIdentifier !== undefined) {
364
+ throw new Error("Duplicate authorityKeyIdentifier keyIdentifier");
365
+ }
366
+ keyIdentifier = child.value;
367
+ }
368
+ }
369
+ return keyIdentifier;
370
+ }
371
+ function parseBoolean(element, strict) {
372
+ if (element.value.length !== 1) {
373
+ throw new Error("Invalid BOOLEAN");
374
+ }
375
+ if (strict && element.value[0] !== 0x00 && element.value[0] !== 0xff) {
376
+ throw new Error("Invalid DER BOOLEAN value");
377
+ }
378
+ return element.value[0] !== 0;
379
+ }
380
+ function assertV3(versionTag) {
381
+ if (!versionTag || versionTag.tag !== 0xa0) {
382
+ throw new Error("Unsupported X.509 version (only v3 is supported)");
383
+ }
384
+ const versionInner = readElement(versionTag.value);
385
+ if (versionInner.tag !== TAG.INTEGER || versionInner.end !== versionTag.value.length ||
386
+ decodeInteger(versionInner.value) !== 2n) {
387
+ throw new Error("Unsupported X.509 version (only v3 is supported)");
388
+ }
389
+ }
390
+ function asciiString(bytes, name) {
391
+ let out = "";
392
+ for (const byte of bytes) {
393
+ if (byte > 0x7f) {
394
+ throw new Error(`Invalid non-ASCII ${name}`);
181
395
  }
396
+ out += String.fromCharCode(byte);
182
397
  }
183
- return undefined;
398
+ return out;
184
399
  }
package/dist/verify.d.ts CHANGED
@@ -9,38 +9,43 @@ export interface VerifyClientCertificateIssuedByOptions {
9
9
  certPem: string;
10
10
  validity?: VerifyClientCertificateValidity;
11
11
  }
12
+ export interface VerifyCertificateIssuedByOptions {
13
+ certificatePem: string;
14
+ issuerCertificatePem: string;
15
+ at?: Date | number;
16
+ }
17
+ export type CertificateVerificationPurpose = "ca" | "clientAuth" | "documentSigning";
18
+ export interface VerifyCertificateChainOptions {
19
+ certificatePem: string;
20
+ intermediateCertificatesPem?: readonly string[];
21
+ trustedRootCertificatesPem: readonly string[];
22
+ at?: Date | number;
23
+ purpose?: CertificateVerificationPurpose;
24
+ }
25
+ export type CertificateVerificationFailureReason = "not-yet-valid" | "expired" | "issuer-name-mismatch" | "key-identifier-mismatch" | "invalid-signature" | "issuer-not-ca" | "issuer-key-usage-invalid" | "path-length-exceeded" | "target-profile-invalid" | "invalid-chain-order" | "duplicate-extension" | "signature-algorithm-mismatch" | "unsupported-critical-extension" | "untrusted-root";
26
+ export type CertificateChainVerificationResult = {
27
+ valid: true;
28
+ trustedRootIndex: number;
29
+ } | {
30
+ valid: false;
31
+ reason: CertificateVerificationFailureReason;
32
+ certificateIndex: number;
33
+ };
12
34
  /**
13
- * Confirms that `options.certPem` was issued by `options.ca`.
14
- *
15
- * This is **not** mTLS verification, and does not even attempt to be.
16
- * At most it is *issuance verification*: "the presented certificate was
17
- * issued by the specified CA" — which is **not** the same as authenticating
18
- * that the presenter is the certificate's legitimate owner.
19
- *
20
- * A client certificate is, by design, presentable to anyone, and its
21
- * contents are trivially copyable. You must assume that anyone could be
22
- * holding a valid copy. Therefore possession of valid certificate data
23
- * **never** proves legitimate ownership.
24
- *
25
- * Proving legitimate ownership additionally requires verifying possession
26
- * of the corresponding private key (a signature made by it, verified
27
- * against the certificate's public key). The TLS handshake's
28
- * `CertificateVerify` message normally provides this, but the Cloudflare
29
- * Workers runtime does not expose that signature to the application. On
30
- * non-Enterprise plans, Cloudflare's TLS layer also does not know about
31
- * your self-managed CA, so `request.cf.tlsClientAuth.certVerified` will
32
- * not be `"SUCCESS"` for certificates EdgCA issued. Application code on
33
- * Workers (Enterprise excluded) has no way to verify proof-of-possession.
34
- *
35
- * Implication: anyone who has obtained a copy of a valid certificate
36
- * (logs, leaked storage, network capture, etc.) can present it and pass
37
- * this check. Use this as a minimum identity-check layer, not as
38
- * authentication. For real authentication, use Cloudflare Enterprise mTLS
39
- * at the TLS layer, or add an application-layer challenge-response that
40
- * has the client sign a server-issued nonce with its private key.
35
+ * Verifies one certificate/issuer link using public certificate material only.
36
+ * This is not proof that a presenter possesses the certificate's private key.
37
+ */
38
+ export declare function verifyCertificateIssuedBy(options: VerifyCertificateIssuedByOptions): Promise<boolean>;
39
+ /**
40
+ * Verifies a caller-ordered chain ending at one explicitly supplied trust
41
+ * anchor. It intentionally does not perform PKI path building or revocation.
42
+ */
43
+ export declare function verifyCertificateChain(options: VerifyCertificateChainOptions): Promise<CertificateChainVerificationResult>;
44
+ /**
45
+ * Legacy compatibility API. Its external validity option and direct-issuer
46
+ * semantics intentionally remain unchanged.
41
47
  *
42
- * Also out of scope (not checked here): `BasicConstraints CA=false`,
43
- * `EKU clientAuth`, revocation, and chain walking.
48
+ * This is not mTLS verification and does not authenticate the presenter.
44
49
  */
45
50
  export declare function verifyClientCertificateIssuedBy(options: VerifyClientCertificateIssuedByOptions): Promise<boolean>;
46
51
  //# sourceMappingURL=verify.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,QAAQ,EAAE,IAAI,GAAG,MAAM,CAAC;IACxB,GAAG,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sCAAsC;IACrD,EAAE,EAAE,oBAAoB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,+BAA+B,CAAC;CAC5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,sCAAsC,GAC9C,OAAO,CAAC,OAAO,CAAC,CAwBlB"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,QAAQ,EAAE,IAAI,GAAG,MAAM,CAAC;IACxB,GAAG,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sCAAsC;IACrD,EAAE,EAAE,oBAAoB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,+BAA+B,CAAC;CAC5C;AAED,MAAM,WAAW,gCAAgC;IAC/C,cAAc,EAAE,MAAM,CAAC;IACvB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,EAAE,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,8BAA8B,GAAG,IAAI,GAAG,YAAY,GAAG,iBAAiB,CAAC;AAErF,MAAM,WAAW,6BAA6B;IAC5C,cAAc,EAAE,MAAM,CAAC;IACvB,2BAA2B,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChD,0BAA0B,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,EAAE,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,8BAA8B,CAAC;CAC1C;AAED,MAAM,MAAM,oCAAoC,GAC5C,eAAe,GACf,SAAS,GACT,sBAAsB,GACtB,yBAAyB,GACzB,mBAAmB,GACnB,eAAe,GACf,0BAA0B,GAC1B,sBAAsB,GACtB,wBAAwB,GACxB,qBAAqB,GACrB,qBAAqB,GACrB,8BAA8B,GAC9B,gCAAgC,GAChC,gBAAgB,CAAC;AAErB,MAAM,MAAM,kCAAkC,GAC1C;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAA;CAAE,GACzC;IACE,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,oCAAoC,CAAC;IAC7C,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAAC;AAIN;;;GAGG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,OAAO,CAAC,CAUlB;AAED;;;GAGG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,kCAAkC,CAAC,CA2F7C;AAED;;;;;GAKG;AACH,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,sCAAsC,GAC9C,OAAO,CAAC,OAAO,CAAC,CAwBlB"}
package/dist/verify.js CHANGED
@@ -1,39 +1,113 @@
1
1
  import { bytesEqual } from "./bytes.js";
2
- import { keyIdentifierFromSpki, verifyDer } from "./crypto.js";
2
+ import { curveOf, keyIdentifierFromSpki, signatureAlgorithmOidForCurve, verifyDer } from "./crypto.js";
3
+ import { OID } from "./oids.js";
3
4
  import { pemToDerWithLabel } from "./pem.js";
4
- import { parseCertificateDer } from "./parser.js";
5
+ import { parseCertificateDer, parseCertificateDerForVerification } from "./parser.js";
5
6
  /**
6
- * Confirms that `options.certPem` was issued by `options.ca`.
7
- *
8
- * This is **not** mTLS verification, and does not even attempt to be.
9
- * At most it is *issuance verification*: "the presented certificate was
10
- * issued by the specified CA" — which is **not** the same as authenticating
11
- * that the presenter is the certificate's legitimate owner.
12
- *
13
- * A client certificate is, by design, presentable to anyone, and its
14
- * contents are trivially copyable. You must assume that anyone could be
15
- * holding a valid copy. Therefore possession of valid certificate data
16
- * **never** proves legitimate ownership.
17
- *
18
- * Proving legitimate ownership additionally requires verifying possession
19
- * of the corresponding private key (a signature made by it, verified
20
- * against the certificate's public key). The TLS handshake's
21
- * `CertificateVerify` message normally provides this, but the Cloudflare
22
- * Workers runtime does not expose that signature to the application. On
23
- * non-Enterprise plans, Cloudflare's TLS layer also does not know about
24
- * your self-managed CA, so `request.cf.tlsClientAuth.certVerified` will
25
- * not be `"SUCCESS"` for certificates EdgCA issued. Application code on
26
- * Workers (Enterprise excluded) has no way to verify proof-of-possession.
27
- *
28
- * Implication: anyone who has obtained a copy of a valid certificate
29
- * (logs, leaked storage, network capture, etc.) can present it and pass
30
- * this check. Use this as a minimum identity-check layer, not as
31
- * authentication. For real authentication, use Cloudflare Enterprise mTLS
32
- * at the TLS layer, or add an application-layer challenge-response that
33
- * has the client sign a server-issued nonce with its private key.
7
+ * Verifies one certificate/issuer link using public certificate material only.
8
+ * This is not proof that a presenter possesses the certificate's private key.
9
+ */
10
+ export async function verifyCertificateIssuedBy(options) {
11
+ assertObject(options, "options");
12
+ const at = toEpochMs(options.at ?? Date.now(), "at");
13
+ const certificate = await parseVerificationCertificate(options.certificatePem, "certificatePem");
14
+ const issuer = await parseVerificationCertificate(options.issuerCertificatePem, "issuerCertificatePem");
15
+ if (certificatePolicyFailure(certificate, at, 0))
16
+ return false;
17
+ if (certificatePolicyFailure(issuer, at, 1))
18
+ return false;
19
+ if (issuerConstraintFailure(issuer, 1))
20
+ return false;
21
+ return (await linkFailure(certificate, issuer, 0)) === undefined;
22
+ }
23
+ /**
24
+ * Verifies a caller-ordered chain ending at one explicitly supplied trust
25
+ * anchor. It intentionally does not perform PKI path building or revocation.
26
+ */
27
+ export async function verifyCertificateChain(options) {
28
+ assertObject(options, "options");
29
+ const at = toEpochMs(options.at ?? Date.now(), "at");
30
+ const intermediatePems = options.intermediateCertificatesPem ?? [];
31
+ if (!Array.isArray(intermediatePems)) {
32
+ throw new Error("intermediateCertificatesPem must be an array");
33
+ }
34
+ if (intermediatePems.length > 1) {
35
+ throw new Error("intermediateCertificatesPem supports at most one certificate");
36
+ }
37
+ if (!Array.isArray(options.trustedRootCertificatesPem) || options.trustedRootCertificatesPem.length === 0) {
38
+ throw new Error("trustedRootCertificatesPem must be a non-empty array");
39
+ }
40
+ if (options.purpose !== undefined &&
41
+ options.purpose !== "ca" &&
42
+ options.purpose !== "clientAuth" &&
43
+ options.purpose !== "documentSigning") {
44
+ throw new Error("purpose must be ca, clientAuth, or documentSigning");
45
+ }
46
+ const target = await parseVerificationCertificate(options.certificatePem, "certificatePem");
47
+ const intermediates = await Promise.all(intermediatePems.map((pem, index) => parseVerificationCertificate(pem, `intermediateCertificatesPem[${index}]`)));
48
+ const roots = await Promise.all(options.trustedRootCertificatesPem.map((pem, index) => parseVerificationCertificate(pem, `trustedRootCertificatesPem[${index}]`)));
49
+ const path = [target, ...intermediates];
50
+ for (let index = 0; index < intermediates.length; index += 1) {
51
+ const intermediate = intermediates[index];
52
+ if (bytesEqual(intermediate.subjectNameDer, intermediate.issuerNameDer)) {
53
+ return failure("invalid-chain-order", index + 1);
54
+ }
55
+ }
56
+ for (let index = 0; index < path.length; index += 1) {
57
+ const policyFailure = certificatePolicyFailure(path[index], at, index);
58
+ if (policyFailure)
59
+ return policyFailure;
60
+ }
61
+ const profileFailure = targetProfileFailure(target, options.purpose);
62
+ if (profileFailure)
63
+ return failure(profileFailure, 0);
64
+ for (let childIndex = 0; childIndex < path.length - 1; childIndex += 1) {
65
+ const issuerIndex = childIndex + 1;
66
+ const issuer = path[issuerIndex];
67
+ const issuerFailure = issuerConstraintFailure(issuer, issuerIndex);
68
+ if (issuerFailure)
69
+ return issuerFailure;
70
+ const pathFailure = pathLengthFailure(issuer, path.slice(0, issuerIndex), issuerIndex);
71
+ if (pathFailure)
72
+ return pathFailure;
73
+ const relationshipFailure = await linkFailure(path[childIndex], issuer, childIndex);
74
+ if (relationshipFailure)
75
+ return relationshipFailure;
76
+ }
77
+ const terminalChild = path[path.length - 1];
78
+ const rootIndexInPath = path.length;
79
+ const candidateRootIndexes = [];
80
+ for (let index = 0; index < roots.length; index += 1) {
81
+ if (await issuerIdentityMatches(terminalChild, roots[index])) {
82
+ candidateRootIndexes.push(index);
83
+ }
84
+ }
85
+ if (candidateRootIndexes.length === 0) {
86
+ return failure("untrusted-root", rootIndexInPath);
87
+ }
88
+ let firstCandidateFailure;
89
+ for (const trustedRootIndex of candidateRootIndexes) {
90
+ const root = roots[trustedRootIndex];
91
+ const checks = [
92
+ certificatePolicyFailure(root, at, rootIndexInPath),
93
+ issuerConstraintFailure(root, rootIndexInPath),
94
+ pathLengthFailure(root, path, rootIndexInPath),
95
+ await linkFailure(terminalChild, root, path.length - 1),
96
+ await rootIntegrityFailure(root, rootIndexInPath)
97
+ ];
98
+ const candidateFailure = checks.find((check) => check !== undefined);
99
+ if (!candidateFailure) {
100
+ return { valid: true, trustedRootIndex };
101
+ }
102
+ firstCandidateFailure ??= candidateFailure;
103
+ }
104
+ return firstCandidateFailure ?? failure("untrusted-root", rootIndexInPath);
105
+ }
106
+ /**
107
+ * Legacy compatibility API. Its external validity option and direct-issuer
108
+ * semantics intentionally remain unchanged.
34
109
  *
35
- * Also out of scope (not checked here): `BasicConstraints CA=false`,
36
- * `EKU clientAuth`, revocation, and chain walking.
110
+ * This is not mTLS verification and does not authenticate the presenter.
37
111
  */
38
112
  export async function verifyClientCertificateIssuedBy(options) {
39
113
  if (options.validity && !isWithinValidity(options.validity)) {
@@ -55,6 +129,116 @@ export async function verifyClientCertificateIssuedBy(options) {
55
129
  }
56
130
  return verifyDer(options.ca.publicKey, cert.signatureDer, cert.tbsCertificateDer);
57
131
  }
132
+ async function parseVerificationCertificate(pem, name) {
133
+ if (typeof pem !== "string" || pem.length === 0) {
134
+ throw new Error(`${name} must be a non-empty certificate PEM string`);
135
+ }
136
+ return parseCertificateDerForVerification(pemToDerWithLabel(pem, "CERTIFICATE"));
137
+ }
138
+ function certificatePolicyFailure(certificate, at, certificateIndex) {
139
+ if (certificate.duplicateExtensionOids.length > 0) {
140
+ return failure("duplicate-extension", certificateIndex);
141
+ }
142
+ if (!certificate.signatureAlgorithmMatches) {
143
+ return failure("signature-algorithm-mismatch", certificateIndex);
144
+ }
145
+ if (certificate.unsupportedCriticalExtensionOids.length > 0) {
146
+ return failure("unsupported-critical-extension", certificateIndex);
147
+ }
148
+ if (at < certificate.notBeforeMs) {
149
+ return failure("not-yet-valid", certificateIndex);
150
+ }
151
+ if (at > certificate.notAfterMs) {
152
+ return failure("expired", certificateIndex);
153
+ }
154
+ return undefined;
155
+ }
156
+ function issuerConstraintFailure(issuer, certificateIndex) {
157
+ if (!issuer.basicConstraintsPresent || !issuer.isCA) {
158
+ return failure("issuer-not-ca", certificateIndex);
159
+ }
160
+ if (!issuer.keyUsagePresent || !issuer.keyUsage.keyCertSign) {
161
+ return failure("issuer-key-usage-invalid", certificateIndex);
162
+ }
163
+ return undefined;
164
+ }
165
+ function pathLengthFailure(issuer, certificatesBelow, certificateIndex) {
166
+ if (issuer.pathLenConstraint === undefined)
167
+ return undefined;
168
+ const caCountBelow = certificatesBelow.filter((certificate) => certificate.isCA).length;
169
+ return caCountBelow > issuer.pathLenConstraint
170
+ ? failure("path-length-exceeded", certificateIndex)
171
+ : undefined;
172
+ }
173
+ function targetProfileFailure(target, purpose) {
174
+ if (purpose === undefined)
175
+ return undefined;
176
+ if (purpose === "ca") {
177
+ return target.basicConstraintsPresent && target.isCA &&
178
+ target.keyUsagePresent && target.keyUsage.keyCertSign
179
+ ? undefined
180
+ : "target-profile-invalid";
181
+ }
182
+ if (!target.basicConstraintsPresent || target.isCA || !target.keyUsagePresent) {
183
+ return "target-profile-invalid";
184
+ }
185
+ if (!target.keyUsage.digitalSignature || !target.extendedKeyUsagePresent) {
186
+ return "target-profile-invalid";
187
+ }
188
+ if (purpose === "clientAuth") {
189
+ return target.extendedKeyUsageOids.includes(OID.clientAuth)
190
+ ? undefined
191
+ : "target-profile-invalid";
192
+ }
193
+ return target.keyUsage.contentCommitment &&
194
+ target.extendedKeyUsageOids.includes(OID.documentSigning)
195
+ ? undefined
196
+ : "target-profile-invalid";
197
+ }
198
+ async function linkFailure(child, issuer, childIndex) {
199
+ if (!bytesEqual(child.issuerNameDer, issuer.subjectNameDer)) {
200
+ return failure("issuer-name-mismatch", childIndex);
201
+ }
202
+ if (!(await keyIdentifierMatches(child, issuer))) {
203
+ return failure("key-identifier-mismatch", childIndex);
204
+ }
205
+ const expectedAlgorithmOid = signatureAlgorithmOidForCurve(curveOf(issuer.publicKey));
206
+ if (child.signatureAlgorithmOid !== expectedAlgorithmOid) {
207
+ return failure("signature-algorithm-mismatch", childIndex);
208
+ }
209
+ if (!(await verifyDer(issuer.publicKey, child.signatureDer, child.tbsCertificateDer))) {
210
+ return failure("invalid-signature", childIndex);
211
+ }
212
+ return undefined;
213
+ }
214
+ async function issuerIdentityMatches(child, issuer) {
215
+ return bytesEqual(child.issuerNameDer, issuer.subjectNameDer) &&
216
+ await keyIdentifierMatches(child, issuer);
217
+ }
218
+ async function keyIdentifierMatches(child, issuer) {
219
+ if (!child.authorityKeyIdentifier || !issuer.subjectKeyIdentifier)
220
+ return false;
221
+ return bytesEqual(child.authorityKeyIdentifier, issuer.subjectKeyIdentifier);
222
+ }
223
+ async function rootIntegrityFailure(root, certificateIndex) {
224
+ if (!bytesEqual(root.issuerNameDer, root.subjectNameDer)) {
225
+ return failure("issuer-name-mismatch", certificateIndex);
226
+ }
227
+ if (!(await keyIdentifierMatches(root, root))) {
228
+ return failure("key-identifier-mismatch", certificateIndex);
229
+ }
230
+ const expectedAlgorithmOid = signatureAlgorithmOidForCurve(curveOf(root.publicKey));
231
+ if (root.signatureAlgorithmOid !== expectedAlgorithmOid) {
232
+ return failure("signature-algorithm-mismatch", certificateIndex);
233
+ }
234
+ if (!(await verifyDer(root.publicKey, root.signatureDer, root.tbsCertificateDer))) {
235
+ return failure("invalid-signature", certificateIndex);
236
+ }
237
+ return undefined;
238
+ }
239
+ function failure(reason, certificateIndex) {
240
+ return { valid: false, reason, certificateIndex };
241
+ }
58
242
  function isWithinValidity(validity) {
59
243
  const notBefore = toEpochMs(validity.notBefore, "validity.notBefore");
60
244
  const notAfter = toEpochMs(validity.notAfter, "validity.notAfter");
@@ -71,3 +255,8 @@ function toEpochMs(value, name) {
71
255
  }
72
256
  return ms;
73
257
  }
258
+ function assertObject(value, name) {
259
+ if (typeof value !== "object" || value === null) {
260
+ throw new Error(`${name} must be an object`);
261
+ }
262
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@noz-ele/edgca",
3
- "version": "0.5.2",
4
- "description": "A small Cloudflare Workers-friendly toolkit for issuing self-managed CA and mTLS client certificates.",
3
+ "version": "0.6.0",
4
+ "description": "A small Cloudflare Workers-friendly toolkit for issuing and validating bounded self-managed CA chains.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "NOZ-ELE",
@@ -46,6 +46,14 @@
46
46
  "./pkcs12": {
47
47
  "types": "./dist/pkcs12.d.ts",
48
48
  "import": "./dist/pkcs12.js"
49
+ },
50
+ "./issuer": {
51
+ "types": "./dist/issuer.d.ts",
52
+ "import": "./dist/issuer.js"
53
+ },
54
+ "./verify": {
55
+ "types": "./dist/verify.d.ts",
56
+ "import": "./dist/verify.js"
49
57
  }
50
58
  },
51
59
  "types": "./dist/index.d.ts",