1/**
2 * Keys, PKCS#10 certificate requests and X.509 parsing for the ACME flow.
3 * WebCrypto plus the tiny DER codec in `Der.ts` — no native code, so the
4 * same code runs at deploy time and inside a Worker or Fly Service.
5 */
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import { Jose } from "@distilled.cloud/acme";
9import * as Der from "./Der.ts";
10import { PkiError } from "./Errors.ts";
11
12export type KeyAlgorithm = "ES256" | "RS256";
13
14const OID = {
15 commonName: "2.5.4.3",
16 organization: "2.5.4.10",
17 extensionRequest: "1.2.840.113549.1.9.14",
18 subjectAltName: "2.5.29.17",
19 ecdsaWithSha256: "1.2.840.10045.4.3.2",
20 sha256WithRsa: "1.2.840.113549.1.1.11",
21} as const;
22
23const keyGenParams = (alg: KeyAlgorithm) =>
24 alg === "RS256"
25 ? {
26 name: "RSASSA-PKCS1-v1_5",
27 modulusLength: 2048,
28 publicExponent: new Uint8Array([1, 0, 1]),
29 hash: "SHA-256",
30 }
31 : { name: "ECDSA", namedCurve: "P-256" };
32
33const importParams = (alg: KeyAlgorithm) =>
34 alg === "RS256"
35 ? { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
36 : { name: "ECDSA", namedCurve: "P-256" };
37
38const signParams = (alg: KeyAlgorithm) =>
39 alg === "RS256"
40 ? { name: "RSASSA-PKCS1-v1_5" }
41 : { name: "ECDSA", hash: "SHA-256" };
42
43const toPem = (label: string, der: Uint8Array): string => {
44 const b64 = btoa(String.fromCharCode(...der));
45 const lines = b64.match(/.{1,64}/g) ?? [];
46 return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
47};
48
49/** The DER bytes of the first (or `index`-th) PEM block in `pem`. */
50export const fromPem = (pem: string, index = 0): Uint8Array => {
51 const blocks = pem.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g);
52 const block = blocks?.[index];
53 if (block === undefined) {
54 throw new Error(`PEM block ${index} not found`);
55 }
56 const body = block
57 .replace(/-----BEGIN [^-]+-----/, "")
58 .replace(/-----END [^-]+-----/, "")
59 .replace(/\s+/g, "");
60 const binary = atob(body);
61 const out = new Uint8Array(binary.length);
62 for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
63 return out;
64};
65
66/** Every PEM block in a chain, leaf first. */
67export const splitPemChain = (pem: string): string[] =>
68 pem.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g) ?? [];
69
70const tryPromise = <A>(message: string, run: () => Promise<A>) =>
71 Effect.tryPromise({
72 try: run,
73 catch: (cause) => new PkiError({ message, cause }),
74 });
75
76export interface GeneratedKey {
77 readonly algorithm: KeyAlgorithm;
78 /** PKCS#8 PEM. */
79 readonly privateKeyPem: Redacted.Redacted<string>;
80 readonly privateKey: CryptoKey;
81 /** SubjectPublicKeyInfo DER. */
82 readonly spki: Uint8Array;
83}
84
85/** Generate a certificate key pair (ES256 P-256 by default). */
86export const generateKey = (
87 algorithm: KeyAlgorithm = "ES256",
88): Effect.Effect<GeneratedKey, PkiError> =>
89 tryPromise("Generating the certificate key failed.", async () => {
90 const pair = await crypto.subtle.generateKey(
91 keyGenParams(algorithm),
92 true,
93 ["sign", "verify"],
94 );
95 const pkcs8 = new Uint8Array(
96 await crypto.subtle.exportKey("pkcs8", pair.privateKey),
97 );
98 const spki = new Uint8Array(
99 await crypto.subtle.exportKey("spki", pair.publicKey),
100 );
101 return {
102 algorithm,
103 privateKeyPem: Redacted.make(toPem("PRIVATE KEY", pkcs8)),
104 privateKey: pair.privateKey,
105 spki,
106 };
107 });
108
109const algorithmOfPkcs8 = (der: Uint8Array): KeyAlgorithm => {
110 // PrivateKeyInfo ::= SEQUENCE { version, AlgorithmIdentifier, ... }
111 const root = Der.read(der, 0);
112 const [, algorithm] = Der.children(root);
113 const [algOid] = Der.children(algorithm!);
114 return Der.decodeOid(algOid!) === "1.2.840.113549.1.1.1" ? "RS256" : "ES256";
115};
116
117/** Import a PKCS#8 PEM private key (ES256 or RS256, detected from the DER). */
118export const importPrivateKey = (
119 pem: Redacted.Redacted<string> | string,
120): Effect.Effect<
121 { readonly key: CryptoKey; readonly algorithm: KeyAlgorithm },
122 PkiError
123> =>
124 tryPromise("Importing the certificate key failed.", async () => {
125 const text = Redacted.isRedacted(pem) ? Redacted.value(pem) : pem;
126 const der = fromPem(text);
127 const algorithm = algorithmOfPkcs8(der);
128 const key = await crypto.subtle.importKey(
129 "pkcs8",
130 der as Uint8Array<ArrayBuffer>,
131 importParams(algorithm),
132 true,
133 ["sign"],
134 );
135 return { key, algorithm };
136 });
137
138/** The private JWK of a PKCS#8 PEM key (for revocation signed by the certificate key). */
139export const privateKeyToJwk = (
140 pem: Redacted.Redacted<string> | string,
141): Effect.Effect<Redacted.Redacted<string>, PkiError> =>
142 importPrivateKey(pem).pipe(
143 Effect.flatMap(({ key }) =>
144 tryPromise("Exporting the certificate key as a JWK failed.", async () =>
145 Redacted.make(
146 JSON.stringify(await crypto.subtle.exportKey("jwk", key)),
147 ),
148 ),
149 ),
150 );
151
152/** Raw `r || s` ECDSA signature → DER `SEQUENCE { r INTEGER, s INTEGER }`. */
153const ecdsaToDer = (raw: Uint8Array): Uint8Array => {
154 const half = raw.length / 2;
155 return Der.sequence(
156 Der.integer(raw.subarray(0, half)),
157 Der.integer(raw.subarray(half)),
158 );
159};
160
161/**
162 * A PKCS#10 certificate request for `identifiers` signed by `key`: the
163 * first identifier is the subject CN, all of them are `subjectAltName`
164 * dNSName entries. Returned as DER.
165 */
166export const createCsr = (options: {
167 readonly key: GeneratedKey;
168 readonly identifiers: ReadonlyArray<string>;
169}): Effect.Effect<Uint8Array, PkiError> =>
170 tryPromise("Encoding the certificate request failed.", async () => {
171 const { key, identifiers } = options;
172 if (identifiers.length === 0) {
173 throw new Error("a certificate needs at least one identifier");
174 }
175 const subject = Der.sequence(
176 Der.set(
177 Der.sequence(Der.oid(OID.commonName), Der.utf8String(identifiers[0]!)),
178 ),
179 );
180 const sanExtension = Der.sequence(
181 Der.oid(OID.subjectAltName),
182 Der.octetString(
183 Der.sequence(
184 ...identifiers.map((name) =>
185 Der.contextPrimitive(2, new TextEncoder().encode(name)),
186 ),
187 ),
188 ),
189 );
190 const attributes = Der.contextTag(
191 0,
192 Der.sequence(
193 Der.oid(OID.extensionRequest),
194 Der.set(Der.sequence(sanExtension)),
195 ),
196 );
197 const info = Der.sequence(Der.integer(0), subject, key.spki, attributes);
198 const rawSignature = new Uint8Array(
199 await crypto.subtle.sign(
200 signParams(key.algorithm),
201 key.privateKey,
202 info as Uint8Array<ArrayBuffer>,
203 ),
204 );
205 const signature =
206 key.algorithm === "ES256" ? ecdsaToDer(rawSignature) : rawSignature;
207 const algorithm =
208 key.algorithm === "ES256"
209 ? Der.sequence(Der.oid(OID.ecdsaWithSha256))
210 : Der.sequence(Der.oid(OID.sha256WithRsa), Der.nullValue());
211 return Der.sequence(info, algorithm, Der.bitString(signature));
212 });
213
214/** base64url of a DER CSR, as ACME's `finalize` expects. */
215export const csrToBase64Url = (der: Uint8Array): string => Jose.base64url(der);
216
217// =============================================================================
218// X.509 parsing
219// =============================================================================
220
221export interface ParsedCertificate {
222 /** Serial number as lowercase hex. */
223 readonly serial: string;
224 readonly notBefore: Date;
225 readonly notAfter: Date;
226 /** Issuer distinguished name, `CN` first (`CN=R11, O=Let's Encrypt`). */
227 readonly issuer: string;
228 /** Subject distinguished name in the same form. */
229 readonly subject: string;
230 /** `subjectAltName` dNSName entries. */
231 readonly dnsNames: ReadonlyArray<string>;
232}
233
234const decodeName = (name: Der.Node): string => {
235 const parts: string[] = [];
236 for (const rdn of Der.children(name)) {
237 for (const attribute of Der.children(rdn)) {
238 const [typeNode, valueNode] = Der.children(attribute);
239 const type = Der.decodeOid(typeNode!);
240 const value = new TextDecoder().decode(Der.content(valueNode!));
241 const label =
242 type === OID.commonName
243 ? "CN"
244 : type === OID.organization
245 ? "O"
246 : type === "2.5.4.6"
247 ? "C"
248 : type;
249 parts.push(`${label}=${value}`);
250 }
251 }
252 // CN first for readability; the rest keep their order.
253 parts.sort((a, b) =>
254 a.startsWith("CN=") ? -1 : b.startsWith("CN=") ? 1 : 0,
255 );
256 return parts.join(", ");
257};
258
259/** Parse the first certificate of a PEM (or a DER buffer). */
260export const parseCertificate = (
261 input: string | Uint8Array,
262): Effect.Effect<ParsedCertificate, PkiError> =>
263 Effect.try({
264 try: () => {
265 const der = typeof input === "string" ? fromPem(input) : input;
266 const certificate = Der.read(der, 0);
267 const [tbs] = Der.children(certificate);
268 const fields = Der.children(tbs!);
269 let index = 0;
270 if (fields[0]!.tag === 0xa0) index++; // [0] EXPLICIT version
271 const serial = Der.toHex(Der.content(fields[index++]!));
272 index++; // signature AlgorithmIdentifier
273 const issuer = decodeName(fields[index++]!);
274 const [notBeforeNode, notAfterNode] = Der.children(fields[index++]!);
275 const subject = decodeName(fields[index++]!);
276 index++; // subjectPublicKeyInfo
277 const dnsNames: string[] = [];
278 for (const field of fields.slice(index)) {
279 if (field.tag !== 0xa3) continue; // [3] EXPLICIT extensions
280 const [extensions] = Der.children(field);
281 for (const extension of Der.children(extensions!)) {
282 const members = Der.children(extension);
283 if (Der.decodeOid(members[0]!) !== OID.subjectAltName) continue;
284 const valueNode = members[members.length - 1]!; // OCTET STRING (critical may precede)
285 const generalNames = Der.read(Der.content(valueNode), 0);
286 for (const generalName of Der.children(generalNames)) {
287 if (generalName.tag === 0x82) {
288 dnsNames.push(new TextDecoder().decode(Der.content(generalName)));
289 }
290 }
291 }
292 }
293 return {
294 serial: serial.replace(/^00/, "") || "0",
295 notBefore: Der.decodeTime(notBeforeNode!),
296 notAfter: Der.decodeTime(notAfterNode!),
297 issuer,
298 subject,
299 dnsNames,
300 };
301 },
302 catch: (cause) =>
303 new PkiError({ message: "Parsing the certificate failed.", cause }),
304 });