@alchemy.run/acme 0.0.0 → 2.0.0-beta.85

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.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -4
  3. package/lib/Account.d.ts +121 -0
  4. package/lib/Account.d.ts.map +1 -0
  5. package/lib/Account.js +163 -0
  6. package/lib/Account.js.map +1 -0
  7. package/lib/Certificate.d.ts +138 -0
  8. package/lib/Certificate.d.ts.map +1 -0
  9. package/lib/Certificate.js +157 -0
  10. package/lib/Certificate.js.map +1 -0
  11. package/lib/CertificateAuthority.d.ts +25 -0
  12. package/lib/CertificateAuthority.d.ts.map +1 -0
  13. package/lib/CertificateAuthority.js +22 -0
  14. package/lib/CertificateAuthority.js.map +1 -0
  15. package/lib/Client.d.ts +89 -0
  16. package/lib/Client.d.ts.map +1 -0
  17. package/lib/Client.js +237 -0
  18. package/lib/Client.js.map +1 -0
  19. package/lib/Der.d.ts +43 -0
  20. package/lib/Der.d.ts.map +1 -0
  21. package/lib/Der.js +138 -0
  22. package/lib/Der.js.map +1 -0
  23. package/lib/Dns.d.ts +59 -0
  24. package/lib/Dns.d.ts.map +1 -0
  25. package/lib/Dns.js +104 -0
  26. package/lib/Dns.js.map +1 -0
  27. package/lib/DnsSolver.d.ts +60 -0
  28. package/lib/DnsSolver.d.ts.map +1 -0
  29. package/lib/DnsSolver.js +48 -0
  30. package/lib/DnsSolver.js.map +1 -0
  31. package/lib/Errors.d.ts +93 -0
  32. package/lib/Errors.d.ts.map +1 -0
  33. package/lib/Errors.js +51 -0
  34. package/lib/Errors.js.map +1 -0
  35. package/lib/IssueCertificate.d.ts +76 -0
  36. package/lib/IssueCertificate.d.ts.map +1 -0
  37. package/lib/IssueCertificate.js +3 -0
  38. package/lib/IssueCertificate.js.map +1 -0
  39. package/lib/IssueCertificateHttp.d.ts +24 -0
  40. package/lib/IssueCertificateHttp.d.ts.map +1 -0
  41. package/lib/IssueCertificateHttp.js +56 -0
  42. package/lib/IssueCertificateHttp.js.map +1 -0
  43. package/lib/Pki.d.ts +56 -0
  44. package/lib/Pki.d.ts.map +1 -0
  45. package/lib/Pki.js +182 -0
  46. package/lib/Pki.js.map +1 -0
  47. package/lib/Providers.d.ts +30 -0
  48. package/lib/Providers.d.ts.map +1 -0
  49. package/lib/Providers.js +31 -0
  50. package/lib/Providers.js.map +1 -0
  51. package/lib/index.d.ts +12 -0
  52. package/lib/index.d.ts.map +1 -0
  53. package/lib/index.js +12 -0
  54. package/lib/index.js.map +1 -0
  55. package/package.json +47 -3
  56. package/src/Account.ts +254 -0
  57. package/src/Certificate.ts +272 -0
  58. package/src/CertificateAuthority.ts +41 -0
  59. package/src/Client.ts +405 -0
  60. package/src/Der.ts +157 -0
  61. package/src/Dns.ts +227 -0
  62. package/src/DnsSolver.ts +109 -0
  63. package/src/Errors.ts +90 -0
  64. package/src/IssueCertificate.ts +89 -0
  65. package/src/IssueCertificateHttp.ts +67 -0
  66. package/src/Pki.ts +275 -0
  67. package/src/Providers.ts +39 -0
  68. package/src/index.ts +11 -0
