| 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 | */ |
| 10 | import * as Acme from "@distilled.cloud/acme"; |
| 11 | import * as acme from "@distilled.cloud/acme/acme"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Redacted from "effect/Redacted"; |
| 15 | import * as Schedule from "effect/Schedule"; |
| 16 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 17 | import * as HttpClient from "effect/http/HttpClient"; |
| 18 | import type { CertificateAuthority } from "./CertificateAuthority.ts"; |
| 19 | import { waitForTxt, type PropagationOptions } from "./Dns.ts"; |
| 20 | import type { DnsChallengeRecord, DnsSolver } from "./DnsSolver.ts"; |
| 21 | import { |
| 22 | ChallengeFailed, |
| 23 | ChallengeUnsupported, |
| 24 | OrderInvalid, |
| 25 | OrderTimeout, |
| 26 | PkiError, |
| 27 | type DnsPropagationTimeout, |
| 28 | type DnsSolverError, |
| 29 | } from "./Errors.ts"; |
| 30 | import { |
| 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. */ |
| 43 | export 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 | */ |
| 58 | export 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 | |
| 81 | export 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 | |
| 92 | export 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 | |
| 111 | export 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 | |
| 127 | const PENDING = new Set(["pending", "processing", "ready"]); |
| 128 | |
| 129 | const challengeFqdn = (identifier: string): string => |
| 130 | `_acme-challenge.${identifier.replace(/^\*\./, "")}`; |
| 131 | |
| 132 | const 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 | */ |
| 161 | const 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 | |
| 216 | const 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 | */ |
| 245 | export 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 | |
| 338 | const 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. */ |
| 360 | export type RevocationReason = |
| 361 | | "unspecified" |
| 362 | | "keyCompromise" |
| 363 | | "affiliationChanged" |
| 364 | | "superseded" |
| 365 | | "cessationOfOperation"; |
| 366 | |
| 367 | const REASON_CODES: Record<RevocationReason, number> = { |
| 368 | unspecified: 0, |
| 369 | keyCompromise: 1, |
| 370 | affiliationChanged: 3, |
| 371 | superseded: 4, |
| 372 | cessationOfOperation: 5, |
| 373 | }; |
| 374 | |
| 375 | export 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 | |
| 387 | export type RevokeError = PkiError | acme.RevokeCertificateError; |
| 388 | |
| 389 | /** Revoke a certificate. `AcmeAlreadyRevoked` is folded into success. */ |
| 390 | export 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 | }); |