| 1 | import * as paymentcryptography from "@distilled.cloud/aws/payment-cryptography"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 10 | import { toWireDays } from "../../Util/Duration.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | |
| 13 | export 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 | |
| 61 | export 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 | |
| 85 | export 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 | |
| 128 | export 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 | */ |
| 222 | export const Key = Resource<Key>("AWS.PaymentCryptography.Key"); |
| 223 | |
| 224 | const 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 | |
| 243 | const 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 | |
| 251 | const tagsToRecord = ( |
| 252 | tags: readonly paymentcryptography.Tag[], |
| 253 | ): Record<string, string> => |
| 254 | Object.fromEntries(tags.map((t) => [t.Key, t.Value ?? ""])); |
| 255 | |
| 256 | const 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 | |
| 263 | export 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 | ); |