@distilled.cloud/acme 0.0.0-placeholder → 1.0.0-rc.13

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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -0
  3. package/lib/credentials.d.ts +50 -0
  4. package/lib/credentials.d.ts.map +1 -0
  5. package/lib/credentials.js +57 -0
  6. package/lib/credentials.js.map +1 -0
  7. package/lib/errors.d.ts +73 -0
  8. package/lib/errors.d.ts.map +1 -0
  9. package/lib/errors.js +50 -0
  10. package/lib/errors.js.map +1 -0
  11. package/lib/index.d.ts +30 -0
  12. package/lib/index.d.ts.map +1 -0
  13. package/lib/index.js +30 -0
  14. package/lib/index.js.map +1 -0
  15. package/lib/jose.d.ts +66 -0
  16. package/lib/jose.d.ts.map +1 -0
  17. package/lib/jose.js +128 -0
  18. package/lib/jose.js.map +1 -0
  19. package/lib/protocol.d.ts +27 -0
  20. package/lib/protocol.d.ts.map +1 -0
  21. package/lib/protocol.js +410 -0
  22. package/lib/protocol.js.map +1 -0
  23. package/lib/response-validation.test.d.ts +4 -0
  24. package/lib/response-validation.test.d.ts.map +1 -0
  25. package/lib/response-validation.test.js +51 -0
  26. package/lib/response-validation.test.js.map +1 -0
  27. package/lib/retry.d.ts +33 -0
  28. package/lib/retry.d.ts.map +1 -0
  29. package/lib/retry.js +33 -0
  30. package/lib/retry.js.map +1 -0
  31. package/lib/services/acme.d.ts +446 -0
  32. package/lib/services/acme.d.ts.map +1 -0
  33. package/lib/services/acme.js +597 -0
  34. package/lib/services/acme.js.map +1 -0
  35. package/lib/services/index.d.ts +2 -0
  36. package/lib/services/index.d.ts.map +1 -0
  37. package/lib/services/index.js +3 -0
  38. package/lib/services/index.js.map +1 -0
  39. package/lib/traits.d.ts +13 -0
  40. package/lib/traits.d.ts.map +1 -0
  41. package/lib/traits.js +13 -0
  42. package/lib/traits.js.map +1 -0
  43. package/package.json +75 -7
  44. package/src/credentials.ts +106 -0
  45. package/src/errors.ts +101 -0
  46. package/src/index.ts +33 -0
  47. package/src/jose.ts +234 -0
  48. package/src/protocol.ts +582 -0
  49. package/src/response-validation.test.ts +65 -0
  50. package/src/retry.ts +55 -0
  51. package/src/services/acme.ts +1140 -0
  52. package/src/services/index.ts +2 -0
  53. package/src/traits.ts +46 -0
