1import * as Effect from "effect/Effect";
2import type { BindingHook } from "../PluginContext.ts";
3import { ConfigError } from "../RuntimeError.shared.ts";
4import type { Worker_Binding_CryptoKey } from "../workerd/Config.ts";
5import { Worker_Binding_CryptoKey_Usage } from "../workerd/Config.ts";
6
7/**
8 * Props for a `secret_key` (CryptoKey) binding, mirroring the Cloudflare
9 * API's `secret_key` binding shape (WebCrypto `importKey` parameters plus
10 * the key material).
11 */
12export interface SecretKeyProps {
13 binding: string;
14 /**
15 * Data format of the key material — the WebCrypto `importKey` format.
16 */
17 format: "raw" | "pkcs8" | "spki" | "jwk" | (string & {});
18 /**
19 * WebCrypto `importKey` algorithm: a name (e.g. `"AES-GCM"`) or a full
20 * algorithm object (e.g. `{ name: "HMAC", hash: "SHA-256" }`).
21 */
22 algorithm: unknown;
23 /**
24 * Allowed key operations (WebCrypto `keyUsages`).
25 */
26 usages: ReadonlyArray<string>;
27 /**
28 * Base64-encoded key material (raw bytes for `"raw"`, DER for `"pkcs8"`
29 * / `"spki"`). Required unless `format` is `"jwk"`.
30 */
31 keyBase64?: string | null | undefined;
32 /**
33 * Key material in JSON Web Key format (an object, or pre-serialized JSON
34 * text). Required when `format` is `"jwk"`.
35 */
36 keyJwk?: unknown;
37 /**
38 * Whether the worker may export the key material.
39 * @default false
40 */
41 extractable?: boolean | undefined;
42}
43
44const USAGES: Record<string, Worker_Binding_CryptoKey_Usage> = {
45 encrypt: Worker_Binding_CryptoKey_Usage.ENCRYPT,
46 decrypt: Worker_Binding_CryptoKey_Usage.DECRYPT,
47 sign: Worker_Binding_CryptoKey_Usage.SIGN,
48 verify: Worker_Binding_CryptoKey_Usage.VERIFY,
49 deriveKey: Worker_Binding_CryptoKey_Usage.DERIVE_KEY,
50 deriveBits: Worker_Binding_CryptoKey_Usage.DERIVE_BITS,
51 wrapKey: Worker_Binding_CryptoKey_Usage.WRAP_KEY,
52 unwrapKey: Worker_Binding_CryptoKey_Usage.UNWRAP_KEY,
53};
54
55/**
56 * Wrap base64 DER key data in PEM armor. The Cloudflare API carries
57 * `pkcs8` / `spki` key material as bare base64 DER (`key_base64`), while
58 * workerd's `cryptoKey.pkcs8` / `.spki` config fields expect PEM text.
59 */
60const toPem = (base64: string, label: string): string => {
61 const body = base64.replace(/\s+/g, "");
62 const lines = body.match(/.{1,64}/g) ?? [];
63 return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----\n`;
64};
65
66const invalid = (
67 subtag: string,
68 message: string,
69 props: SecretKeyProps,
70 hint?: string,
71) =>
72 new ConfigError({
73 subtag,
74 message,
75 ...(hint !== undefined ? { hint } : {}),
76 detail: { binding: props.binding, format: props.format },
77 });
78
79const keyMaterial = (
80 props: SecretKeyProps,
81): Effect.Effect<Worker_Binding_CryptoKey, ConfigError> =>
82 Effect.gen(function* () {
83 switch (props.format) {
84 case "raw":
85 case "pkcs8":
86 case "spki": {
87 const keyBase64 = props.keyBase64;
88 if (typeof keyBase64 !== "string" || keyBase64.length === 0) {
89 return yield* invalid(
90 "MissingSecretKeyMaterial",
91 `secret_key binding "${props.binding}" with format "${props.format}" requires base64-encoded key material`,
92 props,
93 'Provide the key data as base64 (the Cloudflare API\'s "key_base64" field).',
94 );
95 }
96 return props.format === "raw"
97 ? { base64: keyBase64 }
98 : props.format === "pkcs8"
99 ? { pkcs8: toPem(keyBase64, "PRIVATE KEY") }
100 : { spki: toPem(keyBase64, "PUBLIC KEY") };
101 }
102 case "jwk": {
103 const keyJwk = props.keyJwk;
104 if (keyJwk === undefined || keyJwk === null) {
105 return yield* invalid(
106 "MissingSecretKeyMaterial",
107 `secret_key binding "${props.binding}" with format "jwk" requires key material in JSON Web Key format`,
108 props,
109 'Provide the key as a JWK object (the Cloudflare API\'s "key_jwk" field).',
110 );
111 }
112 return {
113 jwk: typeof keyJwk === "string" ? keyJwk : JSON.stringify(keyJwk),
114 };
115 }
116 default:
117 return yield* invalid(
118 "UnsupportedSecretKeyFormat",
119 `secret_key binding "${props.binding}" has unsupported key format "${props.format}"`,
120 props,
121 'Supported formats are "raw", "pkcs8", "spki", and "jwk".',
122 );
123 }
124 });
125
126export const local = (props: SecretKeyProps): BindingHook =>
127 Effect.gen(function* () {
128 const material = yield* keyMaterial(props);
129
130 if (props.algorithm === undefined || props.algorithm === null) {
131 return yield* invalid(
132 "MissingSecretKeyAlgorithm",
133 `secret_key binding "${props.binding}" requires an algorithm`,
134 props,
135 'Provide a WebCrypto importKey algorithm, e.g. { name: "HMAC", hash: "SHA-256" }.',
136 );
137 }
138 const algorithm =
139 typeof props.algorithm === "string"
140 ? { name: props.algorithm }
141 : { json: JSON.stringify(props.algorithm) };
142
143 const usages = yield* Effect.forEach(props.usages, (usage) => {
144 const mapped = USAGES[usage];
145 return mapped !== undefined
146 ? Effect.succeed(mapped)
147 : Effect.fail(
148 invalid(
149 "InvalidSecretKeyUsage",
150 `secret_key binding "${props.binding}" has invalid key usage "${usage}"`,
151 props,
152 `Valid usages are ${Object.keys(USAGES)
153 .map((u) => `"${u}"`)
154 .join(", ")}.`,
155 ),
156 );
157 });
158
159 return {
160 name: props.binding,
161 cryptoKey: {
162 ...material,
163 algorithm,
164 extractable: props.extractable ?? false,
165 usages,
166 },
167 };
168 });