@@ -0,0 +1,272 @@
1
+ import { isResolved } from "alchemy/Diff";
2
+ import * as Provider from "alchemy/Provider";
3
+ import { Resource } from "alchemy/Resource";
4
+ import { arrayEqualsUnordered } from "alchemy/Util/equal";
5
+ import * as Duration from "effect/Duration";
6
+ import * as Effect from "effect/Effect";
7
+ import type * as Redacted from "effect/Redacted";
8
+ import type { Account } from "./Account.ts";
9
+ import {
10
+ accountLayer,
11
+ issueCertificate,
12
+ revokeCertificate,
13
+ type AccountCredentials,
14
+ } from "./Client.ts";
15
+ import type { PropagationOptions } from "./Dns.ts";
16
+ import { resolveDnsSolver, type DnsSolverDescriptor } from "./DnsSolver.ts";
17
+ import type { KeyAlgorithm } from "./Pki.ts";
18
+ import type { Providers } from "./Providers.ts";
19
+
20
+ export 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.ACME.ZoneSolver(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
+
61
+ export 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.ACME.ZoneSolver(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.ACME.ZoneSolver(zone),
144
+ * renewBefore: "45 days",
145
+ * revokeOnDelete: true,
146
+ * });
147
+ * ```
148
+ *
149
+ * @resource
150
+ * @product Certificate
151
+ * @category Certificates
152
+ */
153
+ export const Certificate = Resource<Certificate>("ACME.Certificate");
154
+
155
+ type 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. */
163
+ const accountOf = (value: unknown): AccountRef => value as AccountRef;
164
+
165
+ const credentialsOf = (account: AccountRef): AccountCredentials => ({
166
+ ca: { directoryUrl: account.directoryUrl, trustedRoot: account.trustedRoot },
167
+ accountKey: account.privateKey,
168
+ accountUrl: account.accountUrl,
169
+ });
170
+
171
+ const DEFAULT_RENEW_BEFORE: Duration.Input = "30 days";
172
+
173
+ const renewalDue = (
174
+ notAfter: string,
175
+ renewBefore: Duration.Input | undefined,
176
+ now: number,
177
+ ): boolean => Date.parse(notAfter) - now < Duration.toMillis(renewBefore ?? DEFAULT_RENEW_BEFORE);
178
+
179
+ export const CertificateProvider = () =>
180
+ Provider.succeed(Certificate, {
181
+ stables: ["directoryUrl"],
182
+
183
+ diff: Effect.fn(function* ({ news, olds, output }) {
184
+ if (news === undefined || !isResolved(news) || output === undefined) {
185
+ return undefined;
186
+ }
187
+ const account = accountOf(news.account);
188
+ if (account.accountUrl !== output.accountUrl) {
189
+ return { action: "replace" as const };
190
+ }
191
+ const now = yield* Effect.sync(() => Date.now());
192
+ const reissue =
193
+ !arrayEqualsUnordered(news.identifiers, output.identifiers) ||
194
+ (news.keyAlgorithm ?? "ES256") !== output.keyAlgorithm ||
195
+ news.preferredChain !== olds?.preferredChain ||
196
+ renewalDue(output.notAfter, news.renewBefore, now);
197
+ return reissue ? { action: "update" as const } : undefined;
198
+ }),
199
+
200
+ read: Effect.fn(function* ({ output }) {
201
+ return output;
202
+ }),
203
+
204
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
205
+ const account = accountOf(news.account);
206
+ const keyAlgorithm = news.keyAlgorithm ?? "ES256";
207
+ const now = yield* Effect.sync(() => Date.now());
208
+ // Observe: the stored certificate is authoritative — nothing to do
209
+ // while it covers the names and isn't due.
210
+ if (
211
+ output !== undefined &&
212
+ output.accountUrl === account.accountUrl &&
213
+ arrayEqualsUnordered(news.identifiers, output.identifiers) &&
214
+ output.keyAlgorithm === keyAlgorithm &&
215
+ news.preferredChain === olds?.preferredChain &&
216
+ !renewalDue(output.notAfter, news.renewBefore, now)
217
+ ) {
218
+ return {
219
+ ...output,
220
+ directoryUrl: account.directoryUrl,
221
+ trustedRoot: account.trustedRoot,
222
+ };
223
+ }
224
+ const solver = yield* resolveDnsSolver(news.solver);
225
+ const issued = yield* issueCertificate({
226
+ identifiers: news.identifiers,
227
+ solver,
228
+ keyAlgorithm,
229
+ preferredChain: news.preferredChain,
230
+ propagation: news.propagation,
231
+ }).pipe(Effect.provide(accountLayer(credentialsOf(account))));
232
+ return {
233
+ certificate: issued.certificate,
234
+ chain: issued.chain,
235
+ privateKey: issued.privateKey,
236
+ serial: issued.serial,
237
+ notBefore: issued.notBefore,
238
+ notAfter: issued.notAfter,
239
+ issuer: issued.issuer,
240
+ identifiers: [...news.identifiers],
241
+ certificateUrl: issued.certificateUrl,
242
+ orderUrl: issued.orderUrl,
243
+ keyAlgorithm,
244
+ accountUrl: account.accountUrl,
245
+ directoryUrl: account.directoryUrl,
246
+ trustedRoot: account.trustedRoot,
247
+ };
248
+ }),
249
+
250
+ delete: Effect.fn(function* ({ olds, output }) {
251
+ if (olds.revokeOnDelete !== true) return;
252
+ const account = accountOf(olds.account);
253
+ // Signed with the certificate's own key, so revocation works even
254
+ // after the account was replaced or deactivated.
255
+ yield* revokeCertificate({
256
+ certificate: output.certificate,
257
+ privateKey: output.privateKey,
258
+ reason: "cessationOfOperation",
259
+ }).pipe(
260
+ Effect.provide(
261
+ accountLayer({
262
+ ca: {
263
+ directoryUrl: output.directoryUrl,
264
+ trustedRoot: output.trustedRoot,
265
+ },
266
+ accountKey: account.privateKey,
267
+ accountUrl: account.accountUrl,
268
+ }),
269
+ ),
270
+ );
271
+ }),
272
+ });
@@ -0,0 +1,41 @@
1
+ import { Directories } from "@distilled.cloud/acme";
2
+
3
+ /**
4
+ * An ACME certificate authority: its directory URL plus, for private CAs
5
+ * (Pebble, step-ca), the PEM root that signs the CA's own HTTPS endpoint.
6
+ */
7
+ export interface CertificateAuthority {
8
+ /** The CA's ACME directory URL. */
9
+ readonly directoryUrl: string;
10
+ /**
11
+ * PEM root certificate to trust for the CA's HTTPS endpoint. Only for
12
+ * private CAs whose API certificate is not publicly trusted; public CAs
13
+ * leave it unset.
14
+ */
15
+ readonly trustedRoot?: string | undefined;
16
+ }
17
+
18
+ /** Let's Encrypt production. No External Account Binding. */
19
+ export const LetsEncrypt: CertificateAuthority = {
20
+ directoryUrl: Directories.LetsEncrypt,
21
+ };
22
+
23
+ /** Let's Encrypt staging: generous limits, untrusted chain. */
24
+ export const LetsEncryptStaging: CertificateAuthority = {
25
+ directoryUrl: Directories.LetsEncryptStaging,
26
+ };
27
+
28
+ /** ZeroSSL. Requires an External Account Binding (`eab`). */
29
+ export const ZeroSSL: CertificateAuthority = {
30
+ directoryUrl: Directories.ZeroSSL,
31
+ };
32
+
33
+ /** Google Trust Services. Requires an External Account Binding (`eab`). */
34
+ export const GoogleTrustServices: CertificateAuthority = {
35
+ directoryUrl: Directories.GoogleTrustServices,
36
+ };
37
+
38
+ /** Google Trust Services staging. */
39
+ export const GoogleTrustServicesStaging: CertificateAuthority = {
40
+ directoryUrl: Directories.GoogleTrustServicesStaging,
41
+ };