1/**
2 * KMS binding tests (Encrypt / Decrypt / GenerateDataKey* / ReEncrypt /
3 * Sign / Verify / GenerateMac / VerifyMac / GetPublicKey /
4 * DeriveSharedSecret / DescribeKey / GenerateRandom).
5 *
6 * COST + CLEANUP NOTE — the shared test keys:
7 * KMS keys cost $1/mo while enabled and have a 7-day minimum pending-deletion
8 * window (pending-deletion keys are NOT billed), so this suite neither keeps
9 * permanently-enabled keys nor creates brand-new keys per run. The `Key`
10 * resource has no user-assignable identity (only a cloud-generated keyId), so
11 * it can't be adopted across runs from the scratch stack's in-memory state.
12 * Instead FOUR standing keys (symmetric encryption, HMAC, ECDSA signing, and
13 * ECDH key agreement — each crypto operation requires its matching KeyUsage)
14 * are acquired/released out-of-band via distilled KMS around the whole suite:
15 *
16 * - During the run each key is addressed by its deterministic alias (see the
17 * `STANDING_*_ALIAS` constants in `handler.ts`).
18 * - `beforeAll` (`ensureStandingKeys`) reclaims the previous run's keys — via
19 * the alias if an interrupted run left it behind, otherwise by each key's
20 * unique description — cancelling scheduled deletion and re-creating the
21 * alias. Only on the first-ever run (or >7 days after the last run) does
22 * it `createKey`.
23 * - `afterAll` (`releaseStandingKeys`) deletes the aliases and schedules the
24 * keys for deletion (7-day window, idempotent) so a passing run leaves no
25 * aliases and no enabled/billed keys behind — PendingDeletion is KMS's
26 * terminal "deleted" state; keys pending deletion are not billed and
27 * cannot be removed any faster.
28 * - The Lambda fixture binds the crypto operations by alias name — the
29 * bindings accept `Key | AliasName`, and the alias form scopes IAM with the
30 * `kms:RequestAlias` condition so the keys never need to live in the stack.
31 *
32 * The fixture stack (Lambda only) is destroyed normally.
33 */
34import * as AWS from "@/AWS";
35import * as Core from "@/Test/Core";
36import * as Test from "@/Test/Alchemy";
37import * as kms from "@distilled.cloud/aws/kms";
38import { describe, expect } from "alchemy-test";
39import * as Data from "effect/Data";
40import * as Effect from "effect/Effect";
41import * as Schedule from "effect/Schedule";
42import * as Stream from "effect/Stream";
43import * as HttpClient from "effect/http/HttpClient";
44import * as HttpClientRequest from "effect/http/HttpClientRequest";
45import KMSTestFunctionLive, {
46 KMSTestFunction,
47 STANDING_AGREEMENT_KEY_ALIAS,
48 STANDING_HMAC_KEY_ALIAS,
49 STANDING_KEY_ALIAS,
50 STANDING_SIGNING_KEY_ALIAS,
51} from "./handler";
52
53const testOptions = { providers: AWS.providers() };
54const { test, beforeAll, afterAll } = Test.make(testOptions);
55const sharedStack = Core.scratchStack(testOptions, "KMSBindings");
56
57// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
58// over 60s on a fresh deploy under parallel-suite load.
59const readinessPolicy = Schedule.max([
60 Schedule.fixed("2 seconds"),
61 Schedule.recurs(75),
62]);
63
64let baseUrl: string;
65let standingKeyId: string;
66
67class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
68 readonly status: number;
69 readonly body: string;
70}> {}
71
72class CryptoNotAuthorized extends Data.TaggedError("CryptoNotAuthorized") {}
73
74class StandingKeyNotReady extends Data.TaggedError("StandingKeyNotReady")<{
75 readonly actualState: kms.KeyState | undefined;
76 readonly expectedState: kms.KeyState;
77 readonly keyId: string;
78}> {}
79
80// Cold re-inits under parallel load surface as transient 5xx from the fixture
81// — retry those; a genuine 4xx/assertion failure is returned immediately.
82const send = (request: HttpClientRequest.HttpClientRequest) =>
83 HttpClient.execute(request).pipe(
84 Effect.flatMap((response) =>
85 response.status >= 500
86 ? response.text.pipe(
87 Effect.flatMap((body) =>
88 Effect.fail(
89 new TransientUpstream({ status: response.status, body }),
90 ),
91 ),
92 )
93 : Effect.succeed(response),
94 ),
95 Effect.retry({
96 while: (e) => e._tag === "TransientUpstream",
97 schedule: Schedule.max([
98 Schedule.exponential("500 millis"),
99 Schedule.recurs(6),
100 ]),
101 }),
102 );
103
104const postJson = (path: string, body: object) =>
105 send(
106 HttpClientRequest.bodyJsonUnsafe(
107 HttpClientRequest.post(`${baseUrl}${path}`),
108 body,
109 ),
110 ).pipe(Effect.flatMap((response) => response.json));
111
112const getJson = (path: string) =>
113 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
114 Effect.flatMap((response) => response.json),
115 );
116
117const toBase64 = (value: string) =>
118 Effect.sync(() => Buffer.from(value, "utf8").toString("base64"));
119
120const fromBase64 = (value: string) =>
121 Effect.sync(() => Buffer.from(value, "base64").toString("utf8"));
122
123interface StandingKeySpec {
124 readonly alias: `alias/${string}`;
125 /**
126 * Unique marker for the key. The alias is deleted at teardown, so this
127 * description is what identifies the previous run's (pending-deletion)
128 * key for reclaim.
129 */
130 readonly description: string;
131 readonly keySpec?: kms.KeySpec;
132 readonly keyUsage?: kms.KeyUsageType;
133}
134
135const RECLAIM_NOTE =
136 "scheduled for deletion after each run, reclaimed by description (see test/AWS/KMS/Bindings.test.ts)";
137
138const STANDING_KEYS: readonly StandingKeySpec[] = [
139 {
140 alias: STANDING_KEY_ALIAS,
141 // Keep this exact text stable — it reclaims keys minted by earlier runs.
142 description: `Shared key for alchemy AWS.KMS binding tests — ${RECLAIM_NOTE}`,
143 },
144 {
145 alias: STANDING_HMAC_KEY_ALIAS,
146 description: `Shared HMAC key for alchemy AWS.KMS binding tests — ${RECLAIM_NOTE}`,
147 keySpec: "HMAC_256",
148 keyUsage: "GENERATE_VERIFY_MAC",
149 },
150 {
151 alias: STANDING_SIGNING_KEY_ALIAS,
152 description: `Shared ECDSA signing key for alchemy AWS.KMS binding tests — ${RECLAIM_NOTE}`,
153 keySpec: "ECC_NIST_P256",
154 keyUsage: "SIGN_VERIFY",
155 },
156 {
157 alias: STANDING_AGREEMENT_KEY_ALIAS,
158 description: `Shared ECDH key-agreement key for alchemy AWS.KMS binding tests — ${RECLAIM_NOTE}`,
159 keySpec: "ECC_NIST_P256",
160 keyUsage: "KEY_AGREEMENT",
161 },
162];
163
164/**
165 * One scan of the account's customer keys, hydrated with metadata. Used to
166 * reclaim previous runs' pending-deletion keys by their unique descriptions
167 * once the aliases are gone.
168 */
169const scanKeyMetadatas = Effect.gen(function* () {
170 const keys = yield* kms.listKeys.pages({}).pipe(
171 Stream.runCollect,
172 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Keys ?? [])),
173 );
174 return yield* Effect.all(
175 keys.map((key) =>
176 kms.describeKey({ KeyId: key.KeyId! }).pipe(
177 Effect.map((response) => response.KeyMetadata),
178 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
179 ),
180 ),
181 { concurrency: 5 },
182 );
183});
184
185/**
186 * Wait for KMS's eventually-consistent state transition to become visible.
187 * Ten seconds is ample in practice and keeps a broken transition bounded.
188 */
189const waitForKeyState = Effect.fn(function* (
190 keyId: string,
191 expectedState: kms.KeyState,
192) {
193 return yield* kms.describeKey({ KeyId: keyId }).pipe(
194 Effect.flatMap((response) => {
195 const actualState = response.KeyMetadata?.KeyState;
196 return actualState === expectedState
197 ? Effect.succeed(response.KeyMetadata!)
198 : Effect.fail(
199 new StandingKeyNotReady({
200 actualState,
201 expectedState,
202 keyId,
203 }),
204 );
205 }),
206 Effect.retry({
207 while: (error) => error._tag === "StandingKeyNotReady",
208 schedule: Schedule.max([
209 Schedule.fixed("250 millis"),
210 Schedule.recurs(40),
211 ]),
212 }),
213 );
214});
215
216/** Converge an existing (possibly pending-deletion/disabled) key to enabled. */
217const reviveKey = Effect.fn(function* (metadata: kms.KeyMetadata) {
218 const keyId = metadata.KeyId!;
219 if (metadata.KeyState === "PendingDeletion") {
220 yield* kms.cancelKeyDeletion({ KeyId: keyId });
221 // CancelKeyDeletion leaves the key disabled, but that transition is not
222 // immediately visible. Enabling or aliasing it too early is rejected as
223 // KMSInvalidStateException (the c64 sweep's 17-test setup cascade).
224 yield* waitForKeyState(keyId, "Disabled");
225 yield* kms.enableKey({ KeyId: keyId });
226 } else if (metadata.Enabled === false) {
227 yield* kms.enableKey({ KeyId: keyId });
228 }
229 return yield* waitForKeyState(keyId, "Enabled");
230});
231
232/**
233 * Acquire one standing key: reclaim the previous run's key (cancelling its
234 * scheduled deletion and re-creating the alias), or create it on the
235 * first-ever run. `scan` is the lazily-computed account key scan, shared
236 * across the four keys.
237 */
238const ensureStandingKey = Effect.fn(function* (
239 spec: StandingKeySpec,
240 scanned: { metadatas?: readonly (kms.KeyMetadata | undefined)[] },
241) {
242 const existing = yield* kms.describeKey({ KeyId: spec.alias }).pipe(
243 Effect.map((response) => response.KeyMetadata),
244 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
245 );
246
247 if (existing?.KeyId) {
248 // The alias survived (a previous run was interrupted before release, or
249 // external interference disabled the key) — converge to enabled.
250 yield* reviveKey(existing);
251 return existing.KeyId;
252 }
253
254 // Alias absent — the normal case after a clean release. Reclaim the
255 // previous run's pending-deletion key by its unique description before
256 // resorting to creating a new one.
257 if (scanned.metadatas === undefined) {
258 scanned.metadatas = yield* scanKeyMetadatas;
259 }
260 const reclaimed = scanned.metadatas?.find(
261 (metadata) => metadata?.Description === spec.description,
262 );
263 const keyId = reclaimed?.KeyId
264 ? yield* reviveKey(reclaimed).pipe(Effect.map(() => reclaimed.KeyId!))
265 : yield* kms
266 .createKey({
267 Description: spec.description,
268 KeySpec: spec.keySpec,
269 KeyUsage: spec.keyUsage,
270 Tags: [
271 { TagKey: "alchemy:standing-fixture", TagValue: "kms-bindings" },
272 ],
273 })
274 .pipe(
275 Effect.flatMap((created) =>
276 created.KeyMetadata?.KeyId
277 ? Effect.succeed(created.KeyMetadata.KeyId)
278 : Effect.die(new Error("createKey returned no key ID")),
279 ),
280 );
281
282 yield* kms.createAlias({ AliasName: spec.alias, TargetKeyId: keyId }).pipe(
283 // DescribeKey reaches Enabled before CreateAlias's internal view of the
284 // key always converges. Retry only that exact transient state error.
285 Effect.retry({
286 while: (error) => error._tag === "KMSInvalidStateException",
287 schedule: Schedule.max([
288 Schedule.fixed("250 millis"),
289 Schedule.recurs(40),
290 ]),
291 }),
292 Effect.catchTag("AlreadyExistsException", () =>
293 // Lost a create race with a parallel run: the alias already points at
294 // another key. Schedule ours for deletion so it doesn't become an
295 // orphan, and fall through to the alias's actual target.
296 kms
297 .scheduleKeyDeletion({ KeyId: keyId, PendingWindowInDays: 7 })
298 .pipe(Effect.ignore),
299 ),
300 );
301 // CreateAlias is eventually consistent: the immediate read-back can 404
302 // under load. Retry the alias lookup for a bounded window.
303 const described = yield* kms.describeKey({ KeyId: spec.alias }).pipe(
304 Effect.retry({
305 while: (error) => error._tag === "NotFoundException",
306 schedule: Schedule.max([
307 Schedule.exponential("500 millis"),
308 Schedule.recurs(8),
309 ]),
310 }),
311 );
312 return described.KeyMetadata!.KeyId;
313});
314
315/** Acquire all four standing keys; returns the symmetric key's id. */
316const ensureStandingKeys = Effect.gen(function* () {
317 const scanned: { metadatas?: readonly (kms.KeyMetadata | undefined)[] } = {};
318 let symmetricKeyId = "";
319 for (const spec of STANDING_KEYS) {
320 const keyId = yield* ensureStandingKey(spec, scanned);
321 if (spec.alias === STANDING_KEY_ALIAS) {
322 symmetricKeyId = keyId;
323 }
324 }
325 return symmetricKeyId;
326});
327
328/**
329 * Release the shared test keys: delete each alias and schedule its key for
330 * deletion (7-day minimum window — KMS keys cannot be hard-deleted) so a
331 * passing run leaves no aliases and no enabled/billed keys behind.
332 * Idempotent — tolerates aliases/keys already gone or keys already pending
333 * deletion (a concurrent run's release, or a re-run after partial teardown).
334 */
335const releaseStandingKeys = Effect.gen(function* () {
336 const scanned: { metadatas?: readonly (kms.KeyMetadata | undefined)[] } = {};
337 for (const spec of STANDING_KEYS) {
338 const viaAlias = yield* kms.describeKey({ KeyId: spec.alias }).pipe(
339 Effect.map((response) => response.KeyMetadata),
340 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
341 );
342
343 yield* kms
344 .deleteAlias({ AliasName: spec.alias })
345 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
346
347 // If the alias was already gone (interrupted earlier release), fall back
348 // to the description lookup so an enabled key never survives teardown.
349 if (viaAlias === undefined && scanned.metadatas === undefined) {
350 scanned.metadatas = yield* scanKeyMetadatas;
351 }
352 const target =
353 viaAlias ??
354 scanned.metadatas?.find(
355 (metadata) => metadata?.Description === spec.description,
356 );
357 if (target?.KeyId && target.KeyState !== "PendingDeletion") {
358 yield* kms
359 .scheduleKeyDeletion({ KeyId: target.KeyId, PendingWindowInDays: 7 })
360 .pipe(
361 // Already pending deletion (raced with another run) or already gone.
362 Effect.catchTag(
363 ["KMSInvalidStateException", "NotFoundException"],
364 () => Effect.void,
365 ),
366 );
367 }
368 }
369});
370
371describe(
372 "KMS Bindings",
373 { tags: ["provider:aws", "provider:aws:kms", "provider:aws:lambda", "live"] },
374 () => {
375 beforeAll(
376 Effect.gen(function* () {
377 yield* Effect.logInfo("KMS test setup: ensuring standing keys");
378 // `beforeAll` doesn't run inside `test.provider`'s environment, so
379 // provide the AWS providers (Credentials/Region) explicitly for the
380 // out-of-band distilled calls.
381 standingKeyId = yield* Core.withProviders(
382 ensureStandingKeys,
383 testOptions,
384 "KMSBindings",
385 );
386 yield* Effect.logInfo(
387 `KMS test setup: standing key ${standingKeyId} (${STANDING_KEY_ALIAS})`,
388 );
389
390 yield* Effect.logInfo("KMS test setup: destroying previous resources");
391 yield* sharedStack.destroy();
392
393 yield* Effect.logInfo("KMS test setup: deploying fixture");
394 const { functionUrl } = yield* sharedStack.deploy(
395 Effect.gen(function* () {
396 return yield* KMSTestFunction;
397 }).pipe(Effect.provide(KMSTestFunctionLive)),
398 );
399
400 expect(functionUrl).toBeTruthy();
401 baseUrl = functionUrl!.replace(/\/+$/, "");
402
403 yield* Effect.logInfo(
404 `KMS test setup: probing readiness at ${baseUrl}/ready`,
405 );
406 yield* HttpClient.get(`${baseUrl}/ready`).pipe(
407 Effect.flatMap((response) =>
408 response.status === 200
409 ? Effect.succeed(response)
410 : Effect.fail(
411 new Error(`Function not ready: ${response.status}`),
412 ),
413 ),
414 Effect.retry({ schedule: readinessPolicy }),
415 );
416
417 // The fresh Lambda role's IAM policy propagates eventually — /ready
418 // exercises no KMS permission, so also probe a full encrypt/decrypt
419 // round-trip plus one operation per standing key (bounded) before
420 // letting the tests run. Without this the first calls occasionally
421 // land before the role policy is authorized.
422 yield* Effect.logInfo("KMS test setup: probing crypto authorization");
423 yield* Effect.gen(function* () {
424 const probePlaintext = yield* toBase64("kms-iam-propagation-probe");
425 const encrypted = (yield* postJson("/encrypt", {
426 plaintextBase64: probePlaintext,
427 })) as { ciphertextBase64?: string };
428 if (!encrypted.ciphertextBase64) {
429 return yield* Effect.fail(new CryptoNotAuthorized());
430 }
431 const decrypted = (yield* postJson("/decrypt", {
432 ciphertextBase64: encrypted.ciphertextBase64,
433 })) as { ok: boolean };
434 if (!decrypted.ok) {
435 return yield* Effect.fail(new CryptoNotAuthorized());
436 }
437 const mac = (yield* postJson("/mac", {
438 messageBase64: probePlaintext,
439 })) as { macBase64?: string };
440 if (!mac.macBase64) {
441 return yield* Effect.fail(new CryptoNotAuthorized());
442 }
443 const signed = (yield* postJson("/sign", {
444 messageBase64: probePlaintext,
445 })) as { signatureBase64?: string };
446 if (!signed.signatureBase64) {
447 return yield* Effect.fail(new CryptoNotAuthorized());
448 }
449 }).pipe(
450 Effect.retry({
451 while: (error) => error._tag === "CryptoNotAuthorized",
452 schedule: Schedule.max([
453 Schedule.fixed("2 seconds"),
454 Schedule.recurs(30),
455 ]),
456 }),
457 );
458 }),
459 { timeout: 240_000 },
460 );
461
462 // Release the out-of-band keys even if the stack destroy fails — a passing
463 // (or failing) run must never leave enabled KMS keys behind.
464 afterAll(
465 sharedStack
466 .destroy()
467 .pipe(
468 Effect.ensuring(
469 Core.withProviders(
470 releaseStandingKeys,
471 testOptions,
472 "KMSBindings",
473 ).pipe(Effect.orDie),
474 ),
475 ),
476 { timeout: 120_000 },
477 );
478
479 describe("Encrypt", () => {
480 test.provider("encrypts a payload under the standing key", (_stack) =>
481 Effect.gen(function* () {
482 const plaintextBase64 = yield* toBase64("alchemy kms encrypt");
483 const response = (yield* postJson("/encrypt", {
484 plaintextBase64,
485 })) as { keyId?: string; ciphertextBase64?: string };
486
487 expect(response.ciphertextBase64).toBeTruthy();
488 expect(response.ciphertextBase64).not.toEqual(plaintextBase64);
489 // KMS resolves the alias and reports the backing key ARN.
490 expect(response.keyId).toContain(standingKeyId);
491 }),
492 );
493 });
494
495 describe("Decrypt", () => {
496 test.provider("round-trips plaintext through encrypt/decrypt", (_stack) =>
497 Effect.gen(function* () {
498 const message = "alchemy kms round-trip: attack at dawn";
499 const encrypted = (yield* postJson("/encrypt", {
500 plaintextBase64: yield* toBase64(message),
501 context: { tenant: "alchemy-test" },
502 })) as { ciphertextBase64: string };
503
504 const decrypted = (yield* postJson("/decrypt", {
505 ciphertextBase64: encrypted.ciphertextBase64,
506 context: { tenant: "alchemy-test" },
507 })) as { ok: boolean; keyId?: string; plaintextBase64?: string };
508
509 expect(decrypted.ok).toBe(true);
510 expect(yield* fromBase64(decrypted.plaintextBase64!)).toEqual(
511 message,
512 );
513 expect(decrypted.keyId).toContain(standingKeyId);
514 }),
515 );
516
517 test.provider(
518 "fails with a typed InvalidCiphertextException on context mismatch",
519 (_stack) =>
520 Effect.gen(function* () {
521 const encrypted = (yield* postJson("/encrypt", {
522 plaintextBase64: yield* toBase64("context-bound secret"),
523 context: { tenant: "alpha" },
524 })) as { ciphertextBase64: string };
525
526 const decrypted = (yield* postJson("/decrypt", {
527 ciphertextBase64: encrypted.ciphertextBase64,
528 context: { tenant: "beta" },
529 })) as { ok: boolean; error?: string };
530
531 expect(decrypted.ok).toBe(false);
532 expect(decrypted.error).toEqual("InvalidCiphertextException");
533 }),
534 );
535 });
536
537 describe("GenerateDataKey", () => {
538 test.provider(
539 "returns a plaintext data key whose ciphertext blob decrypts back",
540 (_stack) =>
541 Effect.gen(function* () {
542 const generated = (yield* postJson("/generate-data-key", {})) as {
543 keyId?: string;
544 plaintextBase64?: string;
545 ciphertextBase64?: string;
546 };
547
548 expect(generated.plaintextBase64).toBeTruthy();
549 expect(generated.ciphertextBase64).toBeTruthy();
550 expect(generated.keyId).toContain(standingKeyId);
551 // AES_256 data key = 32 bytes.
552 const dataKey = yield* Effect.sync(() =>
553 Buffer.from(generated.plaintextBase64!, "base64"),
554 );
555 expect(dataKey.length).toBe(32);
556
557 // The encrypted copy must decrypt (via the Decrypt binding) back to
558 // the exact plaintext data key.
559 const decrypted = (yield* postJson("/decrypt", {
560 ciphertextBase64: generated.ciphertextBase64,
561 })) as { ok: boolean; plaintextBase64?: string };
562
563 expect(decrypted.ok).toBe(true);
564 expect(decrypted.plaintextBase64).toEqual(
565 generated.plaintextBase64,
566 );
567 }),
568 );
569 });
570
571 describe("GenerateDataKeyWithoutPlaintext", () => {
572 test.provider(
573 "returns only a ciphertext blob that decrypts to a 32-byte key",
574 (_stack) =>
575 Effect.gen(function* () {
576 const generated = (yield* postJson(
577 "/generate-data-key-without-plaintext",
578 {},
579 )) as { keyId?: string; ciphertextBase64?: string };
580
581 expect(generated.ciphertextBase64).toBeTruthy();
582 expect(generated.keyId).toContain(standingKeyId);
583
584 const decrypted = (yield* postJson("/decrypt", {
585 ciphertextBase64: generated.ciphertextBase64,
586 })) as { ok: boolean; plaintextBase64?: string };
587
588 expect(decrypted.ok).toBe(true);
589 const dataKey = yield* Effect.sync(() =>
590 Buffer.from(decrypted.plaintextBase64!, "base64"),
591 );
592 expect(dataKey.length).toBe(32);
593 }),
594 );
595 });
596
597 describe("GenerateDataKeyPair", () => {
598 test.provider(
599 "returns a key pair whose private blob decrypts back to the plaintext",
600 (_stack) =>
601 Effect.gen(function* () {
602 const generated = (yield* postJson(
603 "/generate-data-key-pair",
604 {},
605 )) as {
606 keyId?: string;
607 keyPairSpec?: string;
608 publicKeyBase64?: string;
609 privateKeyPlaintextBase64?: string;
610 privateKeyCiphertextBase64?: string;
611 };
612
613 expect(generated.keyPairSpec).toEqual("ECC_NIST_P256");
614 expect(generated.publicKeyBase64).toBeTruthy();
615 expect(generated.privateKeyPlaintextBase64).toBeTruthy();
616 expect(generated.privateKeyCiphertextBase64).toBeTruthy();
617
618 const decrypted = (yield* postJson("/decrypt", {
619 ciphertextBase64: generated.privateKeyCiphertextBase64,
620 })) as { ok: boolean; plaintextBase64?: string };
621
622 expect(decrypted.ok).toBe(true);
623 expect(decrypted.plaintextBase64).toEqual(
624 generated.privateKeyPlaintextBase64,
625 );
626 }),
627 );
628 });
629
630 describe("GenerateDataKeyPairWithoutPlaintext", () => {
631 test.provider(
632 "returns a public key and an encrypted private key only",
633 (_stack) =>
634 Effect.gen(function* () {
635 const generated = (yield* postJson(
636 "/generate-data-key-pair-without-plaintext",
637 {},
638 )) as {
639 publicKeyBase64?: string;
640 privateKeyCiphertextBase64?: string;
641 };
642
643 expect(generated.publicKeyBase64).toBeTruthy();
644 expect(generated.privateKeyCiphertextBase64).toBeTruthy();
645
646 const decrypted = (yield* postJson("/decrypt", {
647 ciphertextBase64: generated.privateKeyCiphertextBase64,
648 })) as { ok: boolean; plaintextBase64?: string };
649 expect(decrypted.ok).toBe(true);
650 expect(decrypted.plaintextBase64).toBeTruthy();
651 }),
652 );
653 });
654
655 describe("ReEncrypt", () => {
656 test.provider(
657 "rotates the encryption context without exposing the plaintext",
658 (_stack) =>
659 Effect.gen(function* () {
660 const message = "re-encrypt me in place";
661 const encrypted = (yield* postJson("/encrypt", {
662 plaintextBase64: yield* toBase64(message),
663 context: { tenant: "alpha" },
664 })) as { ciphertextBase64: string };
665
666 const reEncrypted = (yield* postJson("/re-encrypt", {
667 ciphertextBase64: encrypted.ciphertextBase64,
668 sourceContext: { tenant: "alpha" },
669 destinationContext: { tenant: "beta" },
670 })) as {
671 keyId?: string;
672 sourceKeyId?: string;
673 ciphertextBase64?: string;
674 };
675
676 expect(reEncrypted.ciphertextBase64).toBeTruthy();
677 expect(reEncrypted.ciphertextBase64).not.toEqual(
678 encrypted.ciphertextBase64,
679 );
680 expect(reEncrypted.keyId).toContain(standingKeyId);
681
682 const decrypted = (yield* postJson("/decrypt", {
683 ciphertextBase64: reEncrypted.ciphertextBase64,
684 context: { tenant: "beta" },
685 })) as { ok: boolean; plaintextBase64?: string };
686
687 expect(decrypted.ok).toBe(true);
688 expect(yield* fromBase64(decrypted.plaintextBase64!)).toEqual(
689 message,
690 );
691 }),
692 );
693 });
694
695 describe("DescribeKey", () => {
696 test.provider("describes the bound key through the alias", (_stack) =>
697 Effect.gen(function* () {
698 const described = (yield* getJson("/describe-key")) as {
699 keyId?: string;
700 keySpec?: string;
701 keyState?: string;
702 };
703 expect(described.keyId).toEqual(standingKeyId);
704 expect(described.keySpec).toEqual("SYMMETRIC_DEFAULT");
705 expect(described.keyState).toEqual("Enabled");
706 }),
707 );
708 });
709
710 describe("GenerateMac / VerifyMac", () => {
711 test.provider("computes an HMAC that verifies", (_stack) =>
712 Effect.gen(function* () {
713 const messageBase64 = yield* toBase64("hmac-protected payload");
714 const mac = (yield* postJson("/mac", { messageBase64 })) as {
715 macBase64?: string;
716 };
717 expect(mac.macBase64).toBeTruthy();
718
719 const verified = (yield* postJson("/verify-mac", {
720 messageBase64,
721 macBase64: mac.macBase64,
722 })) as { ok: boolean; macValid?: boolean };
723
724 expect(verified.ok).toBe(true);
725 expect(verified.macValid).toBe(true);
726 }),
727 );
728
729 test.provider(
730 "rejects a tampered message with a typed KMSInvalidMacException",
731 (_stack) =>
732 Effect.gen(function* () {
733 const mac = (yield* postJson("/mac", {
734 messageBase64: yield* toBase64("original message"),
735 })) as { macBase64?: string };
736
737 const verified = (yield* postJson("/verify-mac", {
738 messageBase64: yield* toBase64("tampered message"),
739 macBase64: mac.macBase64,
740 })) as { ok: boolean; error?: string };
741
742 expect(verified.ok).toBe(false);
743 expect(verified.error).toEqual("KMSInvalidMacException");
744 }),
745 );
746 });
747
748 describe("Sign / Verify", () => {
749 test.provider("signs a message that verifies inside KMS", (_stack) =>
750 Effect.gen(function* () {
751 const messageBase64 = yield* toBase64("release-manifest-v1");
752 const signed = (yield* postJson("/sign", { messageBase64 })) as {
753 signatureBase64?: string;
754 };
755 expect(signed.signatureBase64).toBeTruthy();
756
757 const verified = (yield* postJson("/verify", {
758 messageBase64,
759 signatureBase64: signed.signatureBase64,
760 })) as { ok: boolean; signatureValid?: boolean };
761
762 expect(verified.ok).toBe(true);
763 expect(verified.signatureValid).toBe(true);
764 }),
765 );
766
767 test.provider(
768 "rejects a tampered message with a typed KMSInvalidSignatureException",
769 (_stack) =>
770 Effect.gen(function* () {
771 const signed = (yield* postJson("/sign", {
772 messageBase64: yield* toBase64("original manifest"),
773 })) as { signatureBase64?: string };
774
775 const verified = (yield* postJson("/verify", {
776 messageBase64: yield* toBase64("forged manifest"),
777 signatureBase64: signed.signatureBase64,
778 })) as { ok: boolean; error?: string };
779
780 expect(verified.ok).toBe(false);
781 expect(verified.error).toEqual("KMSInvalidSignatureException");
782 }),
783 );
784 });
785
786 describe("GetPublicKey", () => {
787 test.provider(
788 "downloads the signing key's DER-encoded public key",
789 (_stack) =>
790 Effect.gen(function* () {
791 const response = (yield* getJson("/public-key")) as {
792 keyUsage?: string;
793 publicKeyBase64?: string;
794 signingAlgorithms?: string[];
795 };
796 expect(response.keyUsage).toEqual("SIGN_VERIFY");
797 expect(response.publicKeyBase64).toBeTruthy();
798 expect(response.signingAlgorithms).toContain("ECDSA_SHA_256");
799 }),
800 );
801 });
802
803 describe("DeriveSharedSecret", () => {
804 test.provider(
805 "KMS-side ECDH matches a locally computed shared secret",
806 (_stack) =>
807 Effect.gen(function* () {
808 const response = (yield* postJson("/derive-shared-secret", {})) as {
809 byteLength?: number;
810 match?: boolean;
811 };
812 // P-256 ECDH shared secret = 32 bytes.
813 expect(response.byteLength).toBe(32);
814 expect(response.match).toBe(true);
815 }),
816 );
817 });
818
819 describe("GenerateRandom", () => {
820 test.provider("returns distinct 32-byte random payloads", (_stack) =>
821 Effect.gen(function* () {
822 const first = (yield* postJson("/random", {})) as {
823 randomBase64?: string;
824 };
825 const second = (yield* postJson("/random", {})) as {
826 randomBase64?: string;
827 };
828 expect(first.randomBase64).toBeTruthy();
829 expect(second.randomBase64).toBeTruthy();
830 const bytes = yield* Effect.sync(() =>
831 Buffer.from(first.randomBase64!, "base64"),
832 );
833 expect(bytes.length).toBe(32);
834 expect(first.randomBase64).not.toEqual(second.randomBase64);
835 }),
836 );
837 });
838
839 describe("least privilege", () => {
840 test.provider(
841 "the role only receives the bound actions (kms:GetKeyRotationStatus is denied)",
842 (_stack) =>
843 Effect.gen(function* () {
844 const response = (yield* getJson("/unauthorized")) as {
845 ok: boolean;
846 error?: string;
847 };
848
849 expect(response.ok).toBe(false);
850 expect(response.error).toEqual("AccessDeniedException");
851 }),
852 );
853 });
854 },
855);