@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.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -2
  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 +62 -3
  56. package/src/Account.ts +256 -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
package/src/Pki.ts ADDED
@@ -0,0 +1,275 @@
1
+ import { Jose } from "@distilled.cloud/acme";
2
+ /**
3
+ * Keys, PKCS#10 certificate requests and X.509 parsing for the ACME flow.
4
+ * WebCrypto plus the tiny DER codec in `Der.ts` — no native code, so the
5
+ * same code runs at deploy time and inside a Worker or Fly Service.
6
+ */
7
+ import * as Effect from "effect/Effect";
8
+ import * as Redacted from "effect/Redacted";
9
+ import * as Der from "./Der.ts";
10
+ import { PkiError } from "./Errors.ts";
11
+
12
+ export type KeyAlgorithm = "ES256" | "RS256";
13
+
14
+ const OID = {
15
+ commonName: "2.5.4.3",
16
+ organization: "2.5.4.10",
17
+ extensionRequest: "1.2.840.113549.1.9.14",
18
+ subjectAltName: "2.5.29.17",
19
+ ecdsaWithSha256: "1.2.840.10045.4.3.2",
20
+ sha256WithRsa: "1.2.840.113549.1.1.11",
21
+ } as const;
22
+
23
+ const keyGenParams = (alg: KeyAlgorithm) =>
24
+ alg === "RS256"
25
+ ? {
26
+ name: "RSASSA-PKCS1-v1_5",
27
+ modulusLength: 2048,
28
+ publicExponent: new Uint8Array([1, 0, 1]),
29
+ hash: "SHA-256",
30
+ }
31
+ : { name: "ECDSA", namedCurve: "P-256" };
32
+
33
+ const importParams = (alg: KeyAlgorithm) =>
34
+ alg === "RS256"
35
+ ? { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
36
+ : { name: "ECDSA", namedCurve: "P-256" };
37
+
38
+ const signParams = (alg: KeyAlgorithm) =>
39
+ alg === "RS256" ? { name: "RSASSA-PKCS1-v1_5" } : { name: "ECDSA", hash: "SHA-256" };
40
+
41
+ const toPem = (label: string, der: Uint8Array): string => {
42
+ const b64 = btoa(String.fromCharCode(...der));
43
+ const lines = b64.match(/.{1,64}/g) ?? [];
44
+ return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
45
+ };
46
+
47
+ /** The DER bytes of the first (or `index`-th) PEM block in `pem`. */
48
+ export const fromPem = (pem: string, index = 0): Uint8Array => {
49
+ const blocks = pem.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g);
50
+ const block = blocks?.[index];
51
+ if (block === undefined) {
52
+ throw new Error(`PEM block ${index} not found`);
53
+ }
54
+ const body = block
55
+ .replace(/-----BEGIN [^-]+-----/, "")
56
+ .replace(/-----END [^-]+-----/, "")
57
+ .replace(/\s+/g, "");
58
+ const binary = atob(body);
59
+ const out = new Uint8Array(binary.length);
60
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
61
+ return out;
62
+ };
63
+
64
+ /** Every PEM block in a chain, leaf first. */
65
+ export const splitPemChain = (pem: string): string[] =>
66
+ pem.match(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g) ?? [];
67
+
68
+ const tryPromise = <A>(message: string, run: () => Promise<A>) =>
69
+ Effect.tryPromise({
70
+ try: run,
71
+ catch: (cause) => new PkiError({ message, cause }),
72
+ });
73
+
74
+ export interface GeneratedKey {
75
+ readonly algorithm: KeyAlgorithm;
76
+ /** PKCS#8 PEM. */
77
+ readonly privateKeyPem: Redacted.Redacted<string>;
78
+ readonly privateKey: CryptoKey;
79
+ /** SubjectPublicKeyInfo DER. */
80
+ readonly spki: Uint8Array;
81
+ }
82
+
83
+ /** Generate a certificate key pair (ES256 P-256 by default). */
84
+ export const generateKey = (
85
+ algorithm: KeyAlgorithm = "ES256",
86
+ ): Effect.Effect<GeneratedKey, PkiError> =>
87
+ tryPromise("Generating the certificate key failed.", async () => {
88
+ const pair = await crypto.subtle.generateKey(keyGenParams(algorithm), true, ["sign", "verify"]);
89
+ const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
90
+ const spki = new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey));
91
+ return {
92
+ algorithm,
93
+ privateKeyPem: Redacted.make(toPem("PRIVATE KEY", pkcs8)),
94
+ privateKey: pair.privateKey,
95
+ spki,
96
+ };
97
+ });
98
+
99
+ const algorithmOfPkcs8 = (der: Uint8Array): KeyAlgorithm => {
100
+ // PrivateKeyInfo ::= SEQUENCE { version, AlgorithmIdentifier, ... }
101
+ const root = Der.read(der, 0);
102
+ const [, algorithm] = Der.children(root);
103
+ const [algOid] = Der.children(algorithm!);
104
+ return Der.decodeOid(algOid!) === "1.2.840.113549.1.1.1" ? "RS256" : "ES256";
105
+ };
106
+
107
+ /** Import a PKCS#8 PEM private key (ES256 or RS256, detected from the DER). */
108
+ export const importPrivateKey = (
109
+ pem: Redacted.Redacted<string> | string,
110
+ ): Effect.Effect<{ readonly key: CryptoKey; readonly algorithm: KeyAlgorithm }, PkiError> =>
111
+ tryPromise("Importing the certificate key failed.", async () => {
112
+ const text = Redacted.isRedacted(pem) ? Redacted.value(pem) : pem;
113
+ const der = fromPem(text);
114
+ const algorithm = algorithmOfPkcs8(der);
115
+ const key = await crypto.subtle.importKey(
116
+ "pkcs8",
117
+ der as Uint8Array<ArrayBuffer>,
118
+ importParams(algorithm),
119
+ true,
120
+ ["sign"],
121
+ );
122
+ return { key, algorithm };
123
+ });
124
+
125
+ /** The private JWK of a PKCS#8 PEM key (for revocation signed by the certificate key). */
126
+ export const privateKeyToJwk = (
127
+ pem: Redacted.Redacted<string> | string,
128
+ ): Effect.Effect<Redacted.Redacted<string>, PkiError> =>
129
+ importPrivateKey(pem).pipe(
130
+ Effect.flatMap(({ key }) =>
131
+ tryPromise("Exporting the certificate key as a JWK failed.", async () =>
132
+ Redacted.make(JSON.stringify(await crypto.subtle.exportKey("jwk", key))),
133
+ ),
134
+ ),
135
+ );
136
+
137
+ /** Raw `r || s` ECDSA signature → DER `SEQUENCE { r INTEGER, s INTEGER }`. */
138
+ const ecdsaToDer = (raw: Uint8Array): Uint8Array => {
139
+ const half = raw.length / 2;
140
+ return Der.sequence(Der.integer(raw.subarray(0, half)), Der.integer(raw.subarray(half)));
141
+ };
142
+
143
+ /**
144
+ * A PKCS#10 certificate request for `identifiers` signed by `key`: the
145
+ * first identifier is the subject CN, all of them are `subjectAltName`
146
+ * dNSName entries. Returned as DER.
147
+ */
148
+ export const createCsr = (options: {
149
+ readonly key: GeneratedKey;
150
+ readonly identifiers: ReadonlyArray<string>;
151
+ }): Effect.Effect<Uint8Array, PkiError> =>
152
+ tryPromise("Encoding the certificate request failed.", async () => {
153
+ const { key, identifiers } = options;
154
+ if (identifiers.length === 0) {
155
+ throw new Error("a certificate needs at least one identifier");
156
+ }
157
+ const subject = Der.sequence(
158
+ Der.set(Der.sequence(Der.oid(OID.commonName), Der.utf8String(identifiers[0]!))),
159
+ );
160
+ const sanExtension = Der.sequence(
161
+ Der.oid(OID.subjectAltName),
162
+ Der.octetString(
163
+ Der.sequence(
164
+ ...identifiers.map((name) => Der.contextPrimitive(2, new TextEncoder().encode(name))),
165
+ ),
166
+ ),
167
+ );
168
+ const attributes = Der.contextTag(
169
+ 0,
170
+ Der.sequence(Der.oid(OID.extensionRequest), Der.set(Der.sequence(sanExtension))),
171
+ );
172
+ const info = Der.sequence(Der.integer(0), subject, key.spki, attributes);
173
+ const rawSignature = new Uint8Array(
174
+ await crypto.subtle.sign(
175
+ signParams(key.algorithm),
176
+ key.privateKey,
177
+ info as Uint8Array<ArrayBuffer>,
178
+ ),
179
+ );
180
+ const signature = key.algorithm === "ES256" ? ecdsaToDer(rawSignature) : rawSignature;
181
+ const algorithm =
182
+ key.algorithm === "ES256"
183
+ ? Der.sequence(Der.oid(OID.ecdsaWithSha256))
184
+ : Der.sequence(Der.oid(OID.sha256WithRsa), Der.nullValue());
185
+ return Der.sequence(info, algorithm, Der.bitString(signature));
186
+ });
187
+
188
+ /** base64url of a DER CSR, as ACME's `finalize` expects. */
189
+ export const csrToBase64Url = (der: Uint8Array): string => Jose.base64url(der);
190
+
191
+ // =============================================================================
192
+ // X.509 parsing
193
+ // =============================================================================
194
+
195
+ export interface ParsedCertificate {
196
+ /** Serial number as lowercase hex. */
197
+ readonly serial: string;
198
+ readonly notBefore: Date;
199
+ readonly notAfter: Date;
200
+ /** Issuer distinguished name, `CN` first (`CN=R11, O=Let's Encrypt`). */
201
+ readonly issuer: string;
202
+ /** Subject distinguished name in the same form. */
203
+ readonly subject: string;
204
+ /** `subjectAltName` dNSName entries. */
205
+ readonly dnsNames: ReadonlyArray<string>;
206
+ }
207
+
208
+ const decodeName = (name: Der.Node): string => {
209
+ const parts: string[] = [];
210
+ for (const rdn of Der.children(name)) {
211
+ for (const attribute of Der.children(rdn)) {
212
+ const [typeNode, valueNode] = Der.children(attribute);
213
+ const type = Der.decodeOid(typeNode!);
214
+ const value = new TextDecoder().decode(Der.content(valueNode!));
215
+ const label =
216
+ type === OID.commonName
217
+ ? "CN"
218
+ : type === OID.organization
219
+ ? "O"
220
+ : type === "2.5.4.6"
221
+ ? "C"
222
+ : type;
223
+ parts.push(`${label}=${value}`);
224
+ }
225
+ }
226
+ // CN first for readability; the rest keep their order.
227
+ parts.sort((a, b) => (a.startsWith("CN=") ? -1 : b.startsWith("CN=") ? 1 : 0));
228
+ return parts.join(", ");
229
+ };
230
+
231
+ /** Parse the first certificate of a PEM (or a DER buffer). */
232
+ export const parseCertificate = (
233
+ input: string | Uint8Array,
234
+ ): Effect.Effect<ParsedCertificate, PkiError> =>
235
+ Effect.try({
236
+ try: () => {
237
+ const der = typeof input === "string" ? fromPem(input) : input;
238
+ const certificate = Der.read(der, 0);
239
+ const [tbs] = Der.children(certificate);
240
+ const fields = Der.children(tbs!);
241
+ let index = 0;
242
+ if (fields[0]!.tag === 0xa0) index++; // [0] EXPLICIT version
243
+ const serial = Der.toHex(Der.content(fields[index++]!));
244
+ index++; // signature AlgorithmIdentifier
245
+ const issuer = decodeName(fields[index++]!);
246
+ const [notBeforeNode, notAfterNode] = Der.children(fields[index++]!);
247
+ const subject = decodeName(fields[index++]!);
248
+ index++; // subjectPublicKeyInfo
249
+ const dnsNames: string[] = [];
250
+ for (const field of fields.slice(index)) {
251
+ if (field.tag !== 0xa3) continue; // [3] EXPLICIT extensions
252
+ const [extensions] = Der.children(field);
253
+ for (const extension of Der.children(extensions!)) {
254
+ const members = Der.children(extension);
255
+ if (Der.decodeOid(members[0]!) !== OID.subjectAltName) continue;
256
+ const valueNode = members[members.length - 1]!; // OCTET STRING (critical may precede)
257
+ const generalNames = Der.read(Der.content(valueNode), 0);
258
+ for (const generalName of Der.children(generalNames)) {
259
+ if (generalName.tag === 0x82) {
260
+ dnsNames.push(new TextDecoder().decode(Der.content(generalName)));
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return {
266
+ serial: serial.replace(/^00/, "") || "0",
267
+ notBefore: Der.decodeTime(notBeforeNode!),
268
+ notAfter: Der.decodeTime(notAfterNode!),
269
+ issuer,
270
+ subject,
271
+ dnsNames,
272
+ };
273
+ },
274
+ catch: (cause) => new PkiError({ message: "Parsing the certificate failed.", cause }),
275
+ });
@@ -0,0 +1,39 @@
1
+ import * as Provider from "alchemy/Provider";
2
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
3
+ import * as Layer from "effect/Layer";
4
+ import { Account, AccountProvider } from "./Account.ts";
5
+ import { Certificate, CertificateProvider } from "./Certificate.ts";
6
+ import { IssueCertificateHttp } from "./IssueCertificateHttp.ts";
7
+
8
+ export class Providers extends Provider.ProviderCollection<Providers>()("ACME") {}
9
+
10
+ export type ProviderRequirements = Layer.Services<ReturnType<typeof providers>>;
11
+
12
+ /**
13
+ * Registers the ACME resource providers ({@link Account},
14
+ * {@link Certificate}) and the {@link IssueCertificateHttp} binding. DNS-01
15
+ * solvers come from the DNS provider, e.g. `Cloudflare.providers()` registers
16
+ * `Cloudflare.ACME.ZoneSolver`.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * export default Alchemy.Stack(
21
+ * "Certs",
22
+ * {
23
+ * providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()),
24
+ * state: Alchemy.localState(),
25
+ * },
26
+ * Effect.gen(function* () {
27
+ * const wildcard = yield* Wildcard;
28
+ * return { notAfter: wildcard.notAfter };
29
+ * }),
30
+ * );
31
+ * ```
32
+ */
33
+ export const providers = () =>
34
+ Layer.effect(Providers, Provider.collection([Account, Certificate])).pipe(
35
+ Layer.provide(Layer.mergeAll(AccountProvider(), CertificateProvider())),
36
+ Layer.provideMerge(IssueCertificateHttp),
37
+ Layer.provideMerge(FetchHttpClient.layer),
38
+ Layer.orDie,
39
+ );
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./Account.ts";
2
+ export * from "./Certificate.ts";
3
+ export * from "./CertificateAuthority.ts";
4
+ export * from "./Client.ts";
5
+ export * from "./Dns.ts";
6
+ export * from "./DnsSolver.ts";
7
+ export * from "./Errors.ts";
8
+ export * from "./IssueCertificate.ts";
9
+ export * from "./IssueCertificateHttp.ts";
10
+ export * from "./Pki.ts";
11
+ export * from "./Providers.ts";