@noz-ele/edgca 0.2.0 → 0.3.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
@@ -1,351 +1,385 @@
1
- # EdgCA
2
-
3
- > [日本語](https://github.com/noz-ele/EdgCA/blob/main/docs/jp/README.md) | English
4
-
5
- EdgCA is a small TypeScript library that issues mTLS client certificates from a self-managed CA on Cloudflare Workers-compatible runtimes.
6
-
7
- The scope is intentionally narrow:
8
-
9
- - Create a self-signed root CA.
10
- - Issue an intermediate CA from a root CA.
11
- - Issue an mTLS client certificate and private key from an intermediate CA.
12
- - Issue an mTLS client certificate from a caller-provided public key (no private key returned). Pairs with the CSR helpers below.
13
- - Parse a PKCS#10 CSR (subject, requested SAN, public key, raw extensions/attributes) and verify its proof-of-possession signature.
14
- - Decide whether a received client certificate was issued by your own CA.
15
- - Encode/decode certificates as PEM/DER. Keys are exchanged as `CryptoKey` only — the library never returns or accepts string forms (PEM, JWK, etc.) of private keys.
16
- - Delegate all cryptographic operations to `globalThis.crypto.subtle`.
17
-
18
- ECDSA on **NIST P-256, P-384, and P-521** is supported throughout (signing, verification, CSR parsing). RSA, EdDSA, and other curves are intentionally out of scope.
19
-
20
- > ⚠ **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).
21
-
22
- ## Status
23
-
24
- EdgCA is in **v0.2.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.
25
-
26
- ## Install
27
-
28
- ```sh
29
- npm install @noz-ele/edgca
30
- ```
31
-
32
- 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.
33
-
34
- ## Quick Start
35
-
36
- ```ts
37
- import {
38
- createRootCA,
39
- issueIntermediateCA,
40
- issueClientCert
41
- } from "@noz-ele/edgca";
42
-
43
- const root = await createRootCA({
44
- subject: [{ type: "CN", value: "dev-root" }],
45
- days: 3650
46
- });
47
-
48
- const intermediate = await issueIntermediateCA({
49
- ca: root,
50
- subject: [{ type: "CN", value: "dev-intermediate" }],
51
- days: 365
52
- });
53
-
54
- const client = await issueClientCert({
55
- ca: intermediate,
56
- subject: [
57
- { type: "CN", value: "worker-client" },
58
- { type: "UID", value: "worker-001" }
59
- ],
60
- days: 30
61
- });
62
-
63
- // Persist these via your secrets manager / KV / vault.
64
- // `client.privateKey` is a CryptoKey. To persist it, export with
65
- // crypto.subtle.exportKey("pkcs8", client.privateKey) (or another form)
66
- // and treat the resulting bytes as a secret — never log or transmit them.
67
- // client.certPem — public certificate
68
- // client.certChainPem — full chain to present during mTLS
69
- // client.privateKey — secret CryptoKey, hand off only over a trusted channel
70
- ```
71
-
72
- The basic shape is:
73
-
74
- ```text
75
- root CA -> intermediate CA -> mTLS client certificate
76
- ```
77
-
78
- This is the deepest CA hierarchy EdgCA targets. Issuing further intermediate CAs from an intermediate is out of scope.
79
-
80
- `client.certChainPem` is concatenated in this order:
81
-
82
- ```text
83
- client certificate
84
- issuer certificate
85
- issuer chain
86
- ```
87
-
88
- For a client certificate issued by an EdgCA-built intermediate, the result is `client + intermediate + root`.
89
-
90
- ## Verify (Cloudflare Worker)
91
-
92
- > ⚠ **What this is — and is not**
93
- >
94
- > `verifyClientCertificateIssuedBy` is **not** mTLS verification. (Real mTLS verification does not exist for a self-managed CA on Cloudflare Workers in the first place.) At most it is *issuance verification*: it confirms that the presented certificate was issued by the specified CA. **That is not the same as authenticating that the presenter is the certificate's legitimate owner.**
95
- >
96
- > A client certificate is, by design, presentable to anyone, and its contents are trivially copyable. You must assume that anyone can be holding a valid copy. Therefore possession of valid certificate data **never** proves legitimate ownership.
97
- >
98
- > Proving legitimate ownership additionally requires verifying possession of the corresponding private key i.e., a signature made by the private key, verified against the certificate's public key. The TLS handshake's `CertificateVerify` message normally does this, but **the Cloudflare Workers runtime does not expose that signature to the application.** On non-Enterprise plans, Cloudflare's TLS layer also does not know about your self-managed CA, so `request.cf.tlsClientAuth.certVerified` will not be `"SUCCESS"` for certificates EdgCA issued. Workers application code (Enterprise plans excluded) has no way to verify proof-of-possession.
99
- >
100
- > Implication: an attacker who has obtained a copy of a valid certificate (logs, leaked storage, network capture, etc.) can present it and pass this check. Use this function as a *minimum* identity-check layer, not as authentication. For real authentication, either (a) use Cloudflare Enterprise with mTLS configured at the TLS layer (Cloudflare validates the handshake signature against your CA), or (b) add an application-layer challenge-response that has the client sign a server-issued nonce with its private key.
101
- >
102
- > Also out of scope (not checked by this function): `BasicConstraints CA=false`, `EKU clientAuth`, revocation, and chain walking.
103
-
104
- 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.
105
-
106
- ### Formats Cloudflare exposes after extraction
107
-
108
- | field | format | example |
109
- | --- | --- | --- |
110
- | `certPresented` | whether a client cert was sent | `"1"` / `"0"` |
111
- | `certVerified` | TLS-layer verification status string. **For self-managed CAs on non-Enterprise plans this will not be `"SUCCESS"`** — the TLS layer does not know about your CA. | `"SUCCESS"` / `"FAILED:..."` / `"NONE"` |
112
- | `certRFC9440` | RFC 9440 Structured Field Item (Byte Sequence). Base64 wrapped in `:` | `":MIIB...:"` |
113
- | `certNotBefore` / `certNotAfter` | OpenSSL-style textual format (always GMT). Single-digit day padded with two spaces | `"Dec 24 23:59:59 2025 GMT"` / `"Dec 4 23:59:59 2025 GMT"` |
114
- | `certSubjectDN`, `certIssuerDN`, `certSerial`, etc. | strings | identity extraction |
115
-
116
- `verifyClientCertificateIssuedBy` accepts PEM (`certPem: string`) and `Date` / epoch ms (`validity.notBefore` / `notAfter`). Those forms **do not match** what Cloudflare provides, so the application must convert:
117
-
118
- - `certRFC9440` (`":...:"`) strip the surrounding colons, wrap with PEM markers.
119
- - `certNotBefore` / `certNotAfter` (textual) → `new Date(...)` (V8 / the Workers runtime parses this format).
120
-
121
- These parsers live in the caller, not in the library, because (a) we do not want to track Cloudflare's output-format changes, (b) we do not want to rely on runtime-dependent `Date.parse` leniency, and (c) the caller already holds the values, so reimplementing them here would be redundant. See [docs/en/NON_GOALS.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/NON_GOALS.md) for the full rationale.
122
-
123
- ### Example
124
-
125
- ```ts
126
- import { importCertificateAuthority, pemToDer, verifyClientCertificateIssuedBy } from "@noz-ele/edgca";
127
-
128
- // At Worker startup: import the CA loaded from your vault once.
129
- // The library accepts the private key as a CryptoKey only — convert from
130
- // whatever persistence format you use (PKCS#8 PEM, JWK, raw bytes, ...).
131
- const pkcs8Der = pemToDer(env.CA_PRIVATE_KEY_PEM);
132
- const privateKey = await crypto.subtle.importKey(
133
- "pkcs8",
134
- pkcs8Der,
135
- { name: "ECDSA", namedCurve: "P-256" },
136
- /* extractable */ false,
137
- ["sign"]
138
- );
139
- const ca = await importCertificateAuthority({
140
- certPem: env.CA_CERT_PEM,
141
- privateKey
142
- });
143
-
144
- export default {
145
- async fetch(request: Request): Promise<Response> {
146
- const tls = request.cf?.tlsClientAuth;
147
- if (!tls || tls.certPresented !== "1") {
148
- return new Response("client certificate required", { status: 401 });
149
- }
150
- // Note: tls.certVerified !== "SUCCESS" is expected for self-managed CAs
151
- // on non-Enterprise plans. The application performs the issuance check below.
152
-
153
- // Convert Cloudflare's formats to the library's formats.
154
- // certRFC9440 (":base64:") -> PEM string
155
- // certNotBefore / certNotAfter -> Date
156
- const b64 = tls.certRFC9440.replace(/^:|:$/g, "");
157
- const certPem = `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`;
158
-
159
- const ok = await verifyClientCertificateIssuedBy({
160
- ca,
161
- certPem,
162
- validity: {
163
- notBefore: new Date(tls.certNotBefore),
164
- notAfter: new Date(tls.certNotAfter)
165
- // omit `now` to use Date.now()
166
- }
167
- });
168
- if (!ok) {
169
- return new Response("not issued by us, or expired", { status: 403 });
170
- }
171
-
172
- // Reminder: passing this check does NOT prove the presenter holds the
173
- // private key. For real authentication, layer a challenge-response
174
- // (nonce signed with the client's private key) on top.
175
-
176
- // Authorization logic: derive identity from cf.tlsClientAuth.certSubjectDN, etc.
177
- return new Response(`hello, ${tls.certSubjectDN}`);
178
- }
179
- };
180
- ```
181
-
182
- ### Notes
183
-
184
- - 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.
185
- - "Not issued by us" and "outside the validity window" return `false`; malformed PEM/DER throws. The two error categories are deliberately split.
186
- - 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.
187
-
188
- ## Issue from a CSR
189
-
190
- When a client manages its own private key and submits a PKCS#10 CSR, EdgCA parses the CSR, verifies its proof-of-possession signature, and issues a certificate that embeds the CSR's public key. The library does **not** auto-adopt the CSR's claimed subject / SAN — the caller passes those explicitly, derived from whatever policy applies in the application layer.
191
-
192
- ```ts
193
- import {
194
- importCertificateAuthority,
195
- issueClientCertForPublicKey,
196
- parseCertificateSigningRequest,
197
- verifyCertificateSigningRequestSignature
198
- } from "@noz-ele/edgca";
199
-
200
- const csr = await parseCertificateSigningRequest(csrPemFromClient);
201
- if (!await verifyCertificateSigningRequestSignature(csr)) {
202
- return new Response("CSR proof-of-possession failed", { status: 400 });
203
- }
204
-
205
- // Application decides what subject and SAN to issue with. The CSR's claimed
206
- // values are available on csr.subject / csr.requestedDnsNames /
207
- // csr.requestedIpAddresses, but treating them as authoritative is a policy
208
- // decision that lives outside EdgCA.
209
- const issued = await issueClientCertForPublicKey({
210
- ca,
211
- publicKey: csr.publicKey,
212
- subject: policyDerivedSubject,
213
- days: 30,
214
- dnsNames: policyDerivedDnsNames
215
- });
216
- // issued has certPem / certDer / certChainPem only no privateKey, because
217
- // the client owns it.
218
- ```
219
-
220
- CSRs signed with anything other than `ecdsa-with-SHA256` / `ecdsa-with-SHA384` / `ecdsa-with-SHA512` are rejected at parse time with an explicit error. CSR-level attributes other than `extensionRequest` are surfaced as raw DER under `csr.otherAttributes` for callers that need them; X.509 extensions other than SAN are surfaced under `csr.requestedExtensions` as `{ oid, critical, valueDer }` for caller-side decoding.
221
-
222
- POP verification proves only that whoever produced the CSR holds the matching private key. **It is not authorization.** Combine it with whatever transport-level (mTLS) and application-level checks make sense for your enrollment flow.
223
-
224
- ## Subject
225
-
226
- Subject only accepts a structured input. DN strings such as `CN=dev-root,O=Example` are not accepted.
227
-
228
- ```ts
229
- const subject = [
230
- { type: "CN", value: "dev-root" },
231
- { type: "O", value: "Example" },
232
- { type: "1.2.3.4.5", value: "custom-value" }
233
- ];
234
- ```
235
-
236
- Supported short names:
237
-
238
- ```text
239
- CN, O, OU, C, ST, L, E, DC, SERIALNUMBER, STREET,
240
- POSTALCODE, TITLE, GIVENNAME, SURNAME, UID
241
- ```
242
-
243
- Dotted OID strings are also accepted. The ASN.1 string type for values is fixed at UTF8String, with `C` as PrintableString. Multi-valued RDNs are out of scope.
244
-
245
- ## Scope
246
-
247
- In scope:
248
-
249
- - ECDSA on NIST P-256 / P-384 / P-521 (paired with SHA-256 / SHA-384 / SHA-512 respectively).
250
- - Key generation, signing, digest, and key import/export via WebCrypto.
251
- - Root CA creation.
252
- - Intermediate CA issuance.
253
- - mTLS client certificate issuance (with internal key generation, or from a caller-provided public key).
254
- - CSR (PKCS#10) parsing and proof-of-possession signature verification.
255
- - Identity check that a cert was issued by your own CA (`verifyClientCertificateIssuedBy`, with optional time-validity check).
256
- - PEM/DER helpers (certificates only — keys are exchanged as `CryptoKey`).
257
- - Basic Constraints, Key Usage, Extended Key Usage, Subject Alternative Name, SKI, AKI.
258
-
259
- Intentionally out of scope:
260
-
261
- - Server certificate issuance.
262
- - Public chain-validation APIs.
263
- - 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`.
264
- - CRL, OCSP, revocation databases, revocation checks.
265
- - Key storage, encryption-at-rest, rotation-state persistence, and integration with KV/D1/R2/Secrets.
266
- - RSA, EdDSA, other elliptic curves (CSRs signed with these algorithms are rejected at parse time).
267
- - A general certificate parsing API (Cloudflare hands you parsed values via `cf.tlsClientAuth.cert*`; the library does not duplicate that).
268
- - Issuance policy decisions (whether to honor a CSR's claimed subject/SAN, deduplicate, etc.) — caller's responsibility.
269
- - DN string parsing.
270
- - Multi-valued RDNs.
271
-
272
- ## Key Handling
273
-
274
- EdgCA exchanges keys as `CryptoKey` only. The library never returns or accepts string forms (PEM, JWK, base64, ...) of private keys, so secret material does not live on the JS string heap at the library boundary. Internally generated keys are extractable so the caller can persist them by calling `crypto.subtle.exportKey` directly, but the choice of persistence format is the caller's.
275
-
276
- EdgCA only handles key generation, signing, and SPKI export of public keys. Where keys are stored, how they are encrypted at rest, how rotation state is persisted, and how they integrate with Cloudflare storage products are all the application's responsibility.
277
-
278
- ### Bringing your own CA key (recommended)
279
-
280
- 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.
281
-
282
- ```ts
283
- // Restore a CryptoKeyPair from whatever persistence format you use.
284
- // Below is one example that converts PKCS#8 PEM stored in a vault.
285
- async function loadKeyPair(label: string): Promise<CryptoKeyPair> {
286
- const pkcs8 = pemToDer(loadFromVault(`${label}-private-pem`));
287
- const privateKey = await crypto.subtle.importKey(
288
- "pkcs8",
289
- pkcs8,
290
- { name: "ECDSA", namedCurve: "P-256" },
291
- /* extractable */ false,
292
- ["sign"]
293
- );
294
- // Derive the matching public key. If you also persist the public key as
295
- // SPKI, import that directly instead of round-tripping through JWK.
296
- const jwk = await crypto.subtle.exportKey("jwk", privateKey);
297
- delete jwk.d;
298
- jwk.key_ops = ["verify"];
299
- const publicKey = await crypto.subtle.importKey(
300
- "jwk",
301
- jwk,
302
- { name: "ECDSA", namedCurve: "P-256" },
303
- true,
304
- ["verify"]
305
- );
306
- return { privateKey, publicKey };
307
- }
308
-
309
- const root = await createRootCA({
310
- subject: [{ type: "CN", value: "dev-root" }],
311
- days: 3650,
312
- keyPair: await loadKeyPair("root")
313
- });
314
-
315
- const intermediate = await issueIntermediateCA({
316
- ca: root,
317
- subject: [{ type: "CN", value: "dev-intermediate" }],
318
- days: 365,
319
- keyPair: await loadKeyPair("intermediate")
320
- });
321
- ```
322
-
323
- Omitting `keyPair` causes the library to generate a key pair internally — convenient for tests and PoCs. Client-certificate keys are intended to be ephemeral, so `issueClientCert` always generates internally.
324
-
325
- ## Development
326
-
327
- ```sh
328
- npm run typecheck
329
- npm run build
330
- npm run test
331
- npm audit
332
- ```
333
-
334
- Tests use `@cloudflare/vitest-pool-workers` to verify WebCrypto behavior on the Workers-compatible runtime.
335
-
336
- ### Property-based tests
337
-
338
- Round-trip invariants in the lower layers are expressed as `fast-check` property-based tests, kept one file per target module under `test/<module>.property.test.ts`.
339
-
340
- - [test/der.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/der.property.test.ts) TLV round-trip for INTEGER / OID / OCTET STRING / BIT STRING / SEQUENCE
341
- - [test/bytes.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/bytes.property.test.ts) — `concatBytes`, `binaryToBytes`/`bytesToBinary`, `bytesEqual`, `cloneBytes`
342
- - [test/ip.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/ip.property.test.ts) — IPv4 dotted-quad and IPv6 (full form / `::` compression) encoding
343
- - [test/pem.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/pem.property.test.ts) round-trip between `certificateToPem` and `pemToDer` / `pemToDerWithLabel` / `splitPemBlocks`
344
-
345
- `vitest.config.ts` includes `test/**/*.test.ts`, so `npm run test` runs them all together. The certificate-assembly layer (`ca.ts` / `x509.ts`) is intentionally outside the PBT scope and stays example-based in [test/edgca.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/edgca.test.ts).
346
-
347
- ## API Documentation
348
-
349
- See [docs/en/API.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/API.md) for the full API reference.
350
-
351
- The initial implementation plan is preserved as history in [docs/jp/PLAN_HISTORY.md](https://github.com/noz-ele/EdgCA/blob/main/docs/jp/PLAN_HISTORY.md) (Japanese only — archival material, not maintained in English).
1
+ # EdgCA
2
+
3
+ > [日本語](https://github.com/noz-ele/EdgCA/blob/main/docs/jp/README.md) | English
4
+
5
+ EdgCA is a small TypeScript library that issues mTLS client certificates from a self-managed CA on Cloudflare Workers-compatible runtimes. It supports internal keygen, CSR-based enrollment (PKCS#10 + proof-of-possession), and PFX (PKCS#12) export for OS keystore import.
6
+
7
+ The scope is intentionally narrow:
8
+
9
+ - Create a self-signed root CA.
10
+ - Issue an intermediate CA from a root CA.
11
+ - Issue an mTLS client certificate and private key from an intermediate CA.
12
+ - Issue an mTLS client certificate from a caller-provided public key (no private key returned). Pairs with the CSR helpers below.
13
+ - Parse a PKCS#10 CSR (subject, requested SAN, public key, raw extensions/attributes) and verify its proof-of-possession signature.
14
+ - Decide whether a received client certificate was issued by your own CA.
15
+ - Encode/decode certificates as PEM/DER. Keys are exchanged as `CryptoKey` only — the library never returns or accepts string forms (PEM, JWK, etc.) of private keys.
16
+ - Bundle an issued cert + private key into a password-protected PFX (PKCS#12) file for OS keystore import (Win11+, macOS 15+, iOS/iPadOS 18+, modern Linux consumers).
17
+ - Delegate all cryptographic operations to `globalThis.crypto.subtle`.
18
+
19
+ ECDSA on **NIST P-256, P-384, and P-521** is supported throughout (signing, verification, CSR parsing). RSA, EdDSA, and other curves are intentionally out of scope.
20
+
21
+ > ⚠ **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).
22
+
23
+ ## Contents
24
+
25
+ - [Quick Start](#quick-start) — root → intermediate → client cert (incl. PFX bundling)
26
+ - [Verify on Cloudflare Worker](#verify-cloudflare-worker) — confirm a cert was issued by your CA
27
+ - [Issue from a CSR](#issue-from-a-csr) — accept a caller-managed key via PKCS#10 + POP
28
+ - [Subject](#subject) · [Scope](#scope) · [Key Handling](#key-handling) · [Development](#development) · [API Documentation](#api-documentation)
29
+
30
+ ## Status
31
+
32
+ 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.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ npm install @noz-ele/edgca
38
+ ```
39
+
40
+ 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.
41
+
42
+ ## Quick Start
43
+
44
+ ```ts
45
+ import {
46
+ createRootCA,
47
+ issueIntermediateCA,
48
+ issueClientCert
49
+ } from "@noz-ele/edgca";
50
+
51
+ const root = await createRootCA({
52
+ subject: [{ type: "CN", value: "dev-root" }],
53
+ days: 3650
54
+ });
55
+
56
+ const intermediate = await issueIntermediateCA({
57
+ ca: root,
58
+ subject: [{ type: "CN", value: "dev-intermediate" }],
59
+ days: 365
60
+ });
61
+
62
+ const client = await issueClientCert({
63
+ ca: intermediate,
64
+ subject: [
65
+ { type: "CN", value: "worker-client" },
66
+ { type: "UID", value: "worker-001" }
67
+ ],
68
+ days: 30
69
+ });
70
+
71
+ // Persist these via your secrets manager / KV / vault.
72
+ // `client.privateKey` is a CryptoKey. To persist it, export with
73
+ // crypto.subtle.exportKey("pkcs8", client.privateKey) (or another form)
74
+ // and treat the resulting bytes as a secret — never log or transmit them.
75
+ // client.certPem — public certificate
76
+ // client.certChainPem — full chain to present during mTLS
77
+ // client.privateKey — secret CryptoKey, hand off only over a trusted channel
78
+ ```
79
+
80
+ The basic shape is:
81
+
82
+ ```text
83
+ root CA -> intermediate CA -> mTLS client certificate
84
+ ```
85
+
86
+ This is the deepest CA hierarchy EdgCA targets. Issuing further intermediate CAs from an intermediate is out of scope.
87
+
88
+ `client.certChainPem` is concatenated in this order:
89
+
90
+ ```text
91
+ client certificate
92
+ issuer certificate
93
+ issuer chain
94
+ ```
95
+
96
+ For a client certificate issued by an EdgCA-built intermediate, the result is `client + intermediate + root`.
97
+
98
+ ### Bundling an issued cert + key as a PFX (PKCS#12)
99
+
100
+ OS certificate stores (Windows, macOS, iOS) accept a single password-protected `.pfx` (also `.p12`) file containing the leaf cert, optional chain, and the encrypted private key. `exportPkcs12` builds that file from an `IssuedClientCertificate`:
101
+
102
+ ```ts
103
+ import { exportPkcs12 } from "@noz-ele/edgca/pkcs12";
104
+
105
+ const pfxBytes = await exportPkcs12({
106
+ certDer: client.certDer,
107
+ chainDer: [intermediate.certDer, root.certDer], // optional
108
+ privateKey: client.privateKey, // CryptoKey, must be extractable
109
+ password: new TextEncoder().encode(passwordString),
110
+ friendlyName: new TextEncoder().encode("worker-client") // optional, BMPString
111
+ });
112
+ // pfxBytes is a Uint8Array write it to disk, send it to a download trigger,
113
+ // or hand it to tls.createSecureContext({ pfx: Buffer.from(pfxBytes), passphrase: passwordString }).
114
+ ```
115
+
116
+ The password is taken as a UTF-8 `Uint8Array` (not a `string`) so that callers can keep secret bytes off the immutable JS string heap. PBKDF2 iterations default to 600 000 and the MAC KDF iterations to 100 000 — these match OWASP and OpenSSL 3 defaults but are caller-overridable.
117
+
118
+ The implementation is environment-agnostic (WebCrypto only, no Node-specific APIs), so PFX assembly can run **server-side, in a Cloudflare Worker, or directly in a browser**. A common architecture is to keep the CA on a server while having the browser generate its keypair locally, send a CSR, receive the cert, and assemble the PFX client-side — keeping the private key and password off the wire.
119
+
120
+ The `@noz-ele/edgca/pkcs12` subpath is provided so consumers that only need PFX assembly can import it without pulling in the CA / CSR / verify modules.
121
+
122
+ ## Verify (Cloudflare Worker)
123
+
124
+ > ⚠ **What this is — and is not**
125
+ >
126
+ > `verifyClientCertificateIssuedBy` is **not** mTLS verification. (Real mTLS verification does not exist for a self-managed CA on Cloudflare Workers in the first place.) At most it is *issuance verification*: it confirms that the presented certificate was issued by the specified CA. **That is not the same as authenticating that the presenter is the certificate's legitimate owner.**
127
+ >
128
+ > A client certificate is, by design, presentable to anyone, and its contents are trivially copyable. You must assume that anyone can be holding a valid copy. Therefore possession of valid certificate data **never** proves legitimate ownership.
129
+ >
130
+ > Proving legitimate ownership additionally requires verifying possession of the corresponding private key — i.e., a signature made by the private key, verified against the certificate's public key. The TLS handshake's `CertificateVerify` message normally does this, but **the Cloudflare Workers runtime does not expose that signature to the application.** On non-Enterprise plans, Cloudflare's TLS layer also does not know about your self-managed CA, so `request.cf.tlsClientAuth.certVerified` will not be `"SUCCESS"` for certificates EdgCA issued. Workers application code (Enterprise plans excluded) has no way to verify proof-of-possession.
131
+ >
132
+ > Implication: an attacker who has obtained a copy of a valid certificate (logs, leaked storage, network capture, etc.) can present it and pass this check. Use this function as a *minimum* identity-check layer, not as authentication. For real authentication, either (a) use Cloudflare Enterprise with mTLS configured at the TLS layer (Cloudflare validates the handshake signature against your CA), or (b) add an application-layer challenge-response that has the client sign a server-issued nonce with its private key.
133
+ >
134
+ > Also out of scope (not checked by this function): `BasicConstraints CA=false`, `EKU clientAuth`, revocation, and chain walking.
135
+
136
+ 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.
137
+
138
+ ### Formats Cloudflare exposes after extraction
139
+
140
+ | field | format | example |
141
+ | --- | --- | --- |
142
+ | `certPresented` | whether a client cert was sent | `"1"` / `"0"` |
143
+ | `certVerified` | TLS-layer verification status string. **For self-managed CAs on non-Enterprise plans this will not be `"SUCCESS"`** — the TLS layer does not know about your CA. | `"SUCCESS"` / `"FAILED:..."` / `"NONE"` |
144
+ | `certRFC9440` | RFC 9440 Structured Field Item (Byte Sequence). Base64 wrapped in `:` | `":MIIB...:"` |
145
+ | `certNotBefore` / `certNotAfter` | OpenSSL-style textual format (always GMT). Single-digit day padded with two spaces | `"Dec 24 23:59:59 2025 GMT"` / `"Dec 4 23:59:59 2025 GMT"` |
146
+ | `certSubjectDN`, `certIssuerDN`, `certSerial`, etc. | strings | identity extraction |
147
+
148
+ `verifyClientCertificateIssuedBy` accepts PEM (`certPem: string`) and `Date` / epoch ms (`validity.notBefore` / `notAfter`). Those forms **do not match** what Cloudflare provides, so the application must convert:
149
+
150
+ - `certRFC9440` (`":...:"`) strip the surrounding colons, wrap with PEM markers.
151
+ - `certNotBefore` / `certNotAfter` (textual) `new Date(...)` (V8 / the Workers runtime parses this format).
152
+
153
+ These parsers live in the caller, not in the library, because (a) we do not want to track Cloudflare's output-format changes, (b) we do not want to rely on runtime-dependent `Date.parse` leniency, and (c) the caller already holds the values, so reimplementing them here would be redundant. See [docs/en/NON_GOALS.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/NON_GOALS.md) for the full rationale.
154
+
155
+ ### Example
156
+
157
+ ```ts
158
+ import { importCertificateAuthority, pemToDer, verifyClientCertificateIssuedBy } from "@noz-ele/edgca";
159
+
160
+ // At Worker startup: import the CA loaded from your vault once.
161
+ // The library accepts the private key as a CryptoKey only — convert from
162
+ // whatever persistence format you use (PKCS#8 PEM, JWK, raw bytes, ...).
163
+ const pkcs8Der = pemToDer(env.CA_PRIVATE_KEY_PEM);
164
+ const privateKey = await crypto.subtle.importKey(
165
+ "pkcs8",
166
+ pkcs8Der,
167
+ { name: "ECDSA", namedCurve: "P-256" },
168
+ /* extractable */ false,
169
+ ["sign"]
170
+ );
171
+ const ca = await importCertificateAuthority({
172
+ certPem: env.CA_CERT_PEM,
173
+ privateKey
174
+ });
175
+
176
+ export default {
177
+ async fetch(request: Request): Promise<Response> {
178
+ const tls = request.cf?.tlsClientAuth;
179
+ if (!tls || tls.certPresented !== "1") {
180
+ return new Response("client certificate required", { status: 401 });
181
+ }
182
+ // Note: tls.certVerified !== "SUCCESS" is expected for self-managed CAs
183
+ // on non-Enterprise plans. The application performs the issuance check below.
184
+
185
+ // Convert Cloudflare's formats to the library's formats.
186
+ // certRFC9440 (":base64:") -> PEM string
187
+ // certNotBefore / certNotAfter -> Date
188
+ const b64 = tls.certRFC9440.replace(/^:|:$/g, "");
189
+ const certPem = `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`;
190
+
191
+ const ok = await verifyClientCertificateIssuedBy({
192
+ ca,
193
+ certPem,
194
+ validity: {
195
+ notBefore: new Date(tls.certNotBefore),
196
+ notAfter: new Date(tls.certNotAfter)
197
+ // omit `now` to use Date.now()
198
+ }
199
+ });
200
+ if (!ok) {
201
+ return new Response("not issued by us, or expired", { status: 403 });
202
+ }
203
+
204
+ // Reminder: passing this check does NOT prove the presenter holds the
205
+ // private key. For real authentication, layer a challenge-response
206
+ // (nonce signed with the client's private key) on top.
207
+
208
+ // Authorization logic: derive identity from cf.tlsClientAuth.certSubjectDN, etc.
209
+ return new Response(`hello, ${tls.certSubjectDN}`);
210
+ }
211
+ };
212
+ ```
213
+
214
+ ### Notes
215
+
216
+ - 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.
217
+ - "Not issued by us" and "outside the validity window" return `false`; malformed PEM/DER throws. The two error categories are deliberately split.
218
+ - 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.
219
+
220
+ ## Issue from a CSR
221
+
222
+ When a client manages its own private key and submits a PKCS#10 CSR, EdgCA parses the CSR, verifies its proof-of-possession signature, and issues a certificate that embeds the CSR's public key. The library does **not** auto-adopt the CSR's claimed subject / SAN the caller passes those explicitly, derived from whatever policy applies in the application layer.
223
+
224
+ ```ts
225
+ import {
226
+ importCertificateAuthority,
227
+ issueClientCertForPublicKey,
228
+ parseCertificateSigningRequest,
229
+ verifyCertificateSigningRequestSignature
230
+ } from "@noz-ele/edgca";
231
+
232
+ const csr = await parseCertificateSigningRequest(csrPemFromClient);
233
+ if (!await verifyCertificateSigningRequestSignature(csr)) {
234
+ return new Response("CSR proof-of-possession failed", { status: 400 });
235
+ }
236
+
237
+ // Application decides what subject and SAN to issue with. The CSR's claimed
238
+ // values are available on csr.subject / csr.requestedDnsNames /
239
+ // csr.requestedIpAddresses, but treating them as authoritative is a policy
240
+ // decision that lives outside EdgCA.
241
+ const issued = await issueClientCertForPublicKey({
242
+ ca,
243
+ publicKey: csr.publicKey,
244
+ subject: policyDerivedSubject,
245
+ days: 30,
246
+ dnsNames: policyDerivedDnsNames
247
+ });
248
+ // issued has certPem / certDer / certChainPem only — no privateKey, because
249
+ // the client owns it.
250
+ ```
251
+
252
+ CSRs signed with anything other than `ecdsa-with-SHA256` / `ecdsa-with-SHA384` / `ecdsa-with-SHA512` are rejected at parse time with an explicit error. CSR-level attributes other than `extensionRequest` are surfaced as raw DER under `csr.otherAttributes` for callers that need them; X.509 extensions other than SAN are surfaced under `csr.requestedExtensions` as `{ oid, critical, valueDer }` for caller-side decoding.
253
+
254
+ POP verification proves only that whoever produced the CSR holds the matching private key. **It is not authorization.** Combine it with whatever transport-level (mTLS) and application-level checks make sense for your enrollment flow.
255
+
256
+ ## Subject
257
+
258
+ Subject only accepts a structured input. DN strings such as `CN=dev-root,O=Example` are not accepted.
259
+
260
+ ```ts
261
+ const subject = [
262
+ { type: "CN", value: "dev-root" },
263
+ { type: "O", value: "Example" },
264
+ { type: "1.2.3.4.5", value: "custom-value" }
265
+ ];
266
+ ```
267
+
268
+ Supported short names:
269
+
270
+ ```text
271
+ CN, O, OU, C, ST, L, E, DC, SERIALNUMBER, STREET,
272
+ POSTALCODE, TITLE, GIVENNAME, SURNAME, UID
273
+ ```
274
+
275
+ Dotted OID strings are also accepted. The ASN.1 string type for values is fixed at UTF8String, with `C` as PrintableString. Multi-valued RDNs are out of scope.
276
+
277
+ ## Scope
278
+
279
+ In scope:
280
+
281
+ - ECDSA on NIST P-256 / P-384 / P-521 (paired with SHA-256 / SHA-384 / SHA-512 respectively).
282
+ - Key generation, signing, digest, and key import/export via WebCrypto.
283
+ - Root CA creation.
284
+ - Intermediate CA issuance.
285
+ - mTLS client certificate issuance (with internal key generation, or from a caller-provided public key).
286
+ - CSR (PKCS#10) parsing and proof-of-possession signature verification.
287
+ - Identity check that a cert was issued by your own CA (`verifyClientCertificateIssuedBy`, with optional time-validity check).
288
+ - PEM/DER helpers (certificates only — keys are exchanged as `CryptoKey`).
289
+ - 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+).
290
+ - Basic Constraints, Key Usage, Extended Key Usage, Subject Alternative Name, SKI, AKI.
291
+
292
+ Intentionally out of scope:
293
+
294
+ - Server certificate issuance.
295
+ - Public chain-validation APIs.
296
+ - 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`.
297
+ - CRL, OCSP, revocation databases, revocation checks.
298
+ - Key storage, encryption-at-rest, rotation-state persistence, and integration with KV/D1/R2/Secrets.
299
+ - RSA, EdDSA, other elliptic curves (CSRs signed with these algorithms are rejected at parse time).
300
+ - 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`.
301
+ - A general certificate parsing API (Cloudflare hands you parsed values via `cf.tlsClientAuth.cert*`; the library does not duplicate that).
302
+ - Issuance policy decisions (whether to honor a CSR's claimed subject/SAN, deduplicate, etc.) — caller's responsibility.
303
+ - DN string parsing.
304
+ - Multi-valued RDNs.
305
+
306
+ ## Key Handling
307
+
308
+ EdgCA exchanges keys as `CryptoKey` only. The library never returns or accepts string forms (PEM, JWK, base64, ...) of private keys, so secret material does not live on the JS string heap at the library boundary. Internally generated keys are extractable so the caller can persist them by calling `crypto.subtle.exportKey` directly, but the choice of persistence format is the caller's.
309
+
310
+ EdgCA only handles key generation, signing, and SPKI export of public keys. Where keys are stored, how they are encrypted at rest, how rotation state is persisted, and how they integrate with Cloudflare storage products are all the application's responsibility.
311
+
312
+ ### Bringing your own CA key (recommended)
313
+
314
+ 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.
315
+
316
+ ```ts
317
+ // Restore a CryptoKeyPair from whatever persistence format you use.
318
+ // Below is one example that converts PKCS#8 PEM stored in a vault.
319
+ async function loadKeyPair(label: string): Promise<CryptoKeyPair> {
320
+ const pkcs8 = pemToDer(loadFromVault(`${label}-private-pem`));
321
+ const privateKey = await crypto.subtle.importKey(
322
+ "pkcs8",
323
+ pkcs8,
324
+ { name: "ECDSA", namedCurve: "P-256" },
325
+ /* extractable */ false,
326
+ ["sign"]
327
+ );
328
+ // Derive the matching public key. If you also persist the public key as
329
+ // SPKI, import that directly instead of round-tripping through JWK.
330
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey);
331
+ delete jwk.d;
332
+ jwk.key_ops = ["verify"];
333
+ const publicKey = await crypto.subtle.importKey(
334
+ "jwk",
335
+ jwk,
336
+ { name: "ECDSA", namedCurve: "P-256" },
337
+ true,
338
+ ["verify"]
339
+ );
340
+ return { privateKey, publicKey };
341
+ }
342
+
343
+ const root = await createRootCA({
344
+ subject: [{ type: "CN", value: "dev-root" }],
345
+ days: 3650,
346
+ keyPair: await loadKeyPair("root")
347
+ });
348
+
349
+ const intermediate = await issueIntermediateCA({
350
+ ca: root,
351
+ subject: [{ type: "CN", value: "dev-intermediate" }],
352
+ days: 365,
353
+ keyPair: await loadKeyPair("intermediate")
354
+ });
355
+ ```
356
+
357
+ Omitting `keyPair` causes the library to generate a key pair internally — convenient for tests and PoCs. Client-certificate keys are intended to be ephemeral, so `issueClientCert` always generates internally.
358
+
359
+ ## Development
360
+
361
+ ```sh
362
+ npm run typecheck
363
+ npm run build
364
+ npm run test
365
+ npm audit
366
+ ```
367
+
368
+ The main suite (`vitest.config.ts`) runs on `@cloudflare/vitest-pool-workers` to verify WebCrypto behavior on the Workers-compatible runtime. A second suite (`vitest.node.config.ts`, file pattern `*.node.test.ts`) runs under Node so the produced PFX can be validated end-to-end against `node:tls`'s `createSecureContext`. `npm run test` runs both in sequence.
369
+
370
+ ### Property-based tests
371
+
372
+ Round-trip invariants in the lower layers are expressed as `fast-check` property-based tests, kept one file per target module under `test/<module>.property.test.ts`.
373
+
374
+ - [test/der.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/der.property.test.ts) — TLV round-trip for INTEGER / OID / OCTET STRING / BIT STRING / SEQUENCE
375
+ - [test/bytes.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/bytes.property.test.ts) — `concatBytes`, `binaryToBytes`/`bytesToBinary`, `bytesEqual`, `cloneBytes`
376
+ - [test/ip.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/ip.property.test.ts) — IPv4 dotted-quad and IPv6 (full form / `::` compression) encoding
377
+ - [test/pem.property.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/pem.property.test.ts) — round-trip between `certificateToPem` and `pemToDer` / `pemToDerWithLabel` / `splitPemBlocks`
378
+
379
+ `vitest.config.ts` includes `test/**/*.test.ts`, so `npm run test` runs them all together. The certificate-assembly layer (`ca.ts` / `x509.ts`) is intentionally outside the PBT scope and stays example-based in [test/edgca.test.ts](https://github.com/noz-ele/EdgCA/blob/main/test/edgca.test.ts).
380
+
381
+ ## API Documentation
382
+
383
+ See [docs/en/API.md](https://github.com/noz-ele/EdgCA/blob/main/docs/en/API.md) for the full API reference.
384
+
385
+ The initial implementation plan is preserved as history in [docs/jp/PLAN_HISTORY.md](https://github.com/noz-ele/EdgCA/blob/main/docs/jp/PLAN_HISTORY.md) (Japanese only — archival material, not maintained in English).