1import * as signer from "@distilled.cloud/aws/signer";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import 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 */
18export 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 */
29export 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 */
46export 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
63export 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
112export 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 */
164export 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 */
172const 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. */
176const 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
186export 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 );