@alchemy.run/acme 0.0.0 → 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 +48 -4
- 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 +47 -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
package/src/Client.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
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 FetchHttpClient from "effect/http/FetchHttpClient";
|
|
14
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
15
|
+
import * as Layer from "effect/Layer";
|
|
16
|
+
import * as Redacted from "effect/Redacted";
|
|
17
|
+
import * as Schedule from "effect/Schedule";
|
|
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.getAuthorization({ url: authorizationUrl }).pipe(
|
|
193
|
+
Effect.repeat({
|
|
194
|
+
schedule: Schedule.spaced("2 seconds"),
|
|
195
|
+
until: (a) => !PENDING.has(a.status),
|
|
196
|
+
times: 8,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
if (settled.status !== "valid") {
|
|
200
|
+
const failed = settled.challenges.find((c) => c.type === "dns-01");
|
|
201
|
+
return yield* new ChallengeFailed({
|
|
202
|
+
identifier,
|
|
203
|
+
type: failed?.error?.type,
|
|
204
|
+
detail: failed?.error?.detail ?? `authorization is ${settled.status}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return settled;
|
|
208
|
+
}),
|
|
209
|
+
() => solver.cleanup(record),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const chooseChain = (
|
|
214
|
+
downloaded: { chain: string; alternates?: ReadonlyArray<string> },
|
|
215
|
+
preferredChain: string | undefined,
|
|
216
|
+
) =>
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
const matches = (chain: string) =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const blocks = splitPemChain(chain);
|
|
221
|
+
const top = blocks[blocks.length - 1];
|
|
222
|
+
if (top === undefined) return false;
|
|
223
|
+
const parsed = yield* parseCertificate(top).pipe(Effect.orElseSucceed(() => undefined));
|
|
224
|
+
return parsed?.issuer.includes(preferredChain!) === true;
|
|
225
|
+
});
|
|
226
|
+
if (preferredChain === undefined) return downloaded.chain;
|
|
227
|
+
if (yield* matches(downloaded.chain)) return downloaded.chain;
|
|
228
|
+
for (const url of downloaded.alternates ?? []) {
|
|
229
|
+
const alternate = yield* acme.downloadCertificate({ url });
|
|
230
|
+
if (yield* matches(alternate.chain)) return alternate.chain;
|
|
231
|
+
}
|
|
232
|
+
return downloaded.chain;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Issue a certificate for `identifiers` as the current `Credentials`
|
|
237
|
+
* account. Requires the SDK's `Credentials` and an `HttpClient` (see
|
|
238
|
+
* {@link accountLayer}) plus whatever the solver needs.
|
|
239
|
+
*/
|
|
240
|
+
export const issueCertificate = <R = never>(
|
|
241
|
+
request: IssueRequest<R>,
|
|
242
|
+
): Effect.Effect<IssuedCertificate, IssueError, Acme.Credentials | HttpClient.HttpClient | R> =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const resolve = yield* Acme.Credentials;
|
|
245
|
+
const config = yield* resolve;
|
|
246
|
+
const jwk = yield* Acme.Jose.parseJwk(config.accountKey);
|
|
247
|
+
const propagation = request.propagation ?? {};
|
|
248
|
+
|
|
249
|
+
const order = yield* acme.newOrder({
|
|
250
|
+
identifiers: request.identifiers.map((value) => ({ type: "dns", value })),
|
|
251
|
+
});
|
|
252
|
+
if (order.location === undefined) {
|
|
253
|
+
return yield* new OrderInvalid({
|
|
254
|
+
orderUrl: order.finalize,
|
|
255
|
+
problems: [{ detail: "The CA omitted the order Location header." }],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const orderUrl = order.location;
|
|
259
|
+
|
|
260
|
+
// Each authorization removes its own TXT record on success or failure.
|
|
261
|
+
const authorizations = yield* Effect.forEach(
|
|
262
|
+
order.authorizations,
|
|
263
|
+
(url) => solveAuthorization(url, jwk, request.solver, propagation),
|
|
264
|
+
{ concurrency: 4 },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const ready = yield* acme.getOrder({ url: orderUrl }).pipe(
|
|
268
|
+
Effect.repeat({
|
|
269
|
+
schedule: Schedule.spaced("2 seconds"),
|
|
270
|
+
until: (value) => value.status !== "pending",
|
|
271
|
+
times: 8,
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
if (ready.status === "invalid") {
|
|
275
|
+
return yield* new OrderInvalid({
|
|
276
|
+
orderUrl,
|
|
277
|
+
problems: problemsOf(ready, authorizations),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (ready.status !== "ready") {
|
|
281
|
+
return yield* new OrderTimeout({ orderUrl, status: ready.status });
|
|
282
|
+
}
|
|
283
|
+
const key = yield* generateKey(request.keyAlgorithm ?? "ES256");
|
|
284
|
+
const csr = yield* createCsr({ key, identifiers: request.identifiers });
|
|
285
|
+
yield* acme.finalizeOrder({ url: order.finalize, csr: csrToBase64Url(csr) }).pipe(
|
|
286
|
+
Effect.retry({
|
|
287
|
+
while: (error) => error._tag === "AcmeOrderNotReady",
|
|
288
|
+
schedule: Schedule.spaced("1 second"),
|
|
289
|
+
times: 8,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const settled = yield* acme.getOrder({ url: orderUrl }).pipe(
|
|
294
|
+
Effect.repeat({
|
|
295
|
+
schedule: Schedule.spaced("2 seconds"),
|
|
296
|
+
until: (o) => o.status === "valid" || o.status === "invalid",
|
|
297
|
+
times: 8,
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
if (settled.status === "invalid") {
|
|
301
|
+
return yield* new OrderInvalid({
|
|
302
|
+
orderUrl,
|
|
303
|
+
problems: problemsOf(settled, authorizations),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (settled.status !== "valid" || settled.certificate === undefined) {
|
|
307
|
+
return yield* new OrderTimeout({ orderUrl, status: settled.status });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const downloaded = yield* acme.downloadCertificate({
|
|
311
|
+
url: settled.certificate,
|
|
312
|
+
});
|
|
313
|
+
const chain = yield* chooseChain(downloaded, request.preferredChain);
|
|
314
|
+
const certificate = splitPemChain(chain)[0] ?? chain;
|
|
315
|
+
const parsed = yield* parseCertificate(certificate);
|
|
316
|
+
return toIssued({
|
|
317
|
+
certificate,
|
|
318
|
+
chain,
|
|
319
|
+
privateKey: key.privateKeyPem,
|
|
320
|
+
identifiers: request.identifiers,
|
|
321
|
+
certificateUrl: settled.certificate,
|
|
322
|
+
orderUrl,
|
|
323
|
+
parsed,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const toIssued = (input: {
|
|
328
|
+
certificate: string;
|
|
329
|
+
chain: string;
|
|
330
|
+
privateKey: Redacted.Redacted<string>;
|
|
331
|
+
identifiers: ReadonlyArray<string>;
|
|
332
|
+
certificateUrl: string;
|
|
333
|
+
orderUrl: string;
|
|
334
|
+
parsed: ParsedCertificate;
|
|
335
|
+
}): IssuedCertificate => ({
|
|
336
|
+
certificate: input.certificate,
|
|
337
|
+
chain: input.chain,
|
|
338
|
+
privateKey: input.privateKey,
|
|
339
|
+
identifiers: input.identifiers,
|
|
340
|
+
serial: input.parsed.serial,
|
|
341
|
+
notBefore: input.parsed.notBefore.toISOString(),
|
|
342
|
+
notAfter: input.parsed.notAfter.toISOString(),
|
|
343
|
+
issuer: input.parsed.issuer,
|
|
344
|
+
certificateUrl: input.certificateUrl,
|
|
345
|
+
orderUrl: input.orderUrl,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
/** RFC 5280 revocation reasons. */
|
|
349
|
+
export type RevocationReason =
|
|
350
|
+
| "unspecified"
|
|
351
|
+
| "keyCompromise"
|
|
352
|
+
| "affiliationChanged"
|
|
353
|
+
| "superseded"
|
|
354
|
+
| "cessationOfOperation";
|
|
355
|
+
|
|
356
|
+
const REASON_CODES: Record<RevocationReason, number> = {
|
|
357
|
+
unspecified: 0,
|
|
358
|
+
keyCompromise: 1,
|
|
359
|
+
affiliationChanged: 3,
|
|
360
|
+
superseded: 4,
|
|
361
|
+
cessationOfOperation: 5,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export interface RevokeRequest {
|
|
365
|
+
/** Leaf certificate PEM. */
|
|
366
|
+
readonly certificate: string;
|
|
367
|
+
/**
|
|
368
|
+
* The certificate's private key. When given, the request is signed with
|
|
369
|
+
* it (RFC 8555 §7.6) so no account is needed; otherwise the ambient
|
|
370
|
+
* account credentials sign.
|
|
371
|
+
*/
|
|
372
|
+
readonly privateKey?: Redacted.Redacted<string> | string | undefined;
|
|
373
|
+
readonly reason?: RevocationReason | undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export type RevokeError = PkiError | acme.RevokeCertificateError;
|
|
377
|
+
|
|
378
|
+
/** Revoke a certificate. `AcmeAlreadyRevoked` is folded into success. */
|
|
379
|
+
export const revokeCertificate = (
|
|
380
|
+
request: RevokeRequest,
|
|
381
|
+
): Effect.Effect<void, RevokeError, Acme.Credentials | HttpClient.HttpClient> =>
|
|
382
|
+
Effect.gen(function* () {
|
|
383
|
+
const leaf = splitPemChain(request.certificate)[0] ?? request.certificate;
|
|
384
|
+
const der = yield* Effect.try({
|
|
385
|
+
try: () => fromPem(leaf),
|
|
386
|
+
catch: (cause) => new PkiError({ message: "The certificate is not PEM.", cause }),
|
|
387
|
+
});
|
|
388
|
+
const revoke = acme
|
|
389
|
+
.revokeCertificate({
|
|
390
|
+
certificate: Acme.Jose.base64url(der),
|
|
391
|
+
reason: request.reason === undefined ? undefined : REASON_CODES[request.reason],
|
|
392
|
+
})
|
|
393
|
+
.pipe(Effect.catchTag("AcmeAlreadyRevoked", () => Effect.void));
|
|
394
|
+
if (request.privateKey === undefined) {
|
|
395
|
+
return yield* revoke;
|
|
396
|
+
}
|
|
397
|
+
// Sign with the certificate key (RFC 8555 §7.6): swap the account key
|
|
398
|
+
// and drop the account URL so the protocol embeds the JWK.
|
|
399
|
+
const resolve = yield* Acme.Credentials;
|
|
400
|
+
const config = yield* resolve;
|
|
401
|
+
const jwk = yield* privateKeyToJwk(request.privateKey);
|
|
402
|
+
return yield* revoke.pipe(
|
|
403
|
+
Effect.provide(Acme.layer({ directoryUrl: config.directoryUrl, accountKey: jwk })),
|
|
404
|
+
);
|
|
405
|
+
});
|
package/src/Der.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal DER encoder/decoder — just enough ASN.1 for a PKCS#10
|
|
3
|
+
* certificate request (encode) and reading a certificate's serial,
|
|
4
|
+
* validity, issuer and subject alternative names (decode). Pure
|
|
5
|
+
* TypeScript; no native code, no dependency, works on workerd.
|
|
6
|
+
*
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const concat = (...parts: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
11
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
12
|
+
let offset = 0;
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
out.set(part, offset);
|
|
15
|
+
offset += part.length;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const encodeLength = (n: number): Uint8Array => {
|
|
21
|
+
if (n < 0x80) return Uint8Array.of(n);
|
|
22
|
+
if (n < 0x100) return Uint8Array.of(0x81, n);
|
|
23
|
+
if (n < 0x10000) return Uint8Array.of(0x82, n >>> 8, n & 0xff);
|
|
24
|
+
return Uint8Array.of(0x83, n >>> 16, (n >>> 8) & 0xff, n & 0xff);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Tag-length-value. */
|
|
28
|
+
export const tlv = (tag: number, body: Uint8Array): Uint8Array =>
|
|
29
|
+
concat(Uint8Array.of(tag), encodeLength(body.length), body);
|
|
30
|
+
|
|
31
|
+
export const sequence = (...items: ReadonlyArray<Uint8Array>) => tlv(0x30, concat(...items));
|
|
32
|
+
export const set = (...items: ReadonlyArray<Uint8Array>) => tlv(0x31, concat(...items));
|
|
33
|
+
export const octetString = (body: Uint8Array) => tlv(0x04, body);
|
|
34
|
+
export const bitString = (body: Uint8Array) => tlv(0x03, concat(Uint8Array.of(0), body));
|
|
35
|
+
export const nullValue = () => Uint8Array.of(0x05, 0x00);
|
|
36
|
+
export const ia5String = (text: string) => tlv(0x16, new TextEncoder().encode(text));
|
|
37
|
+
export const utf8String = (text: string) => tlv(0x0c, new TextEncoder().encode(text));
|
|
38
|
+
/** Context-specific constructed tag `[n]` (implicit or explicit, caller's choice of body). */
|
|
39
|
+
export const contextTag = (n: number, body: Uint8Array) => tlv(0xa0 | n, body);
|
|
40
|
+
/** Context-specific primitive tag `[n]` (e.g. GeneralName dNSName is `[2]`). */
|
|
41
|
+
export const contextPrimitive = (n: number, body: Uint8Array) => tlv(0x80 | n, body);
|
|
42
|
+
|
|
43
|
+
/** INTEGER from a non-negative number or big-endian magnitude bytes. */
|
|
44
|
+
export const integer = (value: number | Uint8Array): Uint8Array => {
|
|
45
|
+
if (typeof value === "number") {
|
|
46
|
+
if (value === 0) return tlv(0x02, Uint8Array.of(0));
|
|
47
|
+
const bytes: number[] = [];
|
|
48
|
+
let n = value;
|
|
49
|
+
while (n > 0) {
|
|
50
|
+
bytes.unshift(n & 0xff);
|
|
51
|
+
n = Math.floor(n / 256);
|
|
52
|
+
}
|
|
53
|
+
if (bytes[0]! & 0x80) bytes.unshift(0);
|
|
54
|
+
return tlv(0x02, Uint8Array.from(bytes));
|
|
55
|
+
}
|
|
56
|
+
let start = 0;
|
|
57
|
+
while (start < value.length - 1 && value[start] === 0) start++;
|
|
58
|
+
const magnitude = value.subarray(start);
|
|
59
|
+
return tlv(0x02, magnitude[0]! & 0x80 ? concat(Uint8Array.of(0), magnitude) : magnitude);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** OBJECT IDENTIFIER from dotted notation. */
|
|
63
|
+
export const oid = (dotted: string): Uint8Array => {
|
|
64
|
+
const parts = dotted.split(".").map(Number);
|
|
65
|
+
const bytes: number[] = [parts[0]! * 40 + parts[1]!];
|
|
66
|
+
for (const part of parts.slice(2)) {
|
|
67
|
+
const stack: number[] = [part & 0x7f];
|
|
68
|
+
let n = Math.floor(part / 128);
|
|
69
|
+
while (n > 0) {
|
|
70
|
+
stack.unshift((n & 0x7f) | 0x80);
|
|
71
|
+
n = Math.floor(n / 128);
|
|
72
|
+
}
|
|
73
|
+
bytes.push(...stack);
|
|
74
|
+
}
|
|
75
|
+
return tlv(0x06, Uint8Array.from(bytes));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Decoding
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export interface Node {
|
|
83
|
+
readonly tag: number;
|
|
84
|
+
/** Offset of the first content byte. */
|
|
85
|
+
readonly start: number;
|
|
86
|
+
/** Offset one past the last content byte. */
|
|
87
|
+
readonly end: number;
|
|
88
|
+
readonly bytes: Uint8Array;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const read = (bytes: Uint8Array, offset: number): Node => {
|
|
92
|
+
const tag = bytes[offset]!;
|
|
93
|
+
let cursor = offset + 1;
|
|
94
|
+
let length = bytes[cursor++]!;
|
|
95
|
+
if (length & 0x80) {
|
|
96
|
+
const count = length & 0x7f;
|
|
97
|
+
length = 0;
|
|
98
|
+
for (let i = 0; i < count; i++) length = length * 256 + bytes[cursor++]!;
|
|
99
|
+
}
|
|
100
|
+
return { tag, start: cursor, end: cursor + length, bytes };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The children of a constructed node, in order. */
|
|
104
|
+
export const children = (node: Node): Node[] => {
|
|
105
|
+
const out: Node[] = [];
|
|
106
|
+
let cursor = node.start;
|
|
107
|
+
while (cursor < node.end) {
|
|
108
|
+
const child = read(node.bytes, cursor);
|
|
109
|
+
out.push(child);
|
|
110
|
+
cursor = child.end;
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const content = (node: Node): Uint8Array => node.bytes.subarray(node.start, node.end);
|
|
116
|
+
|
|
117
|
+
export const decodeOid = (node: Node): string => {
|
|
118
|
+
const body = content(node);
|
|
119
|
+
const first = body[0]!;
|
|
120
|
+
const parts = [Math.floor(first / 40), first % 40];
|
|
121
|
+
let n = 0;
|
|
122
|
+
for (let i = 1; i < body.length; i++) {
|
|
123
|
+
n = n * 128 + (body[i]! & 0x7f);
|
|
124
|
+
if ((body[i]! & 0x80) === 0) {
|
|
125
|
+
parts.push(n);
|
|
126
|
+
n = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// OIDs under joint-iso-itu-t(2) can have a first arc ≥ 2 with large second arcs.
|
|
130
|
+
if (first >= 80) {
|
|
131
|
+
parts[0] = 2;
|
|
132
|
+
parts[1] = first - 80;
|
|
133
|
+
}
|
|
134
|
+
return parts.join(".");
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Parse UTCTime (`YYMMDDHHMMSSZ`) or GeneralizedTime (`YYYYMMDDHHMMSSZ`). */
|
|
138
|
+
export const decodeTime = (node: Node): Date => {
|
|
139
|
+
const text = new TextDecoder().decode(content(node));
|
|
140
|
+
const generalized = node.tag === 0x18;
|
|
141
|
+
const year = generalized
|
|
142
|
+
? Number(text.slice(0, 4))
|
|
143
|
+
: (() => {
|
|
144
|
+
const yy = Number(text.slice(0, 2));
|
|
145
|
+
return yy < 50 ? 2000 + yy : 1900 + yy;
|
|
146
|
+
})();
|
|
147
|
+
const rest = generalized ? text.slice(4) : text.slice(2);
|
|
148
|
+
const month = Number(rest.slice(0, 2));
|
|
149
|
+
const day = Number(rest.slice(2, 4));
|
|
150
|
+
const hour = Number(rest.slice(4, 6));
|
|
151
|
+
const minute = Number(rest.slice(6, 8));
|
|
152
|
+
const second = Number(rest.slice(8, 10)) || 0;
|
|
153
|
+
return new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const toHex = (bytes: Uint8Array): string =>
|
|
157
|
+
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|