@noz-ele/edgca 0.5.0 → 0.5.2

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,22 +2,25 @@
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 that issues mTLS client certificates and document-signing certificates from a self-managed CA on Cloudflare Workers-compatible runtimes. It supports internal keygen, CSR-based enrollment for mTLS leaves (PKCS#10 + proof-of-possession), and PFX (PKCS#12) export for OS keystore import.
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.
6
6
 
7
- The scope is intentionally narrow:
7
+ ## Features
8
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
- - Issue a document-signing certificate (RFC 9336 `id-kp-documentSigning`) and private key from a CA. Used as the signer cert for CAdES / CMS / ASiC-style document signatures, which are produced by separate tooling.
14
- - Parse a PKCS#10 CSR (subject, requested SAN, public key, raw extensions/attributes) and verify its proof-of-possession signature.
15
- - Decide whether a received client certificate was issued by your own CA.
16
- - 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.
17
- - 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).
18
- - Delegate all cryptographic operations to `globalThis.crypto.subtle`.
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.)
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
+ - **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
+ - **mTLS client certificate issuance.** Issue a leaf with internal key generation, or from a caller-managed key via the CSR path below.
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
+ - **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).
17
+ - **PEM/DER encode/decode** for certificates and PKCS#10 CSRs.
18
+ - **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
19
 
20
- 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
+ ### Supported algorithms
21
+
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
+ - **PFX bundling (`exportPkcs12`)**: algorithm-agnostic — accepts any PKCS#8 DER bytes verbatim.
21
24
 
22
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).
23
26
 
@@ -168,7 +171,9 @@ import { exportPkcs12 } from "@noz-ele/edgca/pkcs12";
168
171
  const pfxBytes = await exportPkcs12({
169
172
  certDer: client.certDer,
170
173
  chainDer: [intermediate.certDer, root.certDer], // optional
171
- privateKey: client.privateKey, // CryptoKey, must be extractable
174
+ // exportPkcs12 takes raw PKCS#8 DER bytes (algorithm-agnostic), not a CryptoKey.
175
+ // If you hold a CryptoKey, extract the bytes first:
176
+ privateKey: new Uint8Array(await crypto.subtle.exportKey("pkcs8", client.privateKey)),
172
177
  password: new TextEncoder().encode(passwordString),
173
178
  friendlyName: new TextEncoder().encode("worker-client") // optional, BMPString
174
179
  });
@@ -1 +1 @@
1
- {"version":3,"file":"pem-to-pfx.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/pem-to-pfx.ts"],"names":[],"mappings":"AAYA,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+CnE"}
1
+ {"version":3,"file":"pem-to-pfx.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/pem-to-pfx.ts"],"names":[],"mappings":"AAWA,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAwCnE"}
@@ -1,8 +1,8 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { parseArgs } from "node:util";
3
- import { exportPkcs12, importCertificateAuthority, pemToDer, pemToDerWithLabel, splitPemBlocks } from "../../index.js";
3
+ import { exportPkcs12, pemToDer, pemToDerWithLabel, splitPemBlocks } from "../../index.js";
4
4
  import { requireString } from "../flags.js";
