| 1 | import { isBindingHost } from "@/AWS/Lambda/Function.ts"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as S3 from "@/AWS/S3"; |
| 4 | import * as Signer from "@/AWS/Signer"; |
| 5 | import * as Binding from "@/Binding"; |
| 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 11 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 12 | import path from "pathe"; |
| 13 | |
| 14 | const 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 | */ |
| 20 | const CODE_ZIP_BASE64 = |
| 21 | "UEsDBBQAAAAAAAAAIVyGZ4n3JAAAACQAAAAIAAAAaW5kZXguanNleHBvcnRzLmhhbmRsZXIgPSBhc3luYyAoKSA9PiAoe30pOwpQSwECFAMUAAAAAAAAACFchmeJ9yQAAAAkAAAACAAAAAAAAAAAAAAAgAEAAAAAaW5kZXguanNQSwUGAAAAAAEAAQA2AAAASgAAAAAA"; |
| 22 | const CODE_ZIP = Uint8Array.from(Buffer.from(CODE_ZIP_BASE64, "base64")); |
| 23 | |
| 24 | /** A syntactically valid Notary payload for `SignPayload`. */ |
| 25 | const 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 | |
| 36 | const SOURCE_KEY = "code.zip"; |
| 37 | |
| 38 | export class SignerTestFunction extends Lambda.Function<Lambda.Function>()( |
| 39 | "SignerTestFunction", |
| 40 | ) {} |
| 41 | |
| 42 | export 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 | ); |