@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.
- package/LICENSE +201 -0
- package/README.md +73 -0
- package/lib/credentials.d.ts +50 -0
- package/lib/credentials.d.ts.map +1 -0
- package/lib/credentials.js +57 -0
- package/lib/credentials.js.map +1 -0
- package/lib/errors.d.ts +73 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +50 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +30 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +30 -0
- package/lib/index.js.map +1 -0
- package/lib/jose.d.ts +66 -0
- package/lib/jose.d.ts.map +1 -0
- package/lib/jose.js +128 -0
- package/lib/jose.js.map +1 -0
- package/lib/protocol.d.ts +27 -0
- package/lib/protocol.d.ts.map +1 -0
- package/lib/protocol.js +410 -0
- package/lib/protocol.js.map +1 -0
- package/lib/response-validation.test.d.ts +4 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +51 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/retry.d.ts +33 -0
- package/lib/retry.d.ts.map +1 -0
- package/lib/retry.js +33 -0
- package/lib/retry.js.map +1 -0
- package/lib/services/acme.d.ts +446 -0
- package/lib/services/acme.d.ts.map +1 -0
- package/lib/services/acme.js +597 -0
- package/lib/services/acme.js.map +1 -0
- package/lib/services/index.d.ts +2 -0
- package/lib/services/index.d.ts.map +1 -0
- package/lib/services/index.js +3 -0
- package/lib/services/index.js.map +1 -0
- package/lib/traits.d.ts +13 -0
- package/lib/traits.d.ts.map +1 -0
- package/lib/traits.js +13 -0
- package/lib/traits.js.map +1 -0
- package/package.json +75 -7
- package/src/credentials.ts +106 -0
- package/src/errors.ts +101 -0
- package/src/index.ts +33 -0
- package/src/jose.ts +234 -0
- package/src/protocol.ts +582 -0
- package/src/response-validation.test.ts +65 -0
- package/src/retry.ts +55 -0
- package/src/services/acme.ts +1140 -0
- package/src/services/index.ts +2 -0
- 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": "
|
|
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
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
});
|