5
- import { defaultPfxPath, importPkcs8PrivateKeyFromPem } from "../io.js";
5
+ import { defaultPfxPath } from "../io.js";
6
6
  export async function pemToPfxCommand(args) {
7
7
  const { values } = parseArgs({
8
8
  args,
@@ -29,13 +29,7 @@ export async function pemToPfxCommand(args) {
29
29
  const chainDer = chainPem
30
30
  ? splitPemBlocks(chainPem).map((block) => pemToDerWithLabel(block, "CERTIFICATE"))
31
31
  : [];
32
- const privateKey = await importPkcs8PrivateKeyFromPem(keyPem);
33
- // Refuse to emit a PFX whose key does not match the leaf cert. The library's
34
- // exportPkcs12 does not check this, so without the guard a mismatched pair
35
- // silently produces a structurally valid but unusable .pfx file. We discard
36
- // the returned CertificateAuthority — only the sign/verify round-trip
37
- // performed inside importCertificateAuthority matters here.
38
- await importCertificateAuthority({ certPem, privateKey });
32
+ const privateKey = pemToDerWithLabel(keyPem, "PRIVATE KEY");
39
33
  const pfx = await exportPkcs12({
40
34
  certDer,
41
35
  ...(chainDer.length > 0 ? { chainDer } : {}),
package/dist/parser.d.ts CHANGED
@@ -12,6 +12,7 @@ export interface ParsedCertificate {
12
12
  subjectKeyIdentifier?: Uint8Array;
13
13
  authorityKeyIdentifier?: Uint8Array;
14
14
  }
15
+ export declare function extractCertificateSpkiDer(der: Uint8Array): Uint8Array;
15
16
  export declare function parseCertificateDer(der: Uint8Array): Promise<ParsedCertificate>;
16
17
  export declare function assertIssuerSubjectMatches(issuer: ParsedCertificate, issued: ParsedCertificate): void;
17
18
  //# 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;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,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"}
package/dist/parser.js CHANGED
@@ -2,6 +2,44 @@ import { bytesEqual } from "./bytes.js";
2
2
  import { decodeInteger, decodeOid, readChildren, readElement, readSequenceChildren, TAG } from "./der.js";
3
3
  import { importPublicKeySpki } from "./crypto.js";
4
4
  import { OID } from "./oids.js";
5
+ // Read just the SubjectPublicKeyInfo DER from a v3 X.509 certificate without
6
+ // 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.
11
+ export function extractCertificateSpkiDer(der) {
12
+ const certificate = readElement(der);
13
+ if (certificate.tag !== TAG.SEQUENCE || certificate.end !== der.length) {
14
+ throw new Error("Invalid certificate DER");
15
+ }
16
+ const [tbsCertificate] = readSequenceChildren(certificate);
17
+ if (!tbsCertificate) {
18
+ throw new Error("Invalid certificate structure");
19
+ }
20
+ 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
+ }
29
+ // tbsCertificate (v3) layout:
30
+ // [0] version [0] EXPLICIT INTEGER 2
31
+ // [1] serialNumber
32
+ // [2] signature (AlgorithmIdentifier)
33
+ // [3] issuer
34
+ // [4] validity
35
+ // [5] subject
36
+ // [6] subjectPublicKeyInfo
37
+ const subjectPublicKeyInfo = tbsChildren[6];
38
+ if (!subjectPublicKeyInfo || subjectPublicKeyInfo.tag !== TAG.SEQUENCE) {
39
+ throw new Error("Invalid certificate: missing subjectPublicKeyInfo");
40
+ }
41
+ return subjectPublicKeyInfo.raw;
42
+ }
5
43
  export async function parseCertificateDer(der) {
6
44
  const certificate = readElement(der);
7
45
  if (certificate.tag !== TAG.SEQUENCE || certificate.end !== der.length) {
package/dist/pkcs12.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export interface ExportPkcs12Input {
2
2
  certDer: Uint8Array;
3
3
  chainDer?: Uint8Array[];
4
- privateKey: CryptoKey;
4
+ privateKey: Uint8Array;
5
5
  password: Uint8Array;
6
6
  friendlyName?: Uint8Array;
7
7
  iterations?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"pkcs12.d.ts","sourceRoot":"","sources":["../src/pkcs12.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAC;IACxB,UAAU,EAAE,SAAS,CAAC;IACtB,QAAQ,EAAE,UAAU,CAAC;IACrB,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAUD,wBAAsB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CA8FhF"}
1
+ {"version":3,"file":"pkcs12.d.ts","sourceRoot":"","sources":["../src/pkcs12.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAC;IACxB,UAAU,EAAE,UAAU,CAAC;IACvB,QAAQ,EAAE,UAAU,CAAC;IACrB,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAUD,wBAAsB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CA4FhF"}
package/dist/pkcs12.js CHANGED
@@ -2,7 +2,7 @@ import { arrayBufferFromBytes, concatBytes, randomBytes } from "./bytes.js";
2
2
  import { digestSha256, keyIdentifierFromSpki } from "./crypto.js";
3
3
  import { contextPrimitive, der, explicit, integer, nullValue, octetString, oid, sequence, set } from "./der.js";
4
4
  import { OID } from "./oids.js";
5
- import { parseCertificateDer } from "./parser.js";
5
+ import { extractCertificateSpkiDer } from "./parser.js";
6
6
  const DEFAULT_PBKDF2_ITERATIONS = 600_000;
7
7
  const DEFAULT_MAC_ITERATIONS = 100_000;
8
8
  const SALT_LENGTH = 16;
@@ -14,8 +14,8 @@ export async function exportPkcs12(input) {
14
14
  const iterations = input.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
15
15
  const macIterations = input.macIterations ?? DEFAULT_MAC_ITERATIONS;
16
16
  validateInput(input, iterations, macIterations);
17
- const parsedLeaf = await parseCertificateDer(input.certDer);
18
- const localKeyId = await keyIdentifierFromSpki(parsedLeaf.subjectPublicKeyInfoDer);
17
+ const spkiDer = extractCertificateSpkiDer(input.certDer);
18
+ const localKeyId = await keyIdentifierFromSpki(spkiDer);
19
19
  const friendlyNameAttr = input.friendlyName !== undefined && input.friendlyName.length > 0
20
20
  ? buildFriendlyNameAttr(input.friendlyName)
21
21
  : undefined;
@@ -35,9 +35,7 @@ export async function exportPkcs12(input) {
35
35
  const pbkdf2BaseKey = await crypto.subtle.importKey("raw", arrayBufferFromBytes(input.password), "PBKDF2", false, ["deriveKey"]);
36
36
  const certEncrypted = await pbes2EncryptAesCbc(certSafeContents, pbkdf2BaseKey, iterations);
37
37
  const certContentInfo = sequence(oid(OID.encryptedData), explicit(0, sequence(integer(0n), sequence(oid(OID.data), certEncrypted.algorithm, contextPrimitive(0, certEncrypted.ciphertext)))));
38
- const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", input.privateKey));
39
- const keyEncrypted = await pbes2EncryptAesCbc(pkcs8, pbkdf2BaseKey, iterations);
40
- wipe(pkcs8);
38
+ const keyEncrypted = await pbes2EncryptAesCbc(input.privateKey, pbkdf2BaseKey, iterations);
41
39
  const epki = sequence(keyEncrypted.algorithm, octetString(keyEncrypted.ciphertext));
42
40
  const keyBag = buildKeySafeBag(epki, leafAttrs);
43
41
  const keySafeContents = sequence(keyBag);
@@ -55,14 +53,24 @@ export async function exportPkcs12(input) {
55
53
  return sequence(integer(3n), pfxAuthSafeContentInfo, macData);
56
54
  }
57
55
  function validateInput(input, iterations, macIterations) {
56
+ if (!(input.certDer instanceof Uint8Array) || input.certDer.length === 0) {
57
+ throw new Error("certDer must be a non-empty Uint8Array of certificate DER bytes");
58
+ }
59
+ if (input.chainDer !== undefined) {
60
+ if (!Array.isArray(input.chainDer)) {
61
+ throw new Error("chainDer must be an array of Uint8Array");
62
+ }
63
+ for (const entry of input.chainDer) {
64
+ if (!(entry instanceof Uint8Array) || entry.length === 0) {
65
+ throw new Error("chainDer entries must be non-empty Uint8Array");
66
+ }
67
+ }
68
+ }
58
69
  if (!(input.password instanceof Uint8Array) || input.password.length === 0) {
59
70
  throw new Error("password must be a non-empty Uint8Array of UTF-8 bytes");
60
71
  }
61
- if (!input.privateKey.extractable) {
62
- throw new Error("privateKey must be extractable");
63
- }
64
- if (input.privateKey.algorithm.name !== "ECDSA") {
65
- throw new Error("privateKey must be an ECDSA key");
72
+ if (!(input.privateKey instanceof Uint8Array) || input.privateKey.length === 0) {
73
+ throw new Error("privateKey must be a non-empty Uint8Array of PKCS#8 DER bytes");
66
74
  }
67
75
  if (!Number.isInteger(iterations) || iterations < 1) {
68
76
  throw new Error("iterations must be a positive integer");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noz-ele/edgca",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "A small Cloudflare Workers-friendly toolkit for issuing self-managed CA and mTLS client certificates.",
5
5
  "type": "module",
6
6
  "license": "MIT",