@alchemy.run/acme 0.0.0-stage → 2.0.0-beta.84
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.
- package/LICENSE +201 -0
- package/README.md +52 -2
- package/lib/Account.d.ts +121 -0
- package/lib/Account.d.ts.map +1 -0
- package/lib/Account.js +163 -0
- package/lib/Account.js.map +1 -0
- package/lib/Certificate.d.ts +138 -0
- package/lib/Certificate.d.ts.map +1 -0
- package/lib/Certificate.js +157 -0
- package/lib/Certificate.js.map +1 -0
- package/lib/CertificateAuthority.d.ts +25 -0
- package/lib/CertificateAuthority.d.ts.map +1 -0
- package/lib/CertificateAuthority.js +22 -0
- package/lib/CertificateAuthority.js.map +1 -0
- package/lib/Client.d.ts +89 -0
- package/lib/Client.d.ts.map +1 -0
- package/lib/Client.js +237 -0
- package/lib/Client.js.map +1 -0
- package/lib/Der.d.ts +43 -0
- package/lib/Der.d.ts.map +1 -0
- package/lib/Der.js +138 -0
- package/lib/Der.js.map +1 -0
- package/lib/Dns.d.ts +59 -0
- package/lib/Dns.d.ts.map +1 -0
- package/lib/Dns.js +104 -0
- package/lib/Dns.js.map +1 -0
- package/lib/DnsSolver.d.ts +60 -0
- package/lib/DnsSolver.d.ts.map +1 -0
- package/lib/DnsSolver.js +48 -0
- package/lib/DnsSolver.js.map +1 -0
- package/lib/Errors.d.ts +93 -0
- package/lib/Errors.d.ts.map +1 -0
- package/lib/Errors.js +51 -0
- package/lib/Errors.js.map +1 -0
- package/lib/IssueCertificate.d.ts +76 -0
- package/lib/IssueCertificate.d.ts.map +1 -0
- package/lib/IssueCertificate.js +3 -0
- package/lib/IssueCertificate.js.map +1 -0
- package/lib/IssueCertificateHttp.d.ts +24 -0
- package/lib/IssueCertificateHttp.d.ts.map +1 -0
- package/lib/IssueCertificateHttp.js +56 -0
- package/lib/IssueCertificateHttp.js.map +1 -0
- package/lib/Pki.d.ts +56 -0
- package/lib/Pki.d.ts.map +1 -0
- package/lib/Pki.js +182 -0
- package/lib/Pki.js.map +1 -0
- package/lib/Providers.d.ts +30 -0
- package/lib/Providers.d.ts.map +1 -0
- package/lib/Providers.js +31 -0
- package/lib/Providers.js.map +1 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/package.json +62 -3
- package/src/Account.ts +256 -0
- package/src/Certificate.ts +272 -0
- package/src/CertificateAuthority.ts +41 -0
- package/src/Client.ts +405 -0
- package/src/Der.ts +157 -0
- package/src/Dns.ts +227 -0
- package/src/DnsSolver.ts +109 -0
- package/src/Errors.ts +90 -0
- package/src/IssueCertificate.ts +89 -0
- package/src/IssueCertificateHttp.ts +67 -0
- package/src/Pki.ts +275 -0
- package/src/Providers.ts +39 -0
- 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
|
+
};
|