| 1 | import * as signer from "@distilled.cloud/aws/signer"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | /** |
| 13 | * The signing certificate used to sign code. Required for platforms that |
| 14 | * sign with an ACM certificate (e.g. `AWSIoTDeviceManagement-SHA256-ECDSA`); |
| 15 | * the `AWSLambda-*` platforms use AWS-managed signing material and do not |
| 16 | * accept one. |
| 17 | */ |
| 18 | export interface SigningMaterial { |
| 19 | /** |
| 20 | * The ARN of an AWS Certificate Manager (ACM) certificate used to sign |
| 21 | * code. |
| 22 | */ |
| 23 | certificateArn: string; |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * How long a signature produced by this profile remains valid. |
| 28 | */ |
| 29 | export interface SignatureValidityPeriod { |
| 30 | /** |
| 31 | * The numerical value of the validity period. |
| 32 | * @default 135 |
| 33 | */ |
| 34 | value?: number; |
| 35 | |
| 36 | /** |
| 37 | * The unit of the validity period. |
| 38 | * @default "MONTHS" |
| 39 | */ |
| 40 | type?: "DAYS" | "MONTHS" | "YEARS"; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Overrides applied to the signing configuration of the chosen platform. |
| 45 | */ |
| 46 | export interface SigningPlatformOverrides { |
| 47 | /** |
| 48 | * Overrides for the platform's default encryption/hash algorithms. |
| 49 | */ |
| 50 | signingConfiguration?: { |
| 51 | /** The encryption algorithm used to sign code (`RSA` or `ECDSA`). */ |
| 52 | encryptionAlgorithm?: "RSA" | "ECDSA"; |
| 53 | /** The hash algorithm used to sign code (`SHA1` or `SHA256`). */ |
| 54 | hashAlgorithm?: "SHA1" | "SHA256"; |
| 55 | }; |
| 56 | |
| 57 | /** |
| 58 | * The signed image format for the platform, e.g. `JSONEmbedded`. |
| 59 | */ |
| 60 | signingImageFormat?: string; |
| 61 | } |
| 62 | |
| 63 | export interface SigningProfileProps { |
| 64 | /** |
| 65 | * Name of the signing profile. Must match `^[a-zA-Z0-9_]{2,64}$` (letters, |
| 66 | * digits, and underscores only — no hyphens). If omitted, a unique |
| 67 | * underscore-delimited name is generated. Changing it replaces the profile. |
| 68 | */ |
| 69 | profileName?: string; |
| 70 | |
| 71 | /** |
| 72 | * The ID of the signing platform, e.g. `AWSLambda-SHA384-ECDSA` for Lambda |
| 73 | * code signing. Changing it replaces the profile. |
| 74 | */ |
| 75 | platformId: string; |
| 76 | |
| 77 | /** |
| 78 | * The ACM certificate used to sign code. Only for platforms that sign with |
| 79 | * a customer-provided certificate; omit for the `AWSLambda-*` platforms. |
| 80 | * Changing it replaces the profile. |
| 81 | */ |
| 82 | signingMaterial?: SigningMaterial; |
| 83 | |
| 84 | /** |
| 85 | * How long signatures produced by this profile stay valid. |
| 86 | * Changing it replaces the profile. |
| 87 | * @default 135 MONTHS |
| 88 | */ |
| 89 | signatureValidityPeriod?: SignatureValidityPeriod; |
| 90 | |
| 91 | /** |
| 92 | * Overrides of the platform's default signing configuration. |
| 93 | * Changing them replaces the profile. |
| 94 | */ |
| 95 | overrides?: SigningPlatformOverrides; |
| 96 | |
| 97 | /** |
| 98 | * Map of key-value pairs for signing, attached as-is to every signing job |
| 99 | * started with this profile. Changing them replaces the profile. Rejected |
| 100 | * by the `AWSLambda-*` platforms ("Signing parameters should not be |
| 101 | * present when using AWSLambda-SHA384-ECDSA platform"). |
| 102 | */ |
| 103 | signingParameters?: Record<string, string>; |
| 104 | |
| 105 | /** |
| 106 | * User tags to attach to the signing profile. Tags are the only mutable |
| 107 | * aspect of a signing profile. |
| 108 | */ |
| 109 | tags?: Record<string, string>; |
| 110 | } |
| 111 | |
| 112 | export interface SigningProfile extends Resource< |
| 113 | "AWS.Signer.SigningProfile", |
| 114 | SigningProfileProps, |
| 115 | { |
| 116 | /** The name of the signing profile. */ |
| 117 | profileName: string; |
| 118 | /** The ARN of the signing profile (unversioned). */ |
| 119 | arn: string; |
| 120 | /** The current version of the signing profile. */ |
| 121 | profileVersion: string; |
| 122 | /** The ARN of the current signing profile version. */ |
| 123 | profileVersionArn: string; |
| 124 | /** The ID of the signing platform. */ |
| 125 | platformId: string; |
| 126 | /** The status of the signing profile (`Active`, `Canceled`, `Revoked`). */ |
| 127 | status: string; |
| 128 | }, |
| 129 | never, |
| 130 | Providers |
| 131 | > {} |
| 132 | |
| 133 | /** |
| 134 | * An AWS Signer signing profile — a code-signing template (platform + |
| 135 | * signing material + signature validity) used to sign code, most commonly |
| 136 | * as the trust anchor for Lambda code signing configs. |
| 137 | * |
| 138 | * `PutSigningProfile` is create-only in practice (re-putting an existing |
| 139 | * name fails with "Profile with name X already exists"), so every property |
| 140 | * except tags is immutable — changing one replaces the profile under a new |
| 141 | * generated name. On destroy the profile is canceled — AWS retains canceled |
| 142 | * profiles (the name stays reserved) and deletes them per its data-retention |
| 143 | * policy, so prefer generated names over fixed `profileName`s. |
| 144 | * |
| 145 | * ### Creating a Signing Profile |
| 146 | * **Example:** Lambda Code-Signing Profile |
| 147 | * ```typescript |
| 148 | * const profile = yield* Signer.SigningProfile("release-profile", { |
| 149 | * platformId: "AWSLambda-SHA384-ECDSA", |
| 150 | * }); |
| 151 | * ``` |
| 152 | * |
| 153 | * **Example:** Profile with Signature Validity Period |
| 154 | * ```typescript |
| 155 | * const profile = yield* Signer.SigningProfile("release-profile", { |
| 156 | * platformId: "AWSLambda-SHA384-ECDSA", |
| 157 | * signatureValidityPeriod: { value: 12, type: "MONTHS" }, |
| 158 | * tags: { team: "platform" }, |
| 159 | * }); |
| 160 | * ``` |
| 161 | * |
| 162 | * @resource |
| 163 | */ |
| 164 | export const SigningProfile = Resource<SigningProfile>( |
| 165 | "AWS.Signer.SigningProfile", |
| 166 | ); |
| 167 | |
| 168 | /** |
| 169 | * Signer profile names accept only `[a-zA-Z0-9_]` — coerce any other |
| 170 | * character (hyphens from stack/stage names, etc.) to an underscore. |
| 171 | */ |
| 172 | const sanitizeProfileName = (name: string): string => |
| 173 | name.replace(/[^a-zA-Z0-9_]/g, "_"); |
| 174 | |
| 175 | /** Coerce a distilled TagMap (values possibly undefined) to a plain record. */ |
| 176 | const toTagRecord = ( |
| 177 | tags: { [key: string]: string | undefined } | undefined, |
| 178 | ): Record<string, string> => { |
| 179 | const record: Record<string, string> = {}; |
| 180 | for (const [key, value] of Object.entries(tags ?? {})) { |
| 181 | if (value !== undefined) record[key] = value; |
| 182 | } |
| 183 | return record; |
| 184 | }; |
| 185 | |
| 186 | export const SigningProfileProvider = () => |
| 187 | Provider.effect( |
| 188 | SigningProfile, |
| 189 | Effect.gen(function* () { |
| 190 | const createName = Effect.fn(function* ( |
| 191 | id: string, |
| 192 | props: Pick<SigningProfileProps, "profileName">, |
| 193 | ) { |
| 194 | return ( |
| 195 | props.profileName ?? |
| 196 | sanitizeProfileName( |
| 197 | yield* createPhysicalName({ id, delimiter: "_", maxLength: 64 }), |
| 198 | ) |
| 199 | ); |
| 200 | }); |
| 201 | |
| 202 | const getProfile = (profileName: string) => |
| 203 | signer |
| 204 | .getSigningProfile({ profileName }) |
| 205 | .pipe( |
| 206 | Effect.catchTag("ResourceNotFoundException", () => |
| 207 | Effect.succeed(undefined), |
| 208 | ), |
| 209 | ); |
| 210 | |
| 211 | const toAttributes = ( |
| 212 | profileName: string, |
| 213 | live: signer.GetSigningProfileResponse, |
| 214 | ) => ({ |
| 215 | profileName, |
| 216 | arn: live.arn!, |
| 217 | profileVersion: live.profileVersion!, |
| 218 | profileVersionArn: live.profileVersionArn!, |
| 219 | platformId: live.platformId!, |
| 220 | status: (live.status ?? "Active") as string, |
| 221 | }); |
| 222 | |
| 223 | return SigningProfile.Provider.of({ |
| 224 | // Updates only sync tags (all other props replace), so every |
| 225 | // identifying attribute — including the version — is update-stable. |
| 226 | stables: [ |
| 227 | "profileName", |
| 228 | "arn", |
| 229 | "profileVersion", |
| 230 | "profileVersionArn", |
| 231 | "platformId", |
| 232 | ], |
| 233 | |
| 234 | list: () => |
| 235 | signer.listSigningProfiles.pages({ includeCanceled: false }).pipe( |
| 236 | Stream.runCollect, |
| 237 | Effect.map((chunk) => |
| 238 | Array.from(chunk).flatMap((page) => |
| 239 | (page.profiles ?? []) |
| 240 | .filter( |
| 241 | (p) => |
| 242 | p.profileName != null && |
| 243 | p.arn != null && |
| 244 | p.profileVersion != null && |
| 245 | p.profileVersionArn != null && |
| 246 | p.platformId != null, |
| 247 | ) |
| 248 | .map((p) => ({ |
| 249 | profileName: p.profileName!, |
| 250 | arn: p.arn!, |
| 251 | profileVersion: p.profileVersion!, |
| 252 | profileVersionArn: p.profileVersionArn!, |
| 253 | platformId: p.platformId!, |
| 254 | status: (p.status ?? "Active") as string, |
| 255 | })), |
| 256 | ), |
| 257 | ), |
| 258 | ), |
| 259 | |
| 260 | read: Effect.fn(function* ({ id, olds, output }) { |
| 261 | const profileName = |
| 262 | output?.profileName ?? (yield* createName(id, olds ?? {})); |
| 263 | const live = yield* getProfile(profileName); |
| 264 | // Canceled/revoked profiles cannot sign — treat them as deleted so |
| 265 | // a re-deploy publishes a fresh active version. |
| 266 | if (live === undefined || live.status !== "Active") return undefined; |
| 267 | const attrs = toAttributes(profileName, live); |
| 268 | const tags = toTagRecord(live.tags); |
| 269 | return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs); |
| 270 | }), |
| 271 | |
| 272 | diff: Effect.fn(function* ({ id, news, olds }) { |
| 273 | if (!isResolved(news)) return undefined; |
| 274 | const oldName = yield* createName(id, olds); |
| 275 | const newName = yield* createName(id, news); |
| 276 | if (oldName !== newName) return { action: "replace" } as const; |
| 277 | // PutSigningProfile is create-only (re-putting an existing name |
| 278 | // fails with "already exists"), so every property except tags is |
| 279 | // immutable — any change replaces the profile. Mirrors |
| 280 | // CloudFormation, where all AWS::Signer::SigningProfile properties |
| 281 | // trigger replacement. |
| 282 | if ( |
| 283 | olds.platformId !== news.platformId || |
| 284 | JSON.stringify(olds.signatureValidityPeriod ?? null) !== |
| 285 | JSON.stringify(news.signatureValidityPeriod ?? null) || |
| 286 | JSON.stringify(olds.signingParameters ?? null) !== |
| 287 | JSON.stringify(news.signingParameters ?? null) || |
| 288 | JSON.stringify(olds.signingMaterial ?? null) !== |
| 289 | JSON.stringify(news.signingMaterial ?? null) || |
| 290 | JSON.stringify(olds.overrides ?? null) !== |
| 291 | JSON.stringify(news.overrides ?? null) |
| 292 | ) { |
| 293 | return { action: "replace" } as const; |
| 294 | } |
| 295 | return undefined; |
| 296 | }), |
| 297 | |
| 298 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 299 | const profileName = |
| 300 | output?.profileName ?? (yield* createName(id, news)); |
| 301 | const internalTags = yield* createInternalTags(id); |
| 302 | const desiredTags = { ...news.tags, ...internalTags }; |
| 303 | |
| 304 | // OBSERVE — cloud state is authoritative; output is only a cache |
| 305 | let live = yield* getProfile(profileName); |
| 306 | |
| 307 | // ENSURE — PutSigningProfile is create-only in practice: re-putting |
| 308 | // an existing name (even Active with identical config) fails with |
| 309 | // "Profile with name X already exists". Every property except tags |
| 310 | // is therefore immutable and handled by diff → replace; here we |
| 311 | // only create when the profile is missing. |
| 312 | if (live === undefined) { |
| 313 | yield* signer |
| 314 | .putSigningProfile({ |
| 315 | profileName, |
| 316 | platformId: news.platformId, |
| 317 | signingMaterial: news.signingMaterial, |
| 318 | signatureValidityPeriod: news.signatureValidityPeriod, |
| 319 | overrides: news.overrides, |
| 320 | signingParameters: news.signingParameters, |
| 321 | tags: desiredTags, |
| 322 | }) |
| 323 | .pipe( |
| 324 | // Race with a concurrent create — fall through to re-observe. |
| 325 | Effect.catchTag( |
| 326 | "SigningProfileAlreadyExists", |
| 327 | () => Effect.void, |
| 328 | ), |
| 329 | ); |
| 330 | live = yield* getProfile(profileName); |
| 331 | } |
| 332 | if (live === undefined) { |
| 333 | // Extremely unlikely read-after-write miss; surface loudly. |
| 334 | return yield* Effect.fail( |
| 335 | new signer.ResourceNotFoundException({ |
| 336 | message: `signing profile ${profileName} not visible after PutSigningProfile`, |
| 337 | }), |
| 338 | ); |
| 339 | } |
| 340 | if (live.status !== "Active") { |
| 341 | // The name is tombstoned by a canceled/revoked profile — Signer |
| 342 | // reserves canceled names, so the profile cannot be recreated |
| 343 | // under this name. Only reachable with a user-fixed profileName |
| 344 | // (generated names get a fresh random suffix per instance). |
| 345 | return yield* Effect.fail( |
| 346 | new signer.SigningProfileAlreadyExists({ |
| 347 | message: `signing profile ${profileName} exists with status ${live.status}; canceled profile names stay reserved — use a different profileName (or omit it to generate one)`, |
| 348 | }), |
| 349 | ); |
| 350 | } |
| 351 | |
| 352 | // SYNC TAGS — diff against OBSERVED cloud tags (adoption may bring |
| 353 | // foreign tags; put only applies tags on first creation). |
| 354 | const observedTags = toTagRecord(live.tags); |
| 355 | const { upsert, removed } = diffTags(observedTags, desiredTags); |
| 356 | if (upsert.length > 0) { |
| 357 | yield* signer.tagResource({ |
| 358 | resourceArn: live.arn!, |
| 359 | tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])), |
| 360 | }); |
| 361 | } |
| 362 | if (removed.length > 0) { |
| 363 | yield* signer.untagResource({ |
| 364 | resourceArn: live.arn!, |
| 365 | tagKeys: removed, |
| 366 | }); |
| 367 | } |
| 368 | |
| 369 | yield* session.note(profileName); |
| 370 | return toAttributes(profileName, live); |
| 371 | }), |
| 372 | |
| 373 | delete: Effect.fn(function* ({ output }) { |
| 374 | // Signing profiles cannot be deleted directly — cancel deactivates |
| 375 | // them and AWS deletes canceled profiles per its data-retention |
| 376 | // policy. Cancel is idempotent (re-canceling a canceled profile |
| 377 | // succeeds); a missing profile is not an error. |
| 378 | yield* signer |
| 379 | .cancelSigningProfile({ profileName: output.profileName }) |
| 380 | .pipe( |
| 381 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 382 | ); |
| 383 | }), |
| 384 | }); |
| 385 | }), |
| 386 | ); |