1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import type * as Redacted from "effect/Redacted";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import { arrayEqualsUnordered } from "../Util/equal.ts";
8import type { Account } from "./Account.ts";
9import {
10 accountLayer,
11 issueCertificate,
12 revokeCertificate,
13 type AccountCredentials,
14} from "./Client.ts";
15import type { PropagationOptions } from "./Dns.ts";
16import { resolveDnsSolver, type DnsSolverDescriptor } from "./DnsSolver.ts";
17import type { KeyAlgorithm } from "./Pki.ts";
18import type { Providers } from "./Providers.ts";
19
20export interface CertificateProps {
21 /** The {@link Account} that orders the certificate. Changing it replaces. */
22 account: Account;
23 /**
24 * DNS names the certificate covers. The first is the subject CN;
25 * wildcards (`*.example.com`) are allowed. Changing the set re-issues.
26 */
27 identifiers: string[];
28 /**
29 * How `_acme-challenge` TXT records are published, e.g.
30 * `Cloudflare.DNS.AcmeSolver(zone)`. Changing the solver alone does not
31 * re-issue.
32 */
33 solver: DnsSolverDescriptor;
34 /**
35 * Certificate key algorithm. Changing it re-issues the certificate.
36 * @default "ES256"
37 */
38 keyAlgorithm?: KeyAlgorithm;
39 /**
40 * Re-issue when less than this remains before `notAfter`. Renewal is a
41 * `diff` result, so any `alchemy deploy` (a scheduled one, typically)
42 * renews.
43 * @default "30 days"
44 */
45 renewBefore?: Duration.Input;
46 /**
47 * Revoke the certificate at the CA when the resource is deleted. By
48 * default deletion only forgets the certificate.
49 * @default false
50 */
51 revokeOnDelete?: boolean;
52 /**
53 * Issuer name to prefer (`CN=…` substring, e.g. `ISRG Root X1`) when the
54 * CA offers alternate chains.
55 */
56 preferredChain?: string;
57 /** DNS propagation polling (resolvers, timeout, interval). */
58 propagation?: PropagationOptions;
59}
60
61export interface Certificate extends Resource<
62 "ACME.Certificate",
63 CertificateProps,
64 {
65 /** Leaf certificate PEM. */
66 certificate: string;
67 /** Full chain PEM, leaf first. */
68 chain: string;
69 /** PKCS#8 private key PEM. Sensitive; persisted in stack state. */
70 privateKey: Redacted.Redacted<string>;
71 /** Serial number, hex. */
72 serial: string;
73 /** ISO 8601 validity start. */
74 notBefore: string;
75 /** ISO 8601 expiry. Renewal is scheduled against it. */
76 notAfter: string;
77 /** Issuer distinguished name. */
78 issuer: string;
79 /** The DNS names covered. */
80 identifiers: string[];
81 /** The CA's certificate URL. */
82 certificateUrl: string;
83 /** The CA's order URL. */
84 orderUrl: string;
85 /** Key algorithm of `privateKey`. */
86 keyAlgorithm: KeyAlgorithm;
87 /** The account that issued the certificate. */
88 accountUrl: string;
89 /** The CA the certificate was issued by (for revocation on delete). */
90 directoryUrl: string;
91 /** PEM root trusted for the CA's endpoint (private CAs only). */
92 trustedRoot: string | undefined;
93 },
94 never,
95 Providers
96> {}
97
98/**
99 * A TLS certificate issued over ACME at deploy time, with renewal.
100 *
101 * Reconcile places an order, proves control of every name over DNS-01
102 * through the `solver`, finalizes with a fresh key's CSR and stores the
103 * chain and redacted private key in state. Protect the state store as
104 * secret material. `diff` reports an update when the
105 * names change or when `renewBefore` of validity remains, so a scheduled
106 * `alchemy deploy` keeps certificates fresh.
107 *
108 * ### Wildcard certificate
109 * **Example:** Let's Encrypt wildcard over Cloudflare DNS
110 * ```typescript
111 * const account = yield* ACME.Account("Issuer", {
112 * ca: ACME.LetsEncrypt,
113 * termsOfServiceAgreed: true,
114 * });
115 * const zone = yield* Zone;
116 * const wildcard = yield* ACME.Certificate("Wildcard", {
117 * account,
118 * identifiers: ["*.example.com", "example.com"],
119 * solver: Cloudflare.DNS.AcmeSolver(zone),
120 * });
121 * ```
122 *
123 * ### Using the certificate
124 * The chain and key are Outputs — pass them to whatever terminates TLS.
125 *
126 * **Example:** Upload to a Fly App
127 * ```typescript
128 * const www = yield* Fly.Certificate("Www", {
129 * app: Site,
130 * hostname: "www.example.com",
131 * kind: "custom",
132 * fullchain: wildcard.chain,
133 * privateKey: wildcard.privateKey,
134 * });
135 * ```
136 *
137 * ### Renewal
138 * **Example:** Renew earlier and revoke on delete
139 * ```typescript
140 * const api = yield* ACME.Certificate("Api", {
141 * account,
142 * identifiers: ["api.example.com"],
143 * solver: Cloudflare.DNS.AcmeSolver(zone),
144 * renewBefore: "45 days",
145 * revokeOnDelete: true,
146 * });
147 * ```
148 *
149 * @resource
150 * @product Certificate
151 * @category Certificates
152 */
153export const Certificate = Resource<Certificate>("ACME.Certificate");
154
155type AccountRef = {
156 directoryUrl: string;
157 trustedRoot?: string | undefined;
158 accountUrl: string;
159 privateKey: Redacted.Redacted<string>;
160};
161
162/** The resolved account attributes behind a resource-valued prop. */
163const accountOf = (value: unknown): AccountRef => value as AccountRef;
164
165const credentialsOf = (account: AccountRef): AccountCredentials => ({
166 ca: { directoryUrl: account.directoryUrl, trustedRoot: account.trustedRoot },
167 accountKey: account.privateKey,
168 accountUrl: account.accountUrl,
169});
170
171const DEFAULT_RENEW_BEFORE: Duration.Input = "30 days";
172
173const renewalDue = (
174 notAfter: string,
175 renewBefore: Duration.Input | undefined,
176 now: number,
177): boolean =>
178 Date.parse(notAfter) - now <
179 Duration.toMillis(renewBefore ?? DEFAULT_RENEW_BEFORE);
180
181export const CertificateProvider = () =>
182 Provider.succeed(Certificate, {
183 stables: ["directoryUrl"],
184
185 diff: Effect.fn(function* ({ news, olds, output }) {
186 if (news === undefined || !isResolved(news) || output === undefined) {
187 return undefined;
188 }
189 const account = accountOf(news.account);
190 if (account.accountUrl !== output.accountUrl) {
191 return { action: "replace" as const };
192 }
193 const now = yield* Effect.sync(() => Date.now());
194 const reissue =
195 !arrayEqualsUnordered(news.identifiers, output.identifiers) ||
196 (news.keyAlgorithm ?? "ES256") !== output.keyAlgorithm ||
197 news.preferredChain !== olds?.preferredChain ||
198 renewalDue(output.notAfter, news.renewBefore, now);
199 return reissue ? { action: "update" as const } : undefined;
200 }),
201
202 read: Effect.fn(function* ({ output }) {
203 return output;
204 }),
205
206 reconcile: Effect.fn(function* ({ news, olds, output }) {
207 const account = accountOf(news.account);
208 const keyAlgorithm = news.keyAlgorithm ?? "ES256";
209 const now = yield* Effect.sync(() => Date.now());
210 // Observe: the stored certificate is authoritative — nothing to do
211 // while it covers the names and isn't due.
212 if (
213 output !== undefined &&
214 output.accountUrl === account.accountUrl &&
215 arrayEqualsUnordered(news.identifiers, output.identifiers) &&
216 output.keyAlgorithm === keyAlgorithm &&
217 news.preferredChain === olds?.preferredChain &&
218 !renewalDue(output.notAfter, news.renewBefore, now)
219 ) {
220 return {
221 ...output,
222 directoryUrl: account.directoryUrl,
223 trustedRoot: account.trustedRoot,
224 };
225 }
226 const solver = yield* resolveDnsSolver(news.solver);
227 const issued = yield* issueCertificate({
228 identifiers: news.identifiers,
229 solver,
230 keyAlgorithm,
231 preferredChain: news.preferredChain,
232 propagation: news.propagation,
233 }).pipe(Effect.provide(accountLayer(credentialsOf(account))));
234 return {
235 certificate: issued.certificate,
236 chain: issued.chain,
237 privateKey: issued.privateKey,
238 serial: issued.serial,
239 notBefore: issued.notBefore,
240 notAfter: issued.notAfter,
241 issuer: issued.issuer,
242 identifiers: [...news.identifiers],
243 certificateUrl: issued.certificateUrl,
244 orderUrl: issued.orderUrl,
245 keyAlgorithm,
246 accountUrl: account.accountUrl,
247 directoryUrl: account.directoryUrl,
248 trustedRoot: account.trustedRoot,
249 };
250 }),
251
252 delete: Effect.fn(function* ({ olds, output }) {
253 if (olds.revokeOnDelete !== true) return;
254 const account = accountOf(olds.account);
255 // Signed with the certificate's own key, so revocation works even
256 // after the account was replaced or deactivated.
257 yield* revokeCertificate({
258 certificate: output.certificate,
259 privateKey: output.privateKey,
260 reason: "cessationOfOperation",
261 }).pipe(
262 Effect.provide(
263 accountLayer({
264 ca: {
265 directoryUrl: output.directoryUrl,
266 trustedRoot: output.trustedRoot,
267 },
268 accountKey: account.privateKey,
269 accountUrl: account.accountUrl,
270 }),
271 ),
272 );
273 }),
274 });