1import * as kms from "@distilled.cloud/aws/kms";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags } from "../../Tags.ts";
10import type { AccountID } from "../Environment.ts";
11import type { PolicyDocument } from "../IAM/Policy.ts";
12import {
13 normalizePolicyDocument,
14 stringifyPolicyDocument,
15} from "../IAM/Policy.ts";
16import type { Providers } from "../Providers.ts";
17import type { RegionID } from "../Region.ts";
18import { toWireDays } from "../../Util/Duration.ts";
19
20export type KeyId = string;
21export type KeyArn = `arn:aws:kms:${RegionID}:${AccountID}:key/${KeyId}`;
22
23export type { KeySpec, KeyState, KeyUsageType } from "@distilled.cloud/aws/kms";
24
25export interface KeyProps {
26 /**
27 * Description for the KMS key.
28 */
29 description?: string;
30 /**
31 * Cryptographic operations that the key supports.
32 * @default "ENCRYPT_DECRYPT"
33 */
34 keyUsage?: kms.KeyUsageType;
35 /**
36 * Type of key material for the KMS key.
37 * @default "SYMMETRIC_DEFAULT"
38 */
39 keySpec?: kms.KeySpec;
40 /**
41 * Key policy, either as a structured {@link PolicyDocument} or a raw JSON
42 * string (escape hatch). If omitted, AWS creates and manages the default
43 * key policy.
44 */
45 policy?: PolicyDocument | string;
46 /**
47 * Whether to bypass KMS policy lockout safety checks when creating or updating
48 * the key policy.
49 * @default false
50 */
51 bypassPolicyLockoutSafetyCheck?: boolean;
52 /**
53 * Whether the KMS key is enabled.
54 * @default true
55 */
56 enabled?: boolean;
57 /**
58 * Whether automatic key rotation is enabled.
59 * @default false
60 */
61 enableKeyRotation?: boolean;
62 /**
63 * Rotation period when automatic key rotation is enabled. Accepts any
64 * `Duration.Input` (e.g. `"90 days"`, `Duration.days(90)`; a bare number
65 * is milliseconds); the wire unit is whole days.
66 */
67 rotationPeriod?: Duration.Input;
68 /**
69 * Whether to create a multi-region primary key.
70 * @default false
71 */
72 multiRegion?: boolean;
73 /**
74 * Waiting period before AWS permanently deletes the key after destroy
75 * schedules deletion. Accepts any `Duration.Input` (e.g. `"7 days"`,
76 * `Duration.days(7)`; a bare number is milliseconds); the wire unit is
77 * whole days.
78 * @default 30 days
79 */
80 deletionWindow?: Duration.Input;
81 /**
82 * User-defined tags to apply to the key.
83 */
84 tags?: Record<string, string>;
85}
86
87export interface Key extends Resource<
88 "AWS.KMS.Key",
89 KeyProps,
90 {
91 keyId: KeyId;
92 keyArn: KeyArn;
93 description: string | undefined;
94 keyUsage: kms.KeyUsageType;
95 keySpec: kms.KeySpec;
96 keyState: kms.KeyState | undefined;
97 enabled: boolean;
98 keyRotationEnabled: boolean | undefined;
99 rotationPeriodInDays: number | undefined;
100 multiRegion: boolean;
101 policy: string | undefined;
102 deletionWindowInDays: number;
103 tags: Record<string, string>;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * A customer managed AWS KMS key.
111 *
112 * ### Creating Keys
113 * **Example:** Symmetric Encryption Key
114 * ```typescript
115 * import * as KMS from "alchemy/AWS/KMS";
116 *
117 * const key = yield* KMS.Key("AppKey", {
118 * description: "Application encryption key",
119 * enableKeyRotation: true,
120 * deletionWindow: "7 days",
121 * });
122 * ```
123 *
124 * ### Key Policies
125 * **Example:** Key with Inline Policy
126 * ```typescript
127 * const key = yield* KMS.Key("PolicyKey", {
128 * policy: {
129 * Version: "2012-10-17",
130 * Statement: [{
131 * Effect: "Allow",
132 * Principal: { AWS: "arn:aws:iam::123456789012:root" },
133 * Action: ["kms:*"],
134 * Resource: "*",
135 * }],
136 * },
137 * });
138 * ```
139 *
140 * ### Runtime Operations
141 * Bind the KMS crypto operations to the key inside a Lambda function.
142 * Each binding grants least-privilege IAM (the exact key ARN) and injects
143 * the `KeyId` automatically.
144 *
145 * **Example:** Encrypt and Decrypt from a Lambda Function
146 * ```typescript
147 * // init
148 * const key = yield* AWS.KMS.Key("AppKey");
149 * const encrypt = yield* AWS.KMS.Encrypt(key);
150 * const decrypt = yield* AWS.KMS.Decrypt(key);
151 *
152 * // runtime
153 * const { CiphertextBlob } = yield* encrypt({
154 * Plaintext: new TextEncoder().encode("secret"),
155 * });
156 * const { Plaintext } = yield* decrypt({ CiphertextBlob });
157 * ```
158 *
159 * **Example:** Envelope Encryption with a Data Key
160 * ```typescript
161 * // init
162 * const generateDataKey = yield* AWS.KMS.GenerateDataKey(key);
163 *
164 * // runtime — encrypt locally with the plaintext key, store the blob
165 * const { Plaintext, CiphertextBlob } = yield* generateDataKey({
166 * KeySpec: "AES_256",
167 * });
168 * ```
169 */
170export const Key = Resource<Key>("AWS.KMS.Key");
171
172const defaultKeyUsage = "ENCRYPT_DECRYPT" as const;
173const defaultKeySpec = "SYMMETRIC_DEFAULT" as const;
174const defaultDeletionWindowInDays = 30;
175
176export const KeyProvider = () =>
177 Provider.succeed(Key, {
178 stables: ["keyId", "keyArn"],
179 list: () =>
180 Effect.gen(function* () {
181 const keys = yield* kms.listKeys.pages({}).pipe(
182 Stream.runCollect,
183 Effect.map((chunk) =>
184 Array.from(chunk).flatMap((page) =>
185 (page.Keys ?? [])
186 .map((key) => key.KeyId)
187 .filter((keyId): keyId is string => keyId !== undefined),
188 ),
189 ),
190 );
191
192 const hydrated = yield* Effect.forEach(
193 keys,
194 (keyId) =>
195 readKey({
196 keyId,
197 deletionWindowInDays: defaultDeletionWindowInDays,
198 }),
199 { concurrency: 5 },
200 );
201
202 return hydrated.filter(
203 (key): key is Key["Attributes"] =>
204 key !== undefined && key.keyState !== "PendingDeletion",
205 );
206 }),
207 read: Effect.fn(function* ({ olds = {}, output }) {
208 // KMS keys have no stable user-assignable identity — only a
209 // cloud-generated keyId — so there is nothing to adopt. We only refresh
210 // a key we already own (via output.keyId); with no prior output the
211 // engine treats this as a greenfield create and mints a fresh key.
212 if (!output?.keyId) return undefined;
213 return yield* readKey({
214 keyId: output.keyId,
215 deletionWindowInDays:
216 output.deletionWindowInDays ??
217 toWireDays(olds.deletionWindow) ??
218 defaultDeletionWindowInDays,
219 });
220 }),
221 diff: Effect.fn(function* ({ news = {}, olds = {} }) {
222 if (!isResolved(news)) return;
223 if (
224 (news.keyUsage ?? defaultKeyUsage) !==
225 (olds.keyUsage ?? defaultKeyUsage) ||
226 (news.keySpec ?? defaultKeySpec) !== (olds.keySpec ?? defaultKeySpec) ||
227 (news.multiRegion ?? false) !== (olds.multiRegion ?? false)
228 ) {
229 return { action: "replace" } as const;
230 }
231 }),
232 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
233 const internalTags = yield* createInternalTags(id);
234 const desiredTags = { ...internalTags, ...news.tags };
235 const keyUsage = news.keyUsage ?? defaultKeyUsage;
236 const keySpec = news.keySpec ?? defaultKeySpec;
237 const enabled = news.enabled ?? true;
238 const enableKeyRotation = news.enableKeyRotation ?? false;
239 const multiRegion = news.multiRegion ?? false;
240 const deletionWindowInDays =
241 toWireDays(news.deletionWindow) ?? defaultDeletionWindowInDays;
242 const rotationPeriodInDays = toWireDays(news.rotationPeriod);
243 const desiredPolicy =
244 news.policy === undefined
245 ? undefined
246 : typeof news.policy === "string"
247 ? news.policy
248 : stringifyPolicyDocument(news.policy);
249
250 // Observe via the cached identifier only — no tag-based discovery. On a
251 // create or replacement the engine calls reconcile with
252 // `output === undefined`, so we fall through to `createKey` and mint a
253 // fresh key rather than adopting an unrelated one (which would collapse a
254 // replacement into a no-op).
255 let state = output?.keyId
256 ? yield* readKey({ keyId: output.keyId, deletionWindowInDays })
257 : undefined;
258
259 if (state?.keyState === "PendingDeletion") {
260 yield* kms
261 .cancelKeyDeletion({ KeyId: state.keyId })
262 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
263 state = yield* readKey({ keyId: state.keyId, deletionWindowInDays });
264 }
265
266 if (state === undefined) {
267 const created = yield* kms.createKey({
268 Description: news.description,
269 KeyUsage: keyUsage,
270 KeySpec: keySpec,
271 Policy: desiredPolicy,
272 BypassPolicyLockoutSafetyCheck: news.bypassPolicyLockoutSafetyCheck,
273 Tags: toKmsTags(desiredTags),
274 MultiRegion: multiRegion,
275 });
276 const keyId = created.KeyMetadata?.KeyId;
277 if (!keyId) {
278 return yield* Effect.die(new Error("createKey returned no key ID"));
279 }
280 state = yield* readKey({ keyId, deletionWindowInDays });
281 if (!state) {
282 return yield* Effect.die(
283 new Error(`failed to read created KMS key ${keyId}`),
284 );
285 }
286 }
287
288 const desiredDescription = news.description ?? "";
289 if ((state.description ?? "") !== desiredDescription) {
290 yield* kms
291 .updateKeyDescription({
292 KeyId: state.keyId,
293 Description: desiredDescription,
294 })
295 .pipe(
296 Effect.retry({
297 while: isKmsEventuallyConsistent,
298 schedule: kmsRetrySchedule,
299 }),
300 );
301 }
302
303 if (enabled && !state.enabled) {
304 yield* kms.enableKey({ KeyId: state.keyId }).pipe(
305 Effect.retry({
306 while: isKmsEventuallyConsistent,
307 schedule: kmsRetrySchedule,
308 }),
309 );
310 }
311
312 if (
313 desiredPolicy !== undefined &&
314 !samePolicy(state.policy, desiredPolicy)
315 ) {
316 yield* kms
317 .putKeyPolicy({
318 KeyId: state.keyId,
319 PolicyName: "default",
320 Policy: desiredPolicy,
321 BypassPolicyLockoutSafetyCheck: news.bypassPolicyLockoutSafetyCheck,
322 })
323 .pipe(
324 Effect.retry({
325 while: isKmsEventuallyConsistent,
326 schedule: kmsRetrySchedule,
327 }),
328 );
329 }
330
331 if (
332 enableKeyRotation &&
333 (state.keyRotationEnabled !== true ||
334 (rotationPeriodInDays !== undefined &&
335 state.rotationPeriodInDays !== rotationPeriodInDays))
336 ) {
337 yield* kms
338 .enableKeyRotation({
339 KeyId: state.keyId,
340 RotationPeriodInDays: rotationPeriodInDays,
341 })
342 .pipe(
343 Effect.retry({
344 while: isKmsEventuallyConsistent,
345 schedule: kmsRetrySchedule,
346 }),
347 );
348 } else if (!enableKeyRotation && state.keyRotationEnabled) {
349 yield* kms.disableKeyRotation({ KeyId: state.keyId }).pipe(
350 Effect.retry({
351 while: isKmsEventuallyConsistent,
352 schedule: kmsRetrySchedule,
353 }),
354 );
355 }
356
357 if (!enabled && state.enabled) {
358 yield* kms.disableKey({ KeyId: state.keyId }).pipe(
359 Effect.retry({
360 while: isKmsEventuallyConsistent,
361 schedule: kmsRetrySchedule,
362 }),
363 );
364 }
365
366 const observedTags = yield* listKeyTags(state.keyId);
367 const { removed, upsert } = diffTags(observedTags, desiredTags);
368 if (upsert.length > 0) {
369 yield* kms
370 .tagResource({
371 KeyId: state.keyId,
372 Tags: toKmsTags(
373 Object.fromEntries(upsert.map((tag) => [tag.Key, tag.Value])),
374 ),
375 })
376 .pipe(
377 Effect.retry({
378 while: isKmsEventuallyConsistent,
379 schedule: kmsRetrySchedule,
380 }),
381 );
382 }
383 if (removed.length > 0) {
384 yield* kms
385 .untagResource({
386 KeyId: state.keyId,
387 TagKeys: removed,
388 })
389 .pipe(
390 Effect.retry({
391 while: isKmsEventuallyConsistent,
392 schedule: kmsRetrySchedule,
393 }),
394 );
395 }
396
397 const updated = yield* readConvergedKey({
398 keyId: state.keyId,
399 deletionWindowInDays,
400 desiredDescription,
401 desiredEnabled: enabled,
402 desiredKeyRotationEnabled: enableKeyRotation,
403 desiredPolicy,
404 desiredRotationPeriodInDays: rotationPeriodInDays,
405 desiredTags,
406 });
407
408 yield* session.note(updated.keyArn);
409 return updated;
410 }),
411 delete: Effect.fn(function* ({ output, session }) {
412 yield* kms
413 .scheduleKeyDeletion({
414 KeyId: output.keyId,
415 PendingWindowInDays: output.deletionWindowInDays,
416 })
417 .pipe(
418 Effect.catchTag("NotFoundException", () => Effect.void),
419 Effect.catchTag("KMSInvalidStateException", () => Effect.void),
420 );
421
422 const remaining = yield* Effect.repeat(
423 kms.describeKey({ KeyId: output.keyId }).pipe(
424 Effect.map((response) => response.KeyMetadata?.KeyState),
425 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
426 ),
427 {
428 schedule: Schedule.fixed("250 millis"),
429 until: (state) => state === undefined || state === "PendingDeletion",
430 times: 20,
431 },
432 );
433 if (remaining !== undefined && remaining !== "PendingDeletion") {
434 yield* Effect.die(
435 new Error(
436 `KMS key ${output.keyId} remained ${remaining} after scheduling deletion`,
437 ),
438 );
439 }
440 yield* session.note(`Scheduled KMS key deletion: ${output.keyId}`);
441 }),
442 });
443
444const readKey = Effect.fn(function* ({
445 keyId,
446 deletionWindowInDays,
447}: {
448 keyId: string;
449 deletionWindowInDays: number;
450}) {
451 const described = yield* kms
452 .describeKey({ KeyId: keyId })
453 .pipe(
454 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
455 );
456
457 const metadata = described?.KeyMetadata;
458 if (!metadata?.Arn) return undefined;
459 if (metadata.KeyManager !== undefined && metadata.KeyManager !== "CUSTOMER") {
460 return undefined;
461 }
462
463 const [tags, rotation, policy] = yield* Effect.all(
464 [
465 listKeyTags(metadata.KeyId),
466 readKeyRotation(metadata.KeyId),
467 readKeyPolicy(metadata.KeyId),
468 ],
469 { concurrency: "unbounded" },
470 );
471
472 return toAttrs({
473 metadata,
474 tags,
475 rotation,
476 policy,
477 deletionWindowInDays,
478 });
479});
480
481const readConvergedKey = Effect.fn(function* ({
482 keyId,
483 deletionWindowInDays,
484 desiredDescription,
485 desiredEnabled,
486 desiredKeyRotationEnabled,
487 desiredPolicy,
488 desiredRotationPeriodInDays,
489 desiredTags,
490}: {
491 keyId: string;
492 deletionWindowInDays: number;
493 desiredDescription: string;
494 desiredEnabled: boolean;
495 desiredKeyRotationEnabled: boolean;
496 desiredPolicy: string | undefined;
497 desiredRotationPeriodInDays: number | undefined;
498 desiredTags: Record<string, string>;
499}) {
500 const observeConverged = Effect.gen(function* () {
501 const key = yield* readKey({ keyId, deletionWindowInDays });
502 if (!key) {
503 return yield* Effect.fail(new KmsKeyNotConverged());
504 }
505 if ((key.description ?? "") !== desiredDescription) {
506 return yield* Effect.fail(new KmsKeyNotConverged());
507 }
508 if (key.enabled !== desiredEnabled) {
509 return yield* Effect.fail(new KmsKeyNotConverged());
510 }
511 if (
512 !Object.entries(desiredTags).every(
513 ([name, value]) => key.tags[name] === value,
514 ) ||
515 !Object.keys(key.tags).every(
516 (name) => desiredTags[name] === key.tags[name],
517 )
518 ) {
519 return yield* Effect.fail(new KmsKeyNotConverged());
520 }
521 if (desiredPolicy !== undefined && !samePolicy(key.policy, desiredPolicy)) {
522 return yield* Effect.fail(new KmsKeyNotConverged());
523 }
524 if (desiredEnabled) {
525 if (desiredKeyRotationEnabled && key.keyRotationEnabled !== true) {
526 return yield* Effect.fail(new KmsKeyNotConverged());
527 }
528 if (!desiredKeyRotationEnabled && key.keyRotationEnabled === true) {
529 return yield* Effect.fail(new KmsKeyNotConverged());
530 }
531 if (
532 desiredKeyRotationEnabled &&
533 desiredRotationPeriodInDays !== undefined &&
534 key.rotationPeriodInDays !== desiredRotationPeriodInDays
535 ) {
536 return yield* Effect.fail(new KmsKeyNotConverged());
537 }
538 }
539 return key;
540 });
541
542 return yield* Effect.gen(function* () {
543 // KMS reads can briefly move backwards after a successful mutation (for
544 // example, GetKeyPolicy may serve the new policy once and then an older
545 // replica's default policy). A single matching read is therefore not a
546 // sufficient convergence signal under high concurrency. Require two
547 // consecutive observations before returning the resource to the engine.
548 yield* observeConverged;
549 yield* Effect.sleep("500 millis");
550 return yield* observeConverged;
551 }).pipe(
552 Effect.retry({
553 while: (error: { _tag: string }) =>
554 error._tag === "KmsKeyNotConverged" || isKmsEventuallyConsistent(error),
555 schedule: kmsRetrySchedule,
556 }),
557 );
558});
559
560const listKeyTags = Effect.fn(function* (keyId: string) {
561 const pages = yield* kms.listResourceTags.pages({ KeyId: keyId }).pipe(
562 Stream.runCollect,
563 Effect.catchTag("NotFoundException", () => Effect.succeed([])),
564 );
565
566 return toTagRecord(Array.from(pages).flatMap((page) => page.Tags ?? []));
567});
568
569const readKeyRotation = Effect.fn(function* (keyId: string) {
570 return yield* kms.getKeyRotationStatus({ KeyId: keyId }).pipe(
571 Effect.map((response) => ({
572 enabled: response.KeyRotationEnabled,
573 periodInDays: response.RotationPeriodInDays,
574 })),
575 Effect.catchTag("UnsupportedOperationException", () =>
576 Effect.succeed(undefined),
577 ),
578 Effect.catchTag("KMSInvalidStateException", () =>
579 Effect.succeed(undefined),
580 ),
581 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
582 );
583});
584
585const readKeyPolicy = Effect.fn(function* (keyId: string) {
586 return yield* kms.getKeyPolicy({ KeyId: keyId, PolicyName: "default" }).pipe(
587 Effect.map((response) => response.Policy),
588 Effect.catchTag("KMSInvalidStateException", () =>
589 Effect.succeed(undefined),
590 ),
591 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
592 );
593});
594
595const toAttrs = ({
596 metadata,
597 tags,
598 rotation,
599 policy,
600 deletionWindowInDays,
601}: {
602 metadata: kms.KeyMetadata;
603 tags: Record<string, string>;
604 rotation: { enabled?: boolean; periodInDays?: number } | undefined;
605 policy: string | undefined;
606 deletionWindowInDays: number;
607}): Key["Attributes"] => ({
608 keyId: metadata.KeyId,
609 keyArn: metadata.Arn as KeyArn,
610 description: metadata.Description,
611 keyUsage: metadata.KeyUsage ?? defaultKeyUsage,
612 keySpec: metadata.KeySpec ?? defaultKeySpec,
613 keyState: metadata.KeyState,
614 enabled: metadata.Enabled ?? false,
615 keyRotationEnabled: rotation?.enabled,
616 rotationPeriodInDays: rotation?.periodInDays,
617 multiRegion: metadata.MultiRegion ?? false,
618 policy,
619 deletionWindowInDays,
620 tags,
621});
622
623const toTagRecord = (tags: kms.Tag[] | undefined): Record<string, string> =>
624 Object.fromEntries(
625 (tags ?? [])
626 .filter(
627 (tag): tag is kms.Tag =>
628 typeof tag.TagKey === "string" && typeof tag.TagValue === "string",
629 )
630 .map((tag) => [tag.TagKey, tag.TagValue]),
631 );
632
633const toKmsTags = (tags: Record<string, string>): kms.Tag[] =>
634 Object.entries(tags).map(([TagKey, TagValue]) => ({ TagKey, TagValue }));
635
636/**
637 * Compare the observed key policy against the desired one using the shared
638 * IAM canonicalizer so a re-deploy of an equivalent document (different key
639 * order, whitespace, or PolicyDocument-vs-string form) is a no-op.
640 */
641const samePolicy = (
642 observed: string | undefined,
643 desired: string | undefined,
644) => {
645 if (observed === desired) return true;
646 if (observed === undefined || desired === undefined) return false;
647 return normalizePolicyDocument(observed) === normalizePolicyDocument(desired);
648};
649
650const isKmsEventuallyConsistent = (error: { _tag: string }) =>
651 error._tag === "DependencyTimeoutException" ||
652 error._tag === "KMSInternalException" ||
653 error._tag === "KMSInvalidStateException";
654
655class KmsKeyNotConverged extends Error {
656 readonly _tag = "KmsKeyNotConverged";
657}
658
659const kmsRetrySchedule = Schedule.max([
660 Schedule.exponential(250),
661 Schedule.recurs(7),
662]);