package/lib/traits.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ACME SDK trait surface — hand-written.
3
+ *
4
+ * Re-exports the generic protocol traits from core so generated operations
5
+ * import everything from one place (`import * as T from "../traits.ts"`).
6
+ * ACME is JWS-signed JSON, so
7
+ * there are no ACME-specific traits — the REST-protocol traits
8
+ * (`SensitiveValue` / `RawResponse` / `RawResponseRoot`) come from
9
+ * `core/protocol-rest`.
10
+ */
11
+ export { SensitiveValue, RawResponse, RawResponseRoot, sensitiveValueSymbol, rawResponseSymbol, rawResponseRootSymbol, } from "@distilled.cloud/core/protocol-rest";
12
+ export { Body, Header, Query, Label, Http, ResponseCode, HttpBody, FormDataFile, KeyDictionary, UnionCases, applyErrorMatchers, getErrorMatchers, bodySymbol, headerSymbol, querySymbol, labelSymbol, httpSymbol, responseCodeSymbol, httpBodySymbol, formDataFileSymbol, keyDictionarySymbol, unionCasesSymbol, errorMatchersSymbol, } from "@distilled.cloud/core/trait";
13
+ //# sourceMappingURL=traits.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"traits.js","sourceRoot":"","sources":["../src/traits.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EACL,cAAc,EACd,WAAW,EACX,eAAe,EACf,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,qCAAqC,CAAC;AAE7C,OAAO,EACL,IAAI,EACJ,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAGhB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,6BAA6B,CAAC"}
package/package.json CHANGED
@@ -1,15 +1,83 @@
1
1
  {
2
2
  "name": "@distilled.cloud/acme",
3
- "version": "0.0.0-placeholder",
4
- "description": "Placeholder package for initial npm registration.",
5
- "license": "Apache-2.0",
3
+ "version": "1.0.0-rc.13",
6
4
  "repository": {
7
5
  "type": "git",
8
6
  "url": "https://github.com/alchemy-run/distilled",
9
7
  "directory": "packages/acme"
10
8
  },
11
- "publishConfig": {
12
- "access": "public",
13
- "tag": "placeholder"
9
+ "license": "Apache-2.0",
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "module": "src/index.ts",
13
+ "files": [
14
+ "lib",
15
+ "src"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./lib/index.d.ts",
20
+ "bun": "./src/index.ts",
21
+ "worker": "./src/index.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./Credentials": {
25
+ "types": "./lib/credentials.d.ts",
26
+ "bun": "./src/credentials.ts",
27
+ "worker": "./src/credentials.ts",
28
+ "default": "./lib/credentials.js"
29
+ },
30
+ "./Errors": {
31
+ "types": "./lib/errors.d.ts",
32
+ "bun": "./src/errors.ts",
33
+ "worker": "./src/errors.ts",
34
+ "default": "./lib/errors.js"
35
+ },
36
+ "./Jose": {
37
+ "types": "./lib/jose.d.ts",
38
+ "bun": "./src/jose.ts",
39
+ "worker": "./src/jose.ts",
40
+ "default": "./lib/jose.js"
41
+ },
42
+ "./Protocol": {
43
+ "types": "./lib/protocol.d.ts",
44
+ "bun": "./src/protocol.ts",
45
+ "worker": "./src/protocol.ts",
46
+ "default": "./lib/protocol.js"
47
+ },
48
+ "./Retry": {
49
+ "types": "./lib/retry.d.ts",
50
+ "bun": "./src/retry.ts",
51
+ "worker": "./src/retry.ts",
52
+ "default": "./lib/retry.js"
53
+ },
54
+ "./Traits": {
55
+ "types": "./lib/traits.d.ts",
56
+ "bun": "./src/traits.ts",
57
+ "worker": "./src/traits.ts",
58
+ "default": "./lib/traits.js"
59
+ },
60
+ "./acme": {
61
+ "types": "./lib/services/acme.d.ts",
62
+ "bun": "./src/services/acme.ts",
63
+ "worker": "./src/services/acme.ts",
64
+ "default": "./lib/services/acme.js"
65
+ }
66
+ },
67
+ "dependencies": {
68
+ "@distilled.cloud/core": "1.0.0-rc.13"
69
+ },
70
+ "devDependencies": {
71
+ "@types/bun": "1.3.14",
72
+ "@types/node": "26.2.0",
73
+ "@effect/platform-bun": "^4.0.0"
74
+ },
75
+ "peerDependencies": {
76
+ "effect": "^4.0.0"
77
+ },
78
+ "scripts": {
79
+ "build": "tsc -b",
80
+ "generate": "bun scripts/generate.ts",
81
+ "typecheck": "tsc -p tsconfig.json"
14
82
  }
15
- }
83
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ACME credentials — hand-written.
3
+ *
4
+ * An ACME "credential" is the **account key**: a private JWK that signs
5
+ * every request. The CA is addressed by its **directory URL** (Let's
6
+ * Encrypt, ZeroSSL, Google Trust Services and Pebble differ only there),
7
+ * plus, for CAs that require it, an **External Account Binding** (key id +
8
+ * HMAC key from the CA's dashboard) presented once on `newAccount`.
9
+ *
10
+ * The `Credentials` service holds an *effect* that resolves the current
11
+ * credentials, so the protocol picks up a rotated key or a newly learned
12
+ * account URL per request.
13
+ */
14
+ import { ConfigError } from "@distilled.cloud/core/errors";
15
+ import * as EffectConfig from "effect/Config";
16
+ import * as Context from "effect/Context";
17
+ import * as Effect from "effect/Effect";
18
+ import * as Layer from "effect/Layer";
19
+ import * as Option from "effect/Option";
20
+ import * as Redacted from "effect/Redacted";
21
+
22
+ /** Well-known ACME directories. */
23
+ export const Directories = {
24
+ LetsEncrypt: "https://acme-v02.api.letsencrypt.org/directory",
25
+ LetsEncryptStaging: "https://acme-staging-v02.api.letsencrypt.org/directory",
26
+ ZeroSSL: "https://acme.zerossl.com/v2/DV90",
27
+ GoogleTrustServices: "https://dv.acme-v02.api.pki.goog/directory",
28
+ GoogleTrustServicesStaging: "https://dv.acme-v02.test-api.pki.goog/directory",
29
+ } as const;
30
+
31
+ export interface ExternalAccountBinding {
32
+ /** The CA-issued EAB key id. */
33
+ readonly keyId: string;
34
+ /** The CA-issued base64url HMAC key. */
35
+ readonly hmacKey: Redacted.Redacted<string>;
36
+ }
37
+
38
+ export interface Config {
39
+ /** The CA's directory URL. */
40
+ readonly directoryUrl: string;
41
+ /**
42
+ * The account's private key as a JSON Web Key (JSON text). ES256 (`EC`
43
+ * P-256) by default; RS256 (`RSA`) is accepted.
44
+ */
45
+ readonly accountKey: Redacted.Redacted<string>;
46
+ /**
47
+ * The account URL the CA assigned (`Location` of `newAccount`). Sent as
48
+ * `kid` on every request once known; `newAccount` uses the bare JWK.
49
+ */
50
+ readonly accountUrl?: string | undefined;
51
+ /** EAB for CAs that require it (ZeroSSL, Google Trust Services). */
52
+ readonly externalAccountBinding?: ExternalAccountBinding | undefined;
53
+ }
54
+
55
+ export class Credentials extends Context.Service<
56
+ Credentials,
57
+ Effect.Effect<Config>
58
+ >()("AcmeCredentials") {}
59
+
60
+ /**
61
+ * Credentials from the environment:
62
+ *
63
+ * - `ACME_DIRECTORY_URL` (default: Let's Encrypt production)
64
+ * - `ACME_ACCOUNT_KEY` — the private JWK as JSON
65
+ * - `ACME_ACCOUNT_URL` — optional, once the account exists
66
+ * - `ACME_EAB_KID` / `ACME_EAB_HMAC_KEY` — optional EAB pair
67
+ */
68
+ export const CredentialsFromEnv = Layer.succeed(
69
+ Credentials,
70
+ EffectConfig.all({
71
+ directoryUrl: EffectConfig.String("ACME_DIRECTORY_URL").pipe(
72
+ EffectConfig.withDefault(Directories.LetsEncrypt),
73
+ ),
74
+ accountKey: EffectConfig.Redacted("ACME_ACCOUNT_KEY"),
75
+ accountUrl: EffectConfig.option(EffectConfig.String("ACME_ACCOUNT_URL")),
76
+ eabKid: EffectConfig.option(EffectConfig.String("ACME_EAB_KID")),
77
+ eabHmac: EffectConfig.option(EffectConfig.Redacted("ACME_EAB_HMAC_KEY")),
78
+ }).pipe(
79
+ Effect.mapError(
80
+ () =>
81
+ new ConfigError({
82
+ message:
83
+ "ACME_ACCOUNT_KEY (a private JWK) is required; ACME_DIRECTORY_URL, ACME_ACCOUNT_URL, ACME_EAB_KID and ACME_EAB_HMAC_KEY are optional",
84
+ }),
85
+ ),
86
+ Effect.map(
87
+ ({ directoryUrl, accountKey, accountUrl, eabKid, eabHmac }): Config => ({
88
+ directoryUrl,
89
+ accountKey,
90
+ accountUrl: Option.getOrUndefined(accountUrl),
91
+ externalAccountBinding:
92
+ Option.isSome(eabKid) && Option.isSome(eabHmac)
93
+ ? { keyId: eabKid.value, hmacKey: eabHmac.value }
94
+ : undefined,
95
+ }),
96
+ ),
97
+ Effect.orDie,
98
+ ),
99
+ );
100
+
101
+ /** A fixed credentials layer (tests, and consumers that manage keys themselves). */
102
+ export const layer = (config: Config | Effect.Effect<Config>) =>
103
+ Layer.succeed(
104
+ Credentials,
105
+ Effect.isEffect(config) ? config : Effect.succeed(config),
106
+ );
package/src/errors.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ACME-specific error types.
3
+ *
4
+ * ACME reports failures as RFC 7807 problem documents whose `type` is a
5
+ * `urn:ietf:params:acme:error:*` URN (RFC 8555 §6.7). Every URN the SDK
6
+ * knows is a typed class in the generated service (patched onto the
7
+ * operations that raise it, matched on the URN by the protocol). This
8
+ * module holds the shared HTTP defaults and the package's own client
9
+ * errors.
10
+ */
11
+ export {
12
+ BadGateway,
13
+ BadRequest,
14
+ Conflict,
15
+ ConfigError,
16
+ Forbidden,
17
+ GatewayTimeout,
18
+ InternalServerError,
19
+ Locked,
20
+ NotFound,
21
+ ServiceUnavailable,
22
+ TooManyRequests,
23
+ Unauthorized,
24
+ UnprocessableEntity,
25
+ HTTP_STATUS_MAP,
26
+ DEFAULT_ERRORS,
27
+ API_ERRORS,
28
+ } from "@distilled.cloud/core/errors";
29
+ import type {
30
+ BadRequest as CoreBadRequest,
31
+ Conflict as CoreConflict,
32
+ DefaultErrors as CoreDefaultErrors,
33
+ Forbidden as CoreForbidden,
34
+ NotFound as CoreNotFound,
35
+ } from "@distilled.cloud/core/errors";
36
+ import * as Schema from "effect/Schema";
37
+ import * as Category from "@distilled.cloud/core/category";
38
+
39
+ /**
40
+ * Unknown ACME error — a problem document whose `type` matches no typed
41
+ * class on the operation. Carries structured problem fields and a redacted
42
+ * body. Add newly observed URNs to the authored model.
43
+ */
44
+ export class UnknownAcmeError extends Schema.TaggedError<UnknownAcmeError>()(
45
+ "UnknownAcmeError",
46
+ {
47
+ /** The problem `type` URN, when the body was a problem document. */
48
+ type: Schema.optional(Schema.String),
49
+ message: Schema.optional(Schema.String),
50
+ status: Schema.optional(Schema.Number),
51
+ detail: Schema.optional(Schema.String),
52
+ subproblems: Schema.optional(Schema.Array(Schema.Unknown)),
53
+ body: Schema.Unknown,
54
+ },
55
+ ) {}
56
+
57
+ /** Schema parse error wrapper. */
58
+ export class AcmeParseError extends Schema.TaggedError<AcmeParseError>()(
59
+ "AcmeParseError",
60
+ {
61
+ body: Schema.Unknown,
62
+ cause: Schema.Unknown,
63
+ },
64
+ ).pipe(Category.withParseError) {}
65
+
66
+ /** The account key could not be parsed or used to sign (client-side). */
67
+ export class JoseError extends Schema.TaggedError<JoseError>()("JoseError", {
68
+ message: Schema.String,
69
+ cause: Schema.optional(Schema.Unknown),
70
+ }).pipe(Category.withBadRequestError) {}
71
+
72
+ /**
73
+ * The directory has no URL for the requested resource (a CA without
74
+ * `newAuthz`/`keyChange`, or a stale directory URL).
75
+ */
76
+ export class DirectoryMissingResource extends Schema.TaggedError<DirectoryMissingResource>()(
77
+ "DirectoryMissingResource",
78
+ {
79
+ resource: Schema.String,
80
+ directoryUrl: Schema.String,
81
+ },
82
+ ).pipe(Category.withBadRequestError) {}
83
+
84
+ /** Errors any ACME operation may surface beyond the core HTTP defaults. */
85
+ export type ClientErrors =
86
+ | UnknownAcmeError
87
+ | AcmeParseError
88
+ | JoseError
89
+ | DirectoryMissingResource;
90
+
91
+ /**
92
+ * Default ACME operation errors: the shared HTTP status errors from core plus
93
+ * the client errors above.
94
+ */
95
+ export type DefaultErrors =
96
+ | CoreDefaultErrors
97
+ | CoreBadRequest
98
+ | CoreForbidden
99
+ | CoreNotFound
100
+ | CoreConflict
101
+ | ClientErrors;
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @distilled.cloud/acme — ACME (RFC 8555) SDK for Effect.
3
+ *
4
+ * `./services` is generated by `scripts/generate.ts` from the hand-authored
5
+ * Smithy model in `manual-specs/acme.json` (RFC 8555 — there is no vendor spec for
6
+ * a protocol). Everything else in this folder is hand-written: the
7
+ * JWS-signing protocol, credentials (account key + directory + EAB), JOSE
8
+ * helpers, retry and errors.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import * as Acme from "@distilled.cloud/acme";
13
+ *
14
+ * const account = yield* Acme.acme.newAccount({
15
+ * contact: ["mailto:ops@example.com"],
16
+ * termsOfServiceAgreed: true,
17
+ * });
18
+ * const order = yield* Acme.acme.newOrder({
19
+ * identifiers: [{ type: "dns", value: "*.example.com" }],
20
+ * });
21
+ * ```
22
+ */
23
+ export * from "./credentials.ts";
24
+ export * from "./errors.ts";
25
+ export * as Jose from "./jose.ts";
26
+ export * as T from "./traits.ts";
27
+ export * as Retry from "./retry.ts";
28
+ export {
29
+ AcmeProtocol,
30
+ type AcmeOpError,
31
+ type AcmeOpContext,
32
+ } from "./protocol.ts";
33
+ export * from "./services/index.ts";
package/src/jose.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * JOSE helpers for ACME — hand-written, WebCrypto only (no native code).
3
+ *
4
+ * - account keys (ES256 P-256 by default, RS256 accepted)
5
+ * - JWS in the flattened JSON serialization every ACME POST carries
6
+ * (RFC 8555 §6.2), including the HS256 External Account Binding
7
+ * - RFC 7638 JWK thumbprints, key authorizations and the DNS-01 TXT value
8
+ */
9
+ import * as Effect from "effect/Effect";
10
+ import * as Redacted from "effect/Redacted";
11
+ import { JoseError } from "./errors.ts";
12
+
13
+ export type Algorithm = "ES256" | "RS256";
14
+
15
+ export interface Jwk {
16
+ readonly kty: string;
17
+ readonly crv?: string;
18
+ readonly x?: string;
19
+ readonly y?: string;
20
+ readonly n?: string;
21
+ readonly e?: string;
22
+ readonly d?: string;
23
+ readonly [key: string]: unknown;
24
+ }
25
+
26
+ const encoder = new TextEncoder();
27
+
28
+ export const base64url = (bytes: Uint8Array | ArrayBuffer): string => {
29
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
30
+ let binary = "";
31
+ for (const byte of view) binary += String.fromCharCode(byte);
32
+ return btoa(binary)
33
+ .replaceAll("+", "-")
34
+ .replaceAll("/", "_")
35
+ .replace(/=+$/, "");
36
+ };
37
+
38
+ export const base64urlDecode = (text: string): Uint8Array => {
39
+ const padded = text.replaceAll("-", "+").replaceAll("_", "/");
40
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
41
+ const out = new Uint8Array(binary.length);
42
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
43
+ return out;
44
+ };
45
+
46
+ const base64urlJson = (value: unknown): string =>
47
+ base64url(encoder.encode(JSON.stringify(value)));
48
+
49
+ const sha256 = (data: Uint8Array | string): Effect.Effect<Uint8Array> =>
50
+ Effect.promise(async () => {
51
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
52
+ return new Uint8Array(
53
+ await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>),
54
+ );
55
+ });
56
+
57
+ /** Detect the JWS algorithm for a JWK. */
58
+ export const algorithmOf = (jwk: Jwk): Algorithm =>
59
+ jwk.kty === "RSA" ? "RS256" : "ES256";
60
+
61
+ const importAlgorithm = (alg: Algorithm) =>
62
+ alg === "RS256"
63
+ ? { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }
64
+ : { name: "ECDSA", namedCurve: "P-256" };
65
+
66
+ const signAlgorithm = (alg: Algorithm) =>
67
+ alg === "RS256"
68
+ ? { name: "RSASSA-PKCS1-v1_5" }
69
+ : { name: "ECDSA", hash: "SHA-256" };
70
+
71
+ /** Generate a fresh account key as a private JWK (JSON text, Redacted). */
72
+ export const generateAccountKey = (
73
+ alg: Algorithm = "ES256",
74
+ ): Effect.Effect<Redacted.Redacted<string>> =>
75
+ Effect.promise(async () => {
76
+ const pair = await crypto.subtle.generateKey(
77
+ alg === "RS256"
78
+ ? {
79
+ name: "RSASSA-PKCS1-v1_5",
80
+ modulusLength: 2048,
81
+ publicExponent: new Uint8Array([1, 0, 1]),
82
+ hash: "SHA-256",
83
+ }
84
+ : { name: "ECDSA", namedCurve: "P-256" },
85
+ true,
86
+ ["sign", "verify"],
87
+ );
88
+ const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
89
+ return Redacted.make(JSON.stringify(jwk));
90
+ });
91
+
92
+ /** Parse a private JWK from its JSON text. */
93
+ export const parseJwk = (
94
+ key: Redacted.Redacted<string> | string,
95
+ ): Effect.Effect<Jwk, JoseError> =>
96
+ Effect.try({
97
+ try: () =>
98
+ JSON.parse(Redacted.isRedacted(key) ? Redacted.value(key) : key) as Jwk,
99
+ catch: (cause) =>
100
+ new JoseError({
101
+ message: "The account key is not a JSON Web Key.",
102
+ cause,
103
+ }),
104
+ });
105
+
106
+ /** The public half of a JWK, with members in RFC 7638 canonical order. */
107
+ export const publicJwk = (jwk: Jwk): Jwk =>
108
+ jwk.kty === "RSA"
109
+ ? { e: jwk.e, kty: "RSA", n: jwk.n }
110
+ : { crv: jwk.crv, kty: "EC", x: jwk.x, y: jwk.y };
111
+
112
+ /** RFC 7638 JWK thumbprint (base64url SHA-256 of the canonical public JWK). */
113
+ export const thumbprint = (jwk: Jwk): Effect.Effect<string> =>
114
+ sha256(JSON.stringify(publicJwk(jwk))).pipe(Effect.map(base64url));
115
+
116
+ /** `<token>.<thumbprint>` — what a challenge proves the client holds. */
117
+ export const keyAuthorization = (
118
+ token: string,
119
+ jwk: Jwk,
120
+ ): Effect.Effect<string> =>
121
+ thumbprint(jwk).pipe(Effect.map((print) => `${token}.${print}`));
122
+
123
+ /** The TXT record value for a DNS-01 challenge: base64url(SHA-256(keyAuthorization)). */
124
+ export const dnsChallengeValue = (
125
+ token: string,
126
+ jwk: Jwk,
127
+ ): Effect.Effect<string> =>
128
+ keyAuthorization(token, jwk).pipe(
129
+ Effect.flatMap(sha256),
130
+ Effect.map(base64url),
131
+ );
132
+
133
+ export interface FlattenedJws {
134
+ readonly protected: string;
135
+ readonly payload: string;
136
+ readonly signature: string;
137
+ }
138
+
139
+ export interface SignOptions {
140
+ readonly jwk: Jwk;
141
+ readonly url: string;
142
+ readonly nonce: string;
143
+ /** Sent as `kid` when known; otherwise the public JWK is embedded. */
144
+ readonly kid?: string | undefined;
145
+ /** `undefined` produces a POST-as-GET (empty payload). */
146
+ readonly payload?: unknown;
147
+ }
148
+
149
+ /** Sign an ACME request body (RFC 8555 §6.2) with the account key. */
150
+ export const signRequest = (
151
+ options: SignOptions,
152
+ ): Effect.Effect<FlattenedJws, JoseError> =>
153
+ Effect.tryPromise({
154
+ try: async () => {
155
+ const alg = algorithmOf(options.jwk);
156
+ const header = {
157
+ alg,
158
+ nonce: options.nonce,
159
+ url: options.url,
160
+ ...(options.kid !== undefined
161
+ ? { kid: options.kid }
162
+ : { jwk: publicJwk(options.jwk) }),
163
+ };
164
+ const protectedB64 = base64urlJson(header);
165
+ const payloadB64 =
166
+ options.payload === undefined ? "" : base64urlJson(options.payload);
167
+ const key = await crypto.subtle.importKey(
168
+ "jwk",
169
+ options.jwk as JsonWebKey,
170
+ importAlgorithm(alg),
171
+ false,
172
+ ["sign"],
173
+ );
174
+ const signature = await crypto.subtle.sign(
175
+ signAlgorithm(alg),
176
+ key,
177
+ encoder.encode(`${protectedB64}.${payloadB64}`),
178
+ );
179
+ return {
180
+ protected: protectedB64,
181
+ payload: payloadB64,
182
+ signature: base64url(signature),
183
+ };
184
+ },
185
+ catch: (cause) =>
186
+ new JoseError({ message: "Signing the ACME request failed.", cause }),
187
+ });
188
+
189
+ /**
190
+ * The External Account Binding JWS (RFC 8555 §7.3.4): HS256 over the
191
+ * account's public JWK, keyed by the CA-issued HMAC key, with the CA's key
192
+ * id as `kid`.
193
+ */
194
+ export const signExternalAccountBinding = (options: {
195
+ readonly jwk: Jwk;
196
+ readonly url: string;
197
+ readonly keyId: string;
198
+ readonly hmacKey: Redacted.Redacted<string> | string;
199
+ }): Effect.Effect<FlattenedJws, JoseError> =>
200
+ Effect.tryPromise({
201
+ try: async () => {
202
+ const raw = Redacted.isRedacted(options.hmacKey)
203
+ ? Redacted.value(options.hmacKey)
204
+ : options.hmacKey;
205
+ const key = await crypto.subtle.importKey(
206
+ "raw",
207
+ base64urlDecode(raw) as Uint8Array<ArrayBuffer>,
208
+ { name: "HMAC", hash: "SHA-256" },
209
+ false,
210
+ ["sign"],
211
+ );
212
+ const protectedB64 = base64urlJson({
213
+ alg: "HS256",
214
+ kid: options.keyId,
215
+ url: options.url,
216
+ });
217
+ const payloadB64 = base64urlJson(publicJwk(options.jwk));
218
+ const signature = await crypto.subtle.sign(
219
+ "HMAC",
220
+ key,
221
+ encoder.encode(`${protectedB64}.${payloadB64}`),
222
+ );
223
+ return {
224
+ protected: protectedB64,
225
+ payload: payloadB64,
226
+ signature: base64url(signature),
227
+ };
228
+ },
229
+ catch: (cause) =>
230
+ new JoseError({
231
+ message: "Signing the External Account Binding failed.",
232
+ cause,
233
+ }),
234
+ });