1import { isBindingHost } from "@/AWS/Lambda/Function.ts";
2import * as Lambda from "@/AWS/Lambda";
3import * as S3 from "@/AWS/S3";
4import * as Signer from "@/AWS/Signer";
5import * as Binding from "@/Binding";
6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Stream from "effect/Stream";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12import path from "pathe";
13
14const main = path.resolve(import.meta.dirname, "handler.ts");
15
16/**
17 * A deterministic one-file zip (`index.js` with a no-op handler) — the code
18 * artifact the AWSLambda platform signs. Generated once and checked in.
19 */
20const CODE_ZIP_BASE64 =
21 "UEsDBBQAAAAAAAAAIVyGZ4n3JAAAACQAAAAIAAAAaW5kZXguanNleHBvcnRzLmhhbmRsZXIgPSBhc3luYyAoKSA9PiAoe30pOwpQSwECFAMUAAAAAAAAACFchmeJ9yQAAAAkAAAACAAAAAAAAAAAAAAAgAEAAAAAaW5kZXguanNQSwUGAAAAAAEAAQA2AAAASgAAAAAA";
22const CODE_ZIP = Uint8Array.from(Buffer.from(CODE_ZIP_BASE64, "base64"));
23
24/** A syntactically valid Notary payload for `SignPayload`. */
25const NOTARY_PAYLOAD = new TextEncoder().encode(
26 JSON.stringify({
27 targetArtifact: {
28 mediaType: "application/vnd.oci.image.manifest.v1+json",
29 digest:
30 "sha256:73c803930ea3ba1e54bc25c2bdc53edd0284c62ed651fe7b00369da519a3c333",
31 size: 1024,
32 },
33 }),
34);
35
36const SOURCE_KEY = "code.zip";
37
38export class SignerTestFunction extends Lambda.Function<Lambda.Function>()(
39 "SignerTestFunction",
40) {}
41
42export default SignerTestFunction.make(
43 {
44 main,
45 functionUrl: true,
46 timeout: Duration.seconds(60),
47 // The default 128 MB pins at the ceiling (~123 MB used) once the S3 +
48 // Signer clients are warm and 500s under load — give it headroom.
49 memorySize: 512,
50 },
51 Effect.gen(function* () {
52 // The profile under test: AWS-managed Lambda code-signing material.
53 const profile = yield* Signer.SigningProfile("BindingsProfile", {
54 platformId: "AWSLambda-SHA384-ECDSA",
55 });
56 // A Notation profile for the synchronous SignPayload path.
57 const notationProfile = yield* Signer.SigningProfile("NotationProfile", {
58 platformId: "Notation-OCI-SHA384-ECDSA",
59 });
60
61 // StartSigningJob reads the versioned source object and writes the
62 // signed artifact with the CALLER's credentials — bind the S3 access.
63 const src = yield* S3.Bucket("SignerSrc", {
64 versioning: "Enabled",
65 forceDestroy: true,
66 });
67 const dst = yield* S3.Bucket("SignerDst", { forceDestroy: true });
68 // Outputs yield a DEFERRED effect — resolve again per invocation below.
69 const SrcName = yield* src.bucketName;
70 const DstName = yield* dst.bucketName;
71
72 // Event source: subscribe the host to signing-job status changes. The
73 // deploy proves the EventBridge rule + invoke permission wiring.
74 yield* Signer.consumeSigningJobEvents({}, (events) =>
75 Stream.runForEach(events, (event) =>
76 Effect.log(`signer job ${event.detail.job_id}: ${event.detail.status}`),
77 ),
78 );
79
80 const putObject = yield* S3.PutObject(src);
81 yield* S3.GetObject(src); // grants s3:GetObject(Version) for the signing job
82 yield* S3.PutObject(dst); // grants s3:PutObject on the destination
83
84 // Signer validates BUCKET-level access on both buckets when starting a
85 // job ("S3 bucket … not accessible") — grant the documented signing-
86 // caller set the object-level bindings above don't cover.
87 if (!globalThis.__ALCHEMY_RUNTIME__) {
88 const host = yield* Binding.Host;
89 if (isBindingHost(host)) {
90 yield* host.bind`Allow(${host}, SignerSourceDestinationBucketAccess)`({
91 policyStatements: [
92 {
93 Effect: "Allow",
94 Action: [
95 "s3:GetBucketLocation",
96 "s3:GetBucketVersioning",
97 "s3:ListBucket",
98 "s3:ListBucketVersions",
99 ],
100 Resource: [src.bucketArn, dst.bucketArn],
101 },
102 ],
103 });
104 }
105 }
106
107 const startSigningJob = yield* Signer.StartSigningJob(profile);
108 const signPayload = yield* Signer.SignPayload(notationProfile);
109 const revokeSigningProfile =
110 yield* Signer.RevokeSigningProfile(notationProfile);
111 const describeSigningJob = yield* Signer.DescribeSigningJob();
112 const listSigningJobs = yield* Signer.ListSigningJobs();
113 const revokeSignature = yield* Signer.RevokeSignature();
114 const getRevocationStatus = yield* Signer.GetRevocationStatus();
115 const getSigningPlatform = yield* Signer.GetSigningPlatform();
116 const listSigningPlatforms = yield* Signer.ListSigningPlatforms();
117
118 const bound = {
119 startSigningJob,
120 signPayload,
121 revokeSigningProfile,
122 describeSigningJob,
123 listSigningJobs,
124 revokeSignature,
125 getRevocationStatus,
126 getSigningPlatform,
127 listSigningPlatforms,
128 };
129
130 return {
131 fetch: Effect.gen(function* () {
132 const request = yield* HttpServerRequest;
133 const url = new URL(request.originalUrl);
134 const pathname = url.pathname;
135 const id = url.searchParams.get("id");
136
137 if (request.method === "GET" && pathname === "/bindings") {
138 return yield* HttpServerResponse.json({
139 bound: Object.keys(bound),
140 });
141 }
142
143 if (request.method === "GET" && pathname === "/platforms") {
144 // The catalog paginates with a small page size — walk it (bounded).
145 const ids: (string | undefined)[] = [];
146 let nextToken: string | undefined;
147 let pages = 0;
148 do {
149 const page = yield* listSigningPlatforms({
150 maxResults: 25,
151 nextToken,
152 });
153 ids.push(...(page.platforms ?? []).map((p) => p.platformId));
154 nextToken = page.nextToken;
155 pages += 1;
156 } while (nextToken !== undefined && pages < 10);
157 return yield* HttpServerResponse.json({ ids });
158 }
159
160 if (request.method === "GET" && pathname === "/platform") {
161 const platform = yield* getSigningPlatform({
162 platformId: id ?? "AWSLambda-SHA384-ECDSA",
163 });
164 return yield* HttpServerResponse.json({
165 platformId: platform.platformId,
166 revocationSupported: platform.revocationSupported ?? false,
167 });
168 }
169
170 // Upload the code zip (capturing the object version) and start a
171 // signing job over it — the profileName is injected by the binding.
172 if (request.method === "POST" && pathname === "/start") {
173 const uploaded = yield* putObject({
174 Key: SOURCE_KEY,
175 Body: CODE_ZIP,
176 ContentType: "application/zip",
177 });
178 if (uploaded.VersionId === undefined) {
179 // Bucket versioning still propagating — the test re-polls.
180 return yield* HttpServerResponse.json({
181 ok: false,
182 tag: "NoVersionId",
183 });
184 }
185 const started = yield* startSigningJob({
186 // Required idempotency token — a fresh UUID per request so each
187 // POST /start begins a new signing job.
188 clientRequestToken: yield* Effect.sync(() => crypto.randomUUID()),
189 source: {
190 s3: {
191 bucketName: yield* SrcName,
192 key: SOURCE_KEY,
193 version: uploaded.VersionId!,
194 },
195 },
196 destination: {
197 s3: { bucketName: yield* DstName, prefix: "signed/" },
198 },
199 }).pipe(Effect.result);
200 return yield* HttpServerResponse.json(
201 started._tag === "Success"
202 ? { ok: true, jobId: started.success.jobId }
203 : {
204 ok: false,
205 tag: started.failure._tag,
206 message: started.failure.message,
207 },
208 );
209 }
210
211 if (request.method === "GET" && pathname === "/jobs") {
212 const { jobs } = yield* listSigningJobs({ maxResults: 25 });
213 return yield* HttpServerResponse.json({
214 jobIds: (jobs ?? []).map((j) => j.jobId),
215 });
216 }
217
218 // Synchronous Notation signing — reports the typed rejection tag if
219 // Signer refuses the payload so the test can assert the round-trip.
220 if (request.method === "POST" && pathname === "/sign") {
221 const signed = yield* signPayload({
222 payload: NOTARY_PAYLOAD,
223 payloadFormat: "application/vnd.cncf.notary.payload.v1+json",
224 }).pipe(Effect.result);
225 return yield* HttpServerResponse.json(
226 signed._tag === "Success"
227 ? {
228 ok: true,
229 jobId: signed.success.jobId,
230 hasSignature: (signed.success.signature?.length ?? 0) > 0,
231 }
232 : { ok: false, tag: signed.failure._tag },
233 );
234 }
235
236 if (id === null) {
237 return yield* HttpServerResponse.json(
238 { error: "missing id" },
239 { status: 400 },
240 );
241 }
242
243 if (request.method === "GET" && pathname === "/job") {
244 // A freshly-started job can 404 for a few seconds (eventual
245 // consistency) — report the typed tag so the test keeps polling.
246 const job = yield* describeSigningJob({ jobId: id }).pipe(
247 Effect.result,
248 );
249 return yield* HttpServerResponse.json(
250 job._tag === "Success"
251 ? {
252 status: job.success.status,
253 signedKey: job.success.signedObject?.s3?.key,
254 }
255 : { status: "Pending", tag: job.failure._tag },
256 );
257 }
258
259 if (request.method === "POST" && pathname === "/revoke-job") {
260 const revoked = yield* revokeSignature({
261 jobId: id,
262 reason: "alchemy-test",
263 }).pipe(Effect.result);
264 return yield* HttpServerResponse.json(
265 revoked._tag === "Success"
266 ? { revoked: true }
267 : { revoked: false, tag: revoked.failure._tag },
268 );
269 }
270
271 // Revocation-status check for a completed job. ARNs are derived from
272 // the job's own metadata; empty certificateHashes may be rejected
273 // with a typed ValidationException — the route reports the tag so
274 // the test can assert the binding round-trips with IAM intact.
275 if (request.method === "GET" && pathname === "/revocation") {
276 const job = yield* describeSigningJob({ jobId: id });
277 const region = yield* Effect.sync(() => process.env.AWS_REGION);
278 const arnBase = `arn:aws:signer:${region}:${job.jobOwner}`;
279 const checked = yield* getRevocationStatus({
280 signatureTimestamp: job.completedAt ?? new Date(),
281 platformId: job.platformId!,
282 profileVersionArn: `${arnBase}:/signing-profiles/${job.profileName}/${job.profileVersion}`,
283 jobArn: `${arnBase}:/signing-jobs/${job.jobId}`,
284 certificateHashes: [],
285 }).pipe(Effect.result);
286 return yield* HttpServerResponse.json(
287 checked._tag === "Success"
288 ? {
289 ok: true,
290 revokedEntities: checked.success.revokedEntities ?? [],
291 }
292 : { ok: false, tag: checked.failure._tag },
293 );
294 }
295
296 return yield* HttpServerResponse.json(
297 { error: "Not found", method: request.method, pathname },
298 { status: 404 },
299 );
300 }).pipe(Effect.orDie),
301 };
302 }).pipe(
303 Effect.provide(
304 Layer.mergeAll(
305 Lambda.EventSource,
306 S3.PutObjectHttp,
307 S3.GetObjectHttp,
308 Signer.StartSigningJobHttp,
309 Signer.SignPayloadHttp,
310 Signer.RevokeSigningProfileHttp,
311 Signer.DescribeSigningJobHttp,
312 Signer.ListSigningJobsHttp,
313 Signer.RevokeSignatureHttp,
314 Signer.GetRevocationStatusHttp,
315 Signer.GetSigningPlatformHttp,
316 Signer.ListSigningPlatformsHttp,
317 ),
318 ),
319 ),
320);