1/**
2 * The ACME issuance flow shared by the `Certificate` resource (deploy time)
3 * and the `IssueCertificate` binding (runtime):
4 *
5 * order → DNS-01 per authorization (present → propagated → respond →
6 * poll) → finalize with a fresh key's CSR → download → parse.
7 *
8 * The wire is `@distilled.cloud/acme`; this file only sequences it.
9 */
10import * as Acme from "@distilled.cloud/acme";
11import * as acme from "@distilled.cloud/acme/acme";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Redacted from "effect/Redacted";
15import * as Schedule from "effect/Schedule";
16import * as FetchHttpClient from "effect/http/FetchHttpClient";
17import * as HttpClient from "effect/http/HttpClient";
18import type { CertificateAuthority } from "./CertificateAuthority.ts";
19import { waitForTxt, type PropagationOptions } from "./Dns.ts";
20import type { DnsChallengeRecord, DnsSolver } from "./DnsSolver.ts";
21import {
22 ChallengeFailed,
23 ChallengeUnsupported,
24 OrderInvalid,
25 OrderTimeout,
26 PkiError,
27 type DnsPropagationTimeout,
28 type DnsSolverError,
29} from "./Errors.ts";
30import {
31 createCsr,
32 csrToBase64Url,
33 fromPem,
34 generateKey,
35 parseCertificate,
36 privateKeyToJwk,
37 splitPemChain,
38 type KeyAlgorithm,
39 type ParsedCertificate,
40} from "./Pki.ts";
41
42/** Everything the flow needs to sign requests as an account. */
43export interface AccountCredentials {
44 readonly ca: CertificateAuthority;
45 /** The account's private JWK (JSON). */
46 readonly accountKey: Redacted.Redacted<string>;
47 /** The account URL (`kid`), once the account exists. */
48 readonly accountUrl?: string | undefined;
49 readonly externalAccountBinding?:
50 | { readonly keyId: string; readonly hmacKey: Redacted.Redacted<string> }
51 | undefined;
52}
53
54/**
55 * The SDK's `Credentials` (+ a CA-trusting `HttpClient` for private CAs)
56 * for one account. Provide around any `@distilled.cloud/acme` call.
57 */
58export const accountLayer = (
59 credentials: AccountCredentials,
60): Layer.Layer<Acme.Credentials | HttpClient.HttpClient> => {
61 const creds = Acme.layer({
62 directoryUrl: credentials.ca.directoryUrl,
63 accountKey: credentials.accountKey,
64 accountUrl: credentials.accountUrl,
65 externalAccountBinding: credentials.externalAccountBinding,
66 });
67 const http =
68 credentials.ca.trustedRoot === undefined
69 ? FetchHttpClient.layer
70 : FetchHttpClient.layer.pipe(
71 Layer.provideMerge(
72 Layer.succeed(FetchHttpClient.RequestInit, {
73 // Bun's fetch honours `tls`; the option is ignored elsewhere.
74 tls: { ca: credentials.ca.trustedRoot },
75 } as RequestInit),
76 ),
77 );
78 return Layer.merge(creds, http);
79};
80
81export interface IssueRequest<R = never> {
82 /** DNS names; the first is the subject CN. Wildcards allowed. */
83 readonly identifiers: ReadonlyArray<string>;
84 readonly solver: DnsSolver<R>;
85 /** @default "ES256" */
86 readonly keyAlgorithm?: KeyAlgorithm | undefined;
87 /** Issuer name (`CN=...` substring) to prefer when the CA offers alternate chains. */
88 readonly preferredChain?: string | undefined;
89 readonly propagation?: PropagationOptions | undefined;
90}
91
92export interface IssuedCertificate {
93 /** Leaf certificate PEM. */
94 readonly certificate: string;
95 /** Full chain PEM, leaf first. */
96 readonly chain: string;
97 /** PKCS#8 private key PEM. */
98 readonly privateKey: Redacted.Redacted<string>;
99 readonly identifiers: ReadonlyArray<string>;
100 readonly serial: string;
101 /** ISO 8601. */
102 readonly notBefore: string;
103 /** ISO 8601. */
104 readonly notAfter: string;
105 readonly issuer: string;
106 /** The CA's certificate URL (for re-download and revocation bookkeeping). */
107 readonly certificateUrl: string;
108 readonly orderUrl: string;
109}
110
111export type IssueError =
112 | OrderInvalid
113 | ChallengeFailed
114 | ChallengeUnsupported
115 | OrderTimeout
116 | DnsPropagationTimeout
117 | DnsSolverError
118 | PkiError
119 | Acme.JoseError
120 | acme.NewOrderError
121 | acme.GetAuthorizationError
122 | acme.RespondChallengeError
123 | acme.FinalizeOrderError
124 | acme.GetOrderError
125 | acme.DownloadCertificateError;
126
127const PENDING = new Set(["pending", "processing", "ready"]);
128
129const challengeFqdn = (identifier: string): string =>
130 `_acme-challenge.${identifier.replace(/^\*\./, "")}`;
131
132const problemsOf = (
133 order: acme.OrderResponse,
134 authorizations: ReadonlyArray<acme.Authorization>,
135) => {
136 const problems: Array<{
137 identifier?: string;
138 type?: string;
139 detail?: string;
140 }> = [];
141 if (order.error) {
142 problems.push({ type: order.error.type, detail: order.error.detail });
143 }
144 for (const authorization of authorizations) {
145 for (const challenge of authorization.challenges) {
146 if (challenge.error) {
147 problems.push({
148 identifier: authorization.identifier.value,
149 type: challenge.error.type,
150 detail: challenge.error.detail,
151 });
152 }
153 }
154 }
155 return problems;
156};
157
158/**
159 * Complete one DNS-01 authorization and remove its TXT record on every exit.
160 */
161const solveAuthorization = <R>(
162 authorizationUrl: string,
163 jwk: Acme.Jose.Jwk,
164 solver: DnsSolver<R>,
165 propagation: PropagationOptions,
166) =>
167 Effect.gen(function* () {
168 const authorization = yield* acme.getAuthorization({
169 url: authorizationUrl,
170 });
171 if (authorization.status === "valid") return authorization;
172 const identifier = authorization.identifier.value;
173 const challenge = authorization.challenges.find((c) => c.type === "dns-01");
174 if (challenge === undefined || challenge.token === undefined) {
175 return yield* new ChallengeUnsupported({
176 identifier,
177 offered: authorization.challenges.map((c) => c.type),
178 });
179 }
180 const record: DnsChallengeRecord = {
181 fqdn: challengeFqdn(identifier),
182 value: yield* Acme.Jose.dnsChallengeValue(challenge.token, jwk),
183 };
184 return yield* Effect.acquireUseRelease(
185 solver.present(record),
186 () =>
187 Effect.gen(function* () {
188 yield* solver.propagated === undefined
189 ? waitForTxt(record.fqdn, record.value, propagation)
190 : solver.propagated(record, propagation);
191 yield* acme.respondChallenge({ url: challenge.url });
192 const settled = yield* acme
193 .getAuthorization({ url: authorizationUrl })
194 .pipe(
195 Effect.repeat({
196 schedule: Schedule.spaced("2 seconds"),
197 until: (a) => !PENDING.has(a.status),
198 times: 8,
199 }),
200 );
201 if (settled.status !== "valid") {
202 const failed = settled.challenges.find((c) => c.type === "dns-01");
203 return yield* new ChallengeFailed({
204 identifier,
205 type: failed?.error?.type,
206 detail:
207 failed?.error?.detail ?? `authorization is ${settled.status}`,
208 });
209 }
210 return settled;
211 }),
212 () => solver.cleanup(record),
213 );
214 });
215
216const chooseChain = (
217 downloaded: { chain: string; alternates?: ReadonlyArray<string> },
218 preferredChain: string | undefined,
219) =>
220 Effect.gen(function* () {
221 const matches = (chain: string) =>
222 Effect.gen(function* () {
223 const blocks = splitPemChain(chain);
224 const top = blocks[blocks.length - 1];
225 if (top === undefined) return false;
226 const parsed = yield* parseCertificate(top).pipe(
227 Effect.orElseSucceed(() => undefined),
228 );
229 return parsed?.issuer.includes(preferredChain!) === true;
230 });
231 if (preferredChain === undefined) return downloaded.chain;
232 if (yield* matches(downloaded.chain)) return downloaded.chain;
233 for (const url of downloaded.alternates ?? []) {
234 const alternate = yield* acme.downloadCertificate({ url });
235 if (yield* matches(alternate.chain)) return alternate.chain;
236 }
237 return downloaded.chain;
238 });
239
240/**
241 * Issue a certificate for `identifiers` as the current `Credentials`
242 * account. Requires the SDK's `Credentials` and an `HttpClient` (see
243 * {@link accountLayer}) plus whatever the solver needs.
244 */
245export const issueCertificate = <R = never>(
246 request: IssueRequest<R>,
247): Effect.Effect<
248 IssuedCertificate,
249 IssueError,
250 Acme.Credentials | HttpClient.HttpClient | R
251> =>
252 Effect.gen(function* () {
253 const resolve = yield* Acme.Credentials;
254 const config = yield* resolve;
255 const jwk = yield* Acme.Jose.parseJwk(config.accountKey);
256 const propagation = request.propagation ?? {};
257
258 const order = yield* acme.newOrder({
259 identifiers: request.identifiers.map((value) => ({ type: "dns", value })),
260 });
261 if (order.location === undefined) {
262 return yield* new OrderInvalid({
263 orderUrl: order.finalize,
264 problems: [{ detail: "The CA omitted the order Location header." }],
265 });
266 }
267 const orderUrl = order.location;
268
269 // Each authorization removes its own TXT record on success or failure.
270 const authorizations = yield* Effect.forEach(
271 order.authorizations,
272 (url) => solveAuthorization(url, jwk, request.solver, propagation),
273 { concurrency: 4 },
274 );
275
276 const ready = yield* acme.getOrder({ url: orderUrl }).pipe(
277 Effect.repeat({
278 schedule: Schedule.spaced("2 seconds"),
279 until: (value) => value.status !== "pending",
280 times: 8,
281 }),
282 );
283 if (ready.status === "invalid") {
284 return yield* new OrderInvalid({
285 orderUrl,
286 problems: problemsOf(ready, authorizations),
287 });
288 }
289 if (ready.status !== "ready") {
290 return yield* new OrderTimeout({ orderUrl, status: ready.status });
291 }
292 const key = yield* generateKey(request.keyAlgorithm ?? "ES256");
293 const csr = yield* createCsr({ key, identifiers: request.identifiers });
294 yield* acme
295 .finalizeOrder({ url: order.finalize, csr: csrToBase64Url(csr) })
296 .pipe(
297 Effect.retry({
298 while: (error) => error._tag === "AcmeOrderNotReady",
299 schedule: Schedule.spaced("1 second"),
300 times: 8,
301 }),
302 );
303
304 const settled = yield* acme.getOrder({ url: orderUrl }).pipe(
305 Effect.repeat({
306 schedule: Schedule.spaced("2 seconds"),
307 until: (o) => o.status === "valid" || o.status === "invalid",
308 times: 8,
309 }),
310 );
311 if (settled.status === "invalid") {
312 return yield* new OrderInvalid({
313 orderUrl,
314 problems: problemsOf(settled, authorizations),
315 });
316 }
317 if (settled.status !== "valid" || settled.certificate === undefined) {
318 return yield* new OrderTimeout({ orderUrl, status: settled.status });
319 }
320
321 const downloaded = yield* acme.downloadCertificate({
322 url: settled.certificate,
323 });
324 const chain = yield* chooseChain(downloaded, request.preferredChain);
325 const certificate = splitPemChain(chain)[0] ?? chain;
326 const parsed = yield* parseCertificate(certificate);
327 return toIssued({
328 certificate,
329 chain,
330 privateKey: key.privateKeyPem,
331 identifiers: request.identifiers,
332 certificateUrl: settled.certificate,
333 orderUrl,
334 parsed,
335 });
336 });
337
338const toIssued = (input: {
339 certificate: string;
340 chain: string;
341 privateKey: Redacted.Redacted<string>;
342 identifiers: ReadonlyArray<string>;
343 certificateUrl: string;
344 orderUrl: string;
345 parsed: ParsedCertificate;
346}): IssuedCertificate => ({
347 certificate: input.certificate,
348 chain: input.chain,
349 privateKey: input.privateKey,
350 identifiers: input.identifiers,
351 serial: input.parsed.serial,
352 notBefore: input.parsed.notBefore.toISOString(),
353 notAfter: input.parsed.notAfter.toISOString(),
354 issuer: input.parsed.issuer,
355 certificateUrl: input.certificateUrl,
356 orderUrl: input.orderUrl,
357});
358
359/** RFC 5280 revocation reasons. */
360export type RevocationReason =
361 | "unspecified"
362 | "keyCompromise"
363 | "affiliationChanged"
364 | "superseded"
365 | "cessationOfOperation";
366
367const REASON_CODES: Record<RevocationReason, number> = {
368 unspecified: 0,
369 keyCompromise: 1,
370 affiliationChanged: 3,
371 superseded: 4,
372 cessationOfOperation: 5,
373};
374
375export interface RevokeRequest {
376 /** Leaf certificate PEM. */
377 readonly certificate: string;
378 /**
379 * The certificate's private key. When given, the request is signed with
380 * it (RFC 8555 §7.6) so no account is needed; otherwise the ambient
381 * account credentials sign.
382 */
383 readonly privateKey?: Redacted.Redacted<string> | string | undefined;
384 readonly reason?: RevocationReason | undefined;
385}
386
387export type RevokeError = PkiError | acme.RevokeCertificateError;
388
389/** Revoke a certificate. `AcmeAlreadyRevoked` is folded into success. */
390export const revokeCertificate = (
391 request: RevokeRequest,
392): Effect.Effect<void, RevokeError, Acme.Credentials | HttpClient.HttpClient> =>
393 Effect.gen(function* () {
394 const leaf = splitPemChain(request.certificate)[0] ?? request.certificate;
395 const der = yield* Effect.try({
396 try: () => fromPem(leaf),
397 catch: (cause) =>
398 new PkiError({ message: "The certificate is not PEM.", cause }),
399 });
400 const revoke = acme
401 .revokeCertificate({
402 certificate: Acme.Jose.base64url(der),
403 reason:
404 request.reason === undefined
405 ? undefined
406 : REASON_CODES[request.reason],
407 })
408 .pipe(Effect.catchTag("AcmeAlreadyRevoked", () => Effect.void));
409 if (request.privateKey === undefined) {
410 return yield* revoke;
411 }
412 // Sign with the certificate key (RFC 8555 §7.6): swap the account key
413 // and drop the account URL so the protocol embeds the JWK.
414 const resolve = yield* Acme.Credentials;
415 const config = yield* resolve;
416 const jwk = yield* privateKeyToJwk(request.privateKey);
417 return yield* revoke.pipe(
418 Effect.provide(
419 Acme.layer({ directoryUrl: config.directoryUrl, accountKey: jwk }),
420 ),
421 );
422 });