1import * as paymentcryptography from "@distilled.cloud/aws/payment-cryptography";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import { toWireDays } from "../../Util/Duration.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface KeyModesOfUse {
14 /**
15 * Whether the key can be used to encrypt data.
16 * @default false
17 */
18 encrypt?: boolean;
19 /**
20 * Whether the key can be used to decrypt data.
21 * @default false
22 */
23 decrypt?: boolean;
24 /**
25 * Whether the key can be used to wrap other keys.
26 * @default false
27 */
28 wrap?: boolean;
29 /**
30 * Whether the key can be used to unwrap other keys.
31 * @default false
32 */
33 unwrap?: boolean;
34 /**
35 * Whether the key can be used to generate cryptograms and MACs.
36 * @default false
37 */
38 generate?: boolean;
39 /**
40 * Whether the key can be used for signing.
41 * @default false
42 */
43 sign?: boolean;
44 /**
45 * Whether the key can be used to verify signatures, cryptograms and MACs.
46 * @default false
47 */
48 verify?: boolean;
49 /**
50 * Whether the key can be used to derive new keys.
51 * @default false
52 */
53 deriveKey?: boolean;
54 /**
55 * Whether the key usage is unrestricted.
56 * @default false
57 */
58 noRestrictions?: boolean;
59}
60
61export interface KeyAttributes {
62 /**
63 * The key algorithm to be used, e.g. `TDES_2KEY`, `AES_128`, `AES_256`,
64 * `RSA_2048`, `ECC_NIST_P256`. Immutable — changing it replaces the key.
65 */
66 keyAlgorithm: string;
67 /**
68 * The type of key, e.g. `SYMMETRIC_KEY`, `ASYMMETRIC_KEY_PAIR`,
69 * `PRIVATE_KEY`, `PUBLIC_KEY`. Immutable — changing it replaces the key.
70 */
71 keyClass: string;
72 /**
73 * The TR-31 cryptographic usage of the key, e.g.
74 * `TR31_D0_SYMMETRIC_DATA_ENCRYPTION_KEY`, `TR31_M7_HMAC_KEY`,
75 * `TR31_P0_PIN_ENCRYPTION_KEY`. Immutable — changing it replaces the key.
76 */
77 keyUsage: string;
78 /**
79 * The cryptographic operations the key may perform. Immutable — changing
80 * any mode replaces the key.
81 */
82 keyModesOfUse: KeyModesOfUse;
83}
84
85export interface KeyProps {
86 /**
87 * The immutable cryptographic attributes of the key: algorithm, class,
88 * usage and modes of use. Changing any of these replaces the key.
89 */
90 keyAttributes: KeyAttributes;
91 /**
92 * Whether the key can be exported out of the service (e.g. via TR-31/TR-34
93 * key blocks). Immutable — changing it replaces the key.
94 * @default false
95 */
96 exportable?: boolean;
97 /**
98 * Whether the key is enabled for cryptographic operations. Toggling this
99 * calls StartKeyUsage/StopKeyUsage in place.
100 * @default true
101 */
102 enabled?: boolean;
103 /**
104 * The algorithm used to compute the key check value, `CMAC` or
105 * `ANSI_X9_24`. Immutable — changing it replaces the key.
106 * @default ANSI_X9_24 for TDES keys, CMAC otherwise
107 */
108 keyCheckValueAlgorithm?: string;
109 /**
110 * For ECC key pairs used in ECDH key agreement, the usage bound to the
111 * derived symmetric key. Immutable — changing it replaces the key.
112 */
113 deriveKeyUsage?: string;
114 /**
115 * The waiting period before a deleted key is permanently removed, e.g.
116 * `"7 days"` or `Duration.days(7)` (`3` - `180` days; sent to the API as
117 * whole days — a bare number is milliseconds). During the window the key
118 * is `DELETE_PENDING` and can be restored.
119 * @default "3 days"
120 */
121 deleteWindow?: Duration.Input;
122 /**
123 * Tags to apply to the key. Merged with internal Alchemy tags.
124 */
125 tags?: Record<string, string>;
126}
127
128export interface Key extends Resource<
129 "AWS.PaymentCryptography.Key",
130 KeyProps,
131 {
132 /**
133 * ARN of the key.
134 */
135 keyArn: string;
136 /**
137 * Key state (e.g. `CREATE_COMPLETE`, `DELETE_PENDING`).
138 */
139 keyState: string;
140 /**
141 * Key check value (KCV) used to verify the key material.
142 */
143 keyCheckValue: string;
144 /**
145 * Whether the key is enabled for cryptographic operations.
146 */
147 enabled: boolean;
148 /**
149 * Whether the key material can be exported.
150 */
151 exportable: boolean;
152 },
153 never,
154 Providers
155> {}
156
157/**
158 * An AWS Payment Cryptography key — a managed cryptographic key with TR-31
159 * attributes (algorithm, class, usage, modes of use) used for data
160 * encryption, MAC generation/verification, and other payment-domain
161 * cryptographic operations.
162 *
163 * The key ARN is auto-assigned by the service; attach an {@link Alias} for a
164 * stable human-readable identifier. Deletion schedules the key for removal
165 * after a waiting window (minimum 3 days) during which it can be restored.
166 * ### Creating Keys
167 * **Example:** Symmetric data-encryption key
168 * ```typescript
169 * import * as PaymentCryptography from "alchemy/AWS/PaymentCryptography";
170 *
171 * const key = yield* PaymentCryptography.Key("DataKey", {
172 * keyAttributes: {
173 * keyAlgorithm: "AES_128",
174 * keyClass: "SYMMETRIC_KEY",
175 * keyUsage: "TR31_D0_SYMMETRIC_DATA_ENCRYPTION_KEY",
176 * keyModesOfUse: { encrypt: true, decrypt: true, wrap: true, unwrap: true },
177 * },
178 * });
179 * ```
180 *
181 * **Example:** HMAC key for MAC generation and verification
182 * ```typescript
183 * const macKey = yield* PaymentCryptography.Key("MacKey", {
184 * keyAttributes: {
185 * keyAlgorithm: "HMAC_SHA256",
186 * keyClass: "SYMMETRIC_KEY",
187 * keyUsage: "TR31_M7_HMAC_KEY",
188 * keyModesOfUse: { generate: true, verify: true },
189 * },
190 * });
191 * ```
192 *
193 * ### Managing Key State
194 * **Example:** Disable a key without deleting it
195 * ```typescript
196 * const key = yield* PaymentCryptography.Key("DataKey", {
197 * keyAttributes: { ... },
198 * enabled: false,
199 * });
200 * ```
201 *
202 * ### Using Keys at Runtime
203 * **Example:** Encrypt data from a Lambda handler
204 * ```typescript
205 * // init
206 * const encrypt = yield* PaymentCryptography.EncryptData(key);
207 *
208 * return {
209 * fetch: Effect.gen(function* () {
210 * // runtime — PlainText is hex-encoded
211 * const result = yield* encrypt({
212 * PlainText: "31323334353637383930313233343536",
213 * EncryptionAttributes: { Symmetric: { Mode: "CBC" } },
214 * });
215 * return HttpServerResponse.json({ cipherText: result.CipherText });
216 * }),
217 * };
218 * ```
219 *
220 * @resource
221 */
222export const Key = Resource<Key>("AWS.PaymentCryptography.Key");
223
224const toWireKeyAttributes = (
225 attributes: KeyAttributes,
226): paymentcryptography.KeyAttributes => ({
227 KeyAlgorithm: attributes.keyAlgorithm,
228 KeyClass: attributes.keyClass,
229 KeyUsage: attributes.keyUsage,
230 KeyModesOfUse: {
231 Encrypt: attributes.keyModesOfUse.encrypt ?? false,
232 Decrypt: attributes.keyModesOfUse.decrypt ?? false,
233 Wrap: attributes.keyModesOfUse.wrap ?? false,
234 Unwrap: attributes.keyModesOfUse.unwrap ?? false,
235 Generate: attributes.keyModesOfUse.generate ?? false,
236 Sign: attributes.keyModesOfUse.sign ?? false,
237 Verify: attributes.keyModesOfUse.verify ?? false,
238 DeriveKey: attributes.keyModesOfUse.deriveKey ?? false,
239 NoRestrictions: attributes.keyModesOfUse.noRestrictions ?? false,
240 },
241});
242
243const toAttrs = (key: paymentcryptography.Key): Key["Attributes"] => ({
244 keyArn: key.KeyArn,
245 keyState: key.KeyState,
246 keyCheckValue: key.KeyCheckValue,
247 enabled: key.Enabled,
248 exportable: key.Exportable,
249});
250
251const tagsToRecord = (
252 tags: readonly paymentcryptography.Tag[],
253): Record<string, string> =>
254 Object.fromEntries(tags.map((t) => [t.Key, t.Value ?? ""]));
255
256const fetchKeyTags = (keyArn: string) =>
257 paymentcryptography.listTagsForResource.items({ ResourceArn: keyArn }).pipe(
258 Stream.runCollect,
259 Effect.map((chunk) => tagsToRecord(Array.from(chunk))),
260 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
261 );
262
263export const KeyProvider = () =>
264 Provider.effect(
265 Key,
266 Effect.gen(function* () {
267 // Fetch a key by ARN; a missing or fully-deleted key observes as
268 // `undefined`. A key inside its deletion window (DELETE_PENDING) is
269 // still returned so reconcile can restore it.
270 const observeKey = Effect.fn(function* (keyArn: string) {
271 const found = yield* paymentcryptography
272 .getKey({ KeyIdentifier: keyArn })
273 .pipe(
274 Effect.map((r) => r.Key),
275 Effect.catchTag("ResourceNotFoundException", () =>
276 Effect.succeed(undefined),
277 ),
278 );
279 if (found === undefined || found.KeyState === "DELETE_COMPLETE") {
280 return undefined;
281 }
282 return found;
283 });
284
285 // Keys have service-assigned identity (no name), so locating one
286 // without a cached ARN falls back to scanning for the internal
287 // Alchemy tags.
288 const findKeyByTags = Effect.fn(function* (id: string) {
289 const summaries = yield* paymentcryptography.listKeys.items({}).pipe(
290 Stream.runCollect,
291 Effect.map((chunk) =>
292 Array.from(chunk).filter((k) => k.KeyState !== "DELETE_COMPLETE"),
293 ),
294 );
295 for (const summary of summaries) {
296 const tags = yield* fetchKeyTags(summary.KeyArn);
297 if (yield* hasAlchemyTags(id, tags)) {
298 return summary.KeyArn;
299 }
300 }
301 return undefined;
302 });
303
304 return Key.Provider.of({
305 stables: ["keyArn", "keyCheckValue"],
306 list: () =>
307 paymentcryptography.listKeys.items({}).pipe(
308 Stream.runCollect,
309 Effect.map((chunk) =>
310 Array.from(chunk)
311 // Keys already inside their deletion window are as deleted
312 // as the API allows — a second DeleteKey is a no-op, so they
313 // are not orphans (mirrors KMS PendingDeletion filtering).
314 .filter(
315 (k) =>
316 k.KeyState !== "DELETE_PENDING" &&
317 k.KeyState !== "DELETE_COMPLETE",
318 )
319 .map((k): Key["Attributes"] => ({
320 keyArn: k.KeyArn,
321 keyState: k.KeyState,
322 keyCheckValue: k.KeyCheckValue,
323 enabled: k.Enabled,
324 exportable: k.Exportable,
325 })),
326 ),
327 ),
328 read: Effect.fn(function* ({ id, output }) {
329 const keyArn = output?.keyArn ?? (yield* findKeyByTags(id));
330 if (keyArn === undefined) return undefined;
331 const key = yield* observeKey(keyArn);
332 if (key === undefined) return undefined;
333 const attrs = toAttrs(key);
334 const tags = yield* fetchKeyTags(key.KeyArn);
335 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
336 }),
337 diff: Effect.fn(function* ({ news, olds }) {
338 if (!isResolved(news)) return undefined;
339 const wireOld = toWireKeyAttributes(olds.keyAttributes);
340 const wireNew = toWireKeyAttributes(news.keyAttributes);
341 if (
342 JSON.stringify(wireOld) !== JSON.stringify(wireNew) ||
343 (olds.exportable ?? false) !== (news.exportable ?? false) ||
344 olds.keyCheckValueAlgorithm !== news.keyCheckValueAlgorithm ||
345 olds.deriveKeyUsage !== news.deriveKeyUsage
346 ) {
347 return { action: "replace" } as const;
348 }
349 // fall through: undefined → default update (enabled / tags sync)
350 }),
351 reconcile: Effect.fn(function* ({ id, news, output, session }) {
352 const desiredEnabled = news.enabled ?? true;
353 const internalTags = yield* createInternalTags(id);
354
355 // 1. Observe — output.keyArn is only an identity cache; the cloud
356 // is authoritative. Fall back to a tag scan for adoption after
357 // state-persistence failures.
358 const cachedArn = output?.keyArn ?? (yield* findKeyByTags(id));
359 let key =
360 cachedArn === undefined ? undefined : yield* observeKey(cachedArn);
361
362 // A key inside its deletion window is restored rather than
363 // recreated — the material (and dependent ciphertext) survives.
364 if (key !== undefined && key.KeyState === "DELETE_PENDING") {
365 key = yield* paymentcryptography
366 .restoreKey({ KeyIdentifier: key.KeyArn })
367 .pipe(Effect.map((r) => r.Key));
368 }
369
370 // 2. Ensure — create when missing.
371 if (key === undefined) {
372 key = yield* paymentcryptography
373 .createKey({
374 KeyAttributes: toWireKeyAttributes(news.keyAttributes),
375 Exportable: news.exportable ?? false,
376 Enabled: desiredEnabled,
377 KeyCheckValueAlgorithm: news.keyCheckValueAlgorithm,
378 DeriveKeyUsage: news.deriveKeyUsage,
379 Tags: Object.entries({
380 ...news.tags,
381 ...internalTags,
382 }).map(([Key, Value]) => ({ Key, Value })),
383 })
384 .pipe(Effect.map((r) => r.Key));
385 }
386
387 // 3a. Sync enabled state against OBSERVED cloud state.
388 if (key.Enabled !== desiredEnabled) {
389 key = desiredEnabled
390 ? yield* paymentcryptography
391 .startKeyUsage({ KeyIdentifier: key.KeyArn })
392 .pipe(Effect.map((r) => r.Key))
393 : yield* paymentcryptography
394 .stopKeyUsage({ KeyIdentifier: key.KeyArn })
395 .pipe(Effect.map((r) => r.Key));
396 }
397
398 // 3b. Sync tags — diff against observed cloud tags so adoption
399 // (foreign tags) converges.
400 const observedTags = yield* fetchKeyTags(key.KeyArn);
401 const desiredTags: Record<string, string> = {
402 ...news.tags,
403 ...internalTags,
404 };
405 const { upsert, removed } = diffTags(observedTags, desiredTags);
406 if (upsert.length > 0) {
407 yield* paymentcryptography.tagResource({
408 ResourceArn: key.KeyArn,
409 Tags: upsert,
410 });
411 }
412 if (removed.length > 0) {
413 yield* paymentcryptography.untagResource({
414 ResourceArn: key.KeyArn,
415 TagKeys: removed,
416 });
417 }
418
419 yield* session.note(key.KeyArn);
420 return toAttrs(key);
421 }),
422 delete: Effect.fn(function* ({ output, olds }) {
423 yield* paymentcryptography
424 .deleteKey({
425 KeyIdentifier: output.keyArn,
426 DeleteKeyInDays: toWireDays(olds.deleteWindow) ?? 3,
427 })
428 .pipe(
429 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
430 // A key already scheduled for deletion rejects a second
431 // DeleteKey with a conflict — that is the desired end state,
432 // so treat it as success. Any other conflict is re-raised.
433 Effect.catchTag("ConflictException", (error) =>
434 paymentcryptography
435 .getKey({ KeyIdentifier: output.keyArn })
436 .pipe(
437 Effect.flatMap((r) =>
438 r.Key.KeyState === "DELETE_PENDING" ||
439 r.Key.KeyState === "DELETE_COMPLETE"
440 ? Effect.void
441 : Effect.fail(error),
442 ),
443 Effect.catchTag(
444 "ResourceNotFoundException",
445 () => Effect.void,
446 ),
447 ),
448 ),
449 );
450 }),
451 });
452 }),
453 );