1import * as AWS from "@/AWS";
2import * as kms from "@distilled.cloud/aws/kms";
3import crypto from "node:crypto";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9
10/**
11 * Standing test key aliases — see the header comment in `Bindings.test.ts`.
12 * The keys behind these aliases are acquired out-of-band by the test's
13 * `beforeAll` (reclaiming the previous run's pending-deletion keys when
14 * possible) and released in `afterAll` by deleting the aliases and
15 * scheduling the keys for deletion (7-day minimum window — KMS's terminal
16 * "deleted" state; pending-deletion keys are not billed). A passing run
17 * therefore leaves no aliases and no enabled keys behind.
18 */
19export const STANDING_KEY_ALIAS = "alias/alchemy-test-kms-bindings" as const;
20export const STANDING_HMAC_KEY_ALIAS =
21 "alias/alchemy-test-bindings-hmac" as const;
22export const STANDING_SIGNING_KEY_ALIAS =
23 "alias/alchemy-test-bindings-sign" as const;
24export const STANDING_AGREEMENT_KEY_ALIAS =
25 "alias/alchemy-test-bindings-ecdh" as const;
26
27export class KMSTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
28 "KMSTestFunction",
29) {}
30
31const fromBase64 = (value: string) =>
32 Effect.sync(() => new Uint8Array(Buffer.from(value, "base64")));
33
34const toBase64 = (value: Uint8Array) =>
35 Effect.sync(() => Buffer.from(value).toString("base64"));
36
37const unwrapSensitive = (
38 value: Uint8Array | Redacted.Redacted<Uint8Array> | undefined,
39): Uint8Array | undefined =>
40 value === undefined
41 ? undefined
42 : Redacted.isRedacted(value)
43 ? Redacted.value(value)
44 : value;
45
46export default KMSTestFunction.make(
47 {
48 main: import.meta.url,
49 functionUrl: true,
50 },
51 Effect.gen(function* () {
52 // Symmetric encryption key
53 const encrypt = yield* AWS.KMS.Encrypt(STANDING_KEY_ALIAS);
54 const decrypt = yield* AWS.KMS.Decrypt(STANDING_KEY_ALIAS);
55 const generateDataKey = yield* AWS.KMS.GenerateDataKey(STANDING_KEY_ALIAS);
56 const generateDataKeyWithoutPlaintext =
57 yield* AWS.KMS.GenerateDataKeyWithoutPlaintext(STANDING_KEY_ALIAS);
58 const generateDataKeyPair =
59 yield* AWS.KMS.GenerateDataKeyPair(STANDING_KEY_ALIAS);
60 const generateDataKeyPairWithoutPlaintext =
61 yield* AWS.KMS.GenerateDataKeyPairWithoutPlaintext(STANDING_KEY_ALIAS);
62 const reEncrypt = yield* AWS.KMS.ReEncrypt(STANDING_KEY_ALIAS);
63 const describeKey = yield* AWS.KMS.DescribeKey(STANDING_KEY_ALIAS);
64 // HMAC key
65 const generateMac = yield* AWS.KMS.GenerateMac(STANDING_HMAC_KEY_ALIAS);
66 const verifyMac = yield* AWS.KMS.VerifyMac(STANDING_HMAC_KEY_ALIAS);
67 // Asymmetric signing key
68 const sign = yield* AWS.KMS.Sign(STANDING_SIGNING_KEY_ALIAS);
69 const verify = yield* AWS.KMS.Verify(STANDING_SIGNING_KEY_ALIAS);
70 const getSigningPublicKey = yield* AWS.KMS.GetPublicKey(
71 STANDING_SIGNING_KEY_ALIAS,
72 );
73 // ECDH key-agreement key
74 const deriveSharedSecret = yield* AWS.KMS.DeriveSharedSecret(
75 STANDING_AGREEMENT_KEY_ALIAS,
76 );
77 const getAgreementPublicKey = yield* AWS.KMS.GetPublicKey(
78 STANDING_AGREEMENT_KEY_ALIAS,
79 );
80 // Account-level (not key-scoped)
81 const generateRandom = yield* AWS.KMS.GenerateRandom();
82 // Deliberately NOT a binding: used by /unauthorized to prove the Lambda
83 // role received ONLY the bound kms actions (least privilege).
84 const getKeyRotationStatus = yield* kms.getKeyRotationStatus;
85
86 return {
87 fetch: Effect.gen(function* () {
88 const request = yield* HttpServerRequest;
89 const url = new URL(request.originalUrl);
90 const pathname = url.pathname;
91
92 if (request.method === "GET" && pathname === "/ready") {
93 return yield* HttpServerResponse.json({ ok: true });
94 }
95
96 if (request.method === "POST" && pathname === "/encrypt") {
97 const body = (yield* request.json) as {
98 plaintextBase64: string;
99 context?: Record<string, string>;
100 };
101 const result = yield* encrypt({
102 Plaintext: yield* fromBase64(body.plaintextBase64),
103 EncryptionContext: body.context,
104 });
105 return yield* HttpServerResponse.json({
106 keyId: result.KeyId,
107 ciphertextBase64: result.CiphertextBlob
108 ? yield* toBase64(result.CiphertextBlob)
109 : undefined,
110 });
111 }
112
113 if (request.method === "POST" && pathname === "/decrypt") {
114 const body = (yield* request.json) as {
115 ciphertextBase64: string;
116 context?: Record<string, string>;
117 };
118 const ciphertext = yield* fromBase64(body.ciphertextBase64);
119 const result = yield* decrypt({
120 CiphertextBlob: ciphertext,
121 EncryptionContext: body.context,
122 }).pipe(
123 Effect.match({
124 onFailure: (error) => ({
125 ok: false as const,
126 error: error._tag,
127 }),
128 onSuccess: (response) => ({
129 ok: true as const,
130 keyId: response.KeyId,
131 plaintext: unwrapSensitive(response.Plaintext),
132 }),
133 }),
134 );
135 if (!result.ok) {
136 return yield* HttpServerResponse.json(result);
137 }
138 return yield* HttpServerResponse.json({
139 ok: true,
140 keyId: result.keyId,
141 plaintextBase64: result.plaintext
142 ? yield* toBase64(result.plaintext)
143 : undefined,
144 });
145 }
146
147 if (request.method === "POST" && pathname === "/generate-data-key") {
148 const result = yield* generateDataKey({ KeySpec: "AES_256" });
149 const plaintext = unwrapSensitive(result.Plaintext);
150 return yield* HttpServerResponse.json({
151 keyId: result.KeyId,
152 plaintextBase64: plaintext ? yield* toBase64(plaintext) : undefined,
153 ciphertextBase64: result.CiphertextBlob
154 ? yield* toBase64(result.CiphertextBlob)
155 : undefined,
156 });
157 }
158
159 if (
160 request.method === "POST" &&
161 pathname === "/generate-data-key-without-plaintext"
162 ) {
163 const result = yield* generateDataKeyWithoutPlaintext({
164 KeySpec: "AES_256",
165 });
166 return yield* HttpServerResponse.json({
167 keyId: result.KeyId,
168 ciphertextBase64: result.CiphertextBlob
169 ? yield* toBase64(result.CiphertextBlob)
170 : undefined,
171 });
172 }
173
174 if (
175 request.method === "POST" &&
176 pathname === "/generate-data-key-pair"
177 ) {
178 const result = yield* generateDataKeyPair({
179 KeyPairSpec: "ECC_NIST_P256",
180 });
181 const privateKeyPlaintext = unwrapSensitive(
182 result.PrivateKeyPlaintext,
183 );
184 return yield* HttpServerResponse.json({
185 keyId: result.KeyId,
186 keyPairSpec: result.KeyPairSpec,
187 publicKeyBase64: result.PublicKey
188 ? yield* toBase64(result.PublicKey)
189 : undefined,
190 privateKeyPlaintextBase64: privateKeyPlaintext
191 ? yield* toBase64(privateKeyPlaintext)
192 : undefined,
193 privateKeyCiphertextBase64: result.PrivateKeyCiphertextBlob
194 ? yield* toBase64(result.PrivateKeyCiphertextBlob)
195 : undefined,
196 });
197 }
198
199 if (
200 request.method === "POST" &&
201 pathname === "/generate-data-key-pair-without-plaintext"
202 ) {
203 const result = yield* generateDataKeyPairWithoutPlaintext({
204 KeyPairSpec: "ECC_NIST_P256",
205 });
206 return yield* HttpServerResponse.json({
207 keyId: result.KeyId,
208 publicKeyBase64: result.PublicKey
209 ? yield* toBase64(result.PublicKey)
210 : undefined,
211 privateKeyCiphertextBase64: result.PrivateKeyCiphertextBlob
212 ? yield* toBase64(result.PrivateKeyCiphertextBlob)
213 : undefined,
214 });
215 }
216
217 if (request.method === "POST" && pathname === "/re-encrypt") {
218 const body = (yield* request.json) as {
219 ciphertextBase64: string;
220 sourceContext?: Record<string, string>;
221 destinationContext?: Record<string, string>;
222 };
223 const result = yield* reEncrypt({
224 CiphertextBlob: yield* fromBase64(body.ciphertextBase64),
225 SourceEncryptionContext: body.sourceContext,
226 DestinationEncryptionContext: body.destinationContext,
227 });
228 return yield* HttpServerResponse.json({
229 keyId: result.KeyId,
230 sourceKeyId: result.SourceKeyId,
231 ciphertextBase64: result.CiphertextBlob
232 ? yield* toBase64(result.CiphertextBlob)
233 : undefined,
234 });
235 }
236
237 if (request.method === "GET" && pathname === "/describe-key") {
238 const result = yield* describeKey();
239 return yield* HttpServerResponse.json({
240 keyId: result.KeyMetadata?.KeyId,
241 keySpec: result.KeyMetadata?.KeySpec,
242 keyState: result.KeyMetadata?.KeyState,
243 });
244 }
245
246 if (request.method === "POST" && pathname === "/mac") {
247 const body = (yield* request.json) as { messageBase64: string };
248 const result = yield* generateMac({
249 Message: yield* fromBase64(body.messageBase64),
250 MacAlgorithm: "HMAC_SHA_256",
251 });
252 return yield* HttpServerResponse.json({
253 keyId: result.KeyId,
254 macBase64: result.Mac ? yield* toBase64(result.Mac) : undefined,
255 });
256 }
257
258 if (request.method === "POST" && pathname === "/verify-mac") {
259 const body = (yield* request.json) as {
260 messageBase64: string;
261 macBase64: string;
262 };
263 const result = yield* verifyMac({
264 Message: yield* fromBase64(body.messageBase64),
265 Mac: yield* fromBase64(body.macBase64),
266 MacAlgorithm: "HMAC_SHA_256",
267 }).pipe(
268 Effect.match({
269 onFailure: (error) => ({
270 ok: false as const,
271 error: error._tag,
272 }),
273 onSuccess: (response) => ({
274 ok: true as const,
275 macValid: response.MacValid,
276 }),
277 }),
278 );
279 return yield* HttpServerResponse.json(result);
280 }
281
282 if (request.method === "POST" && pathname === "/sign") {
283 const body = (yield* request.json) as { messageBase64: string };
284 const result = yield* sign({
285 Message: yield* fromBase64(body.messageBase64),
286 SigningAlgorithm: "ECDSA_SHA_256",
287 });
288 return yield* HttpServerResponse.json({
289 keyId: result.KeyId,
290 signatureBase64: result.Signature
291 ? yield* toBase64(result.Signature)
292 : undefined,
293 });
294 }
295
296 if (request.method === "POST" && pathname === "/verify") {
297 const body = (yield* request.json) as {
298 messageBase64: string;
299 signatureBase64: string;
300 };
301 const result = yield* verify({
302 Message: yield* fromBase64(body.messageBase64),
303 Signature: yield* fromBase64(body.signatureBase64),
304 SigningAlgorithm: "ECDSA_SHA_256",
305 }).pipe(
306 Effect.match({
307 onFailure: (error) => ({
308 ok: false as const,
309 error: error._tag,
310 }),
311 onSuccess: (response) => ({
312 ok: true as const,
313 signatureValid: response.SignatureValid,
314 }),
315 }),
316 );
317 return yield* HttpServerResponse.json(result);
318 }
319
320 if (request.method === "GET" && pathname === "/public-key") {
321 const result = yield* getSigningPublicKey();
322 return yield* HttpServerResponse.json({
323 keyId: result.KeyId,
324 keyUsage: result.KeyUsage,
325 publicKeyBase64: result.PublicKey
326 ? yield* toBase64(result.PublicKey)
327 : undefined,
328 signingAlgorithms: result.SigningAlgorithms,
329 });
330 }
331
332 if (request.method === "POST" && pathname === "/derive-shared-secret") {
333 // Generate an ephemeral local P-256 key pair, run ECDH inside KMS
334 // against the local public key, then recompute the shared secret
335 // locally against the KMS key's public key. Both sides must agree.
336 const localPair = yield* Effect.sync(() =>
337 crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }),
338 );
339 const localPublicDer = yield* Effect.sync(
340 () =>
341 new Uint8Array(
342 localPair.publicKey.export({ type: "spki", format: "der" }),
343 ),
344 );
345 const kmsResult = yield* deriveSharedSecret({
346 KeyAgreementAlgorithm: "ECDH",
347 PublicKey: localPublicDer,
348 });
349 const kmsShared = unwrapSensitive(kmsResult.SharedSecret);
350 const kmsPublic = yield* getAgreementPublicKey();
351 const localShared = kmsPublic.PublicKey
352 ? yield* Effect.sync(() =>
353 crypto.diffieHellman({
354 privateKey: localPair.privateKey,
355 publicKey: crypto.createPublicKey({
356 key: Buffer.from(kmsPublic.PublicKey!),
357 format: "der",
358 type: "spki",
359 }),
360 }),
361 )
362 : undefined;
363 return yield* HttpServerResponse.json({
364 keyId: kmsResult.KeyId,
365 byteLength: kmsShared?.length,
366 match:
367 kmsShared !== undefined &&
368 localShared !== undefined &&
369 Buffer.from(kmsShared).equals(localShared),
370 });
371 }
372
373 if (request.method === "POST" && pathname === "/random") {
374 const result = yield* generateRandom({ NumberOfBytes: 32 });
375 const bytes = unwrapSensitive(result.Plaintext);
376 return yield* HttpServerResponse.json({
377 randomBase64: bytes ? yield* toBase64(bytes) : undefined,
378 });
379 }
380
381 if (request.method === "GET" && pathname === "/unauthorized") {
382 // kms:GetKeyRotationStatus is not bound — the role must reject it.
383 // The API only accepts a key id/ARN (not an alias), so resolve the
384 // real key id through the bound DescribeKey first.
385 const described = yield* describeKey();
386 const result = yield* getKeyRotationStatus({
387 KeyId: described.KeyMetadata!.KeyId,
388 }).pipe(
389 Effect.match({
390 onFailure: (error) => ({
391 ok: false as const,
392 error: error._tag,
393 }),
394 onSuccess: () => ({ ok: true as const }),
395 }),
396 );
397 return yield* HttpServerResponse.json(result);
398 }
399
400 return yield* HttpServerResponse.json(
401 { error: "Not found", method: request.method, pathname },
402 { status: 404 },
403 );
404 }).pipe(Effect.orDie),
405 };
406 }).pipe(
407 Effect.provide(
408 Layer.mergeAll(
409 AWS.KMS.DecryptHttp,
410 AWS.KMS.EncryptHttp,
411 AWS.KMS.GenerateDataKeyHttp,
412 AWS.KMS.GenerateDataKeyWithoutPlaintextHttp,
413 AWS.KMS.GenerateDataKeyPairHttp,
414 AWS.KMS.GenerateDataKeyPairWithoutPlaintextHttp,
415 AWS.KMS.ReEncryptHttp,
416 AWS.KMS.DescribeKeyHttp,
417 AWS.KMS.GenerateMacHttp,
418 AWS.KMS.VerifyMacHttp,
419 AWS.KMS.SignHttp,
420 AWS.KMS.VerifyHttp,
421 AWS.KMS.GetPublicKeyHttp,
422 AWS.KMS.DeriveSharedSecretHttp,
423 AWS.KMS.GenerateRandomHttp,
424 ),
425 ),
426 ),
427);