@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 +19 -14
- package/dist/cli/commands/pem-to-pfx.d.ts.map +1 -1
- package/dist/cli/commands/pem-to-pfx.js +3 -9
- package/dist/parser.d.ts +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +38 -0
- package/dist/pkcs12.d.ts +1 -1
- package/dist/pkcs12.d.ts.map +1 -1
- package/dist/pkcs12.js +19 -11
- package/package.json +1 -1
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
|
|
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
|
-
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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,
|
|
3
|
+
import { exportPkcs12, pemToDer, pemToDerWithLabel, splitPemBlocks } from "../../index.js";
|
|
4
4
|
import { requireString } from "../flags.js";
|
|
5
|
-
import { defaultPfxPath
|
|
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 =
|
|
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
|
package/dist/parser.d.ts.map
CHANGED
|
@@ -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
package/dist/pkcs12.d.ts.map
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
|
18
|
-
const localKeyId = await keyIdentifierFromSpki(
|
|
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
|
|
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.
|
|
62
|
-
throw new Error("privateKey must be
|
|
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");
|