1import * as iamcredentials from "@distilled.cloud/gcp/iamcredentials_v1";
2import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import { createHash } from "node:crypto";
8import { bindGcpHost, HOST_SERVICE_ACCOUNT } from "../Host.ts";
9import { grantFor } from "../HttpBinding.ts";
10import type { Bucket } from "./Bucket.ts";
11import {
12 SignedUrlFailed,
13 SignGetObjectUrl,
14 type SignGetObjectUrlRequest,
15} from "./SignGetObjectUrl.ts";
16
17const EMAIL_ENDPOINT =
18 "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email";
19const STORAGE_HOST = "storage.googleapis.com";
20const DEFAULT_EXPIRES_IN = 900;
21const MAX_EXPIRES_IN = 604_800;
22
23/** RFC 3986 encoding, as V4 signing requires. */
24const encode = (value: string) =>
25 encodeURIComponent(value).replace(
26 /[!'()*]/g,
27 (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
28 );
29
30const sha256Hex = (value: string) =>
31 createHash("sha256").update(value, "utf8").digest("hex");
32
33/** `20260102T030405Z` and `20260102` for a Unix time in millis. */
34const timestamps = (millis: number) => {
35 const iso = new Date(millis).toISOString().replace(/[-:]/g, "");
36 const datetime = `${iso.slice(0, 15)}Z`;
37 return { datetime, date: datetime.slice(0, 8) };
38};
39
40/**
41 * HTTP implementation of {@link SignGetObjectUrl}: V4 signing where the
42 * signature comes from IAM Credentials `signBlob` as the runtime's own
43 * service account (read from the metadata server).
44 *
45 * @layer
46 * @provides GCP.Storage.SignGetObjectUrl
47 * @category Storage
48 */
49export const SignGetObjectUrlHttp = Layer.effect(
50 SignGetObjectUrl,
51 Effect.gen(function* () {
52 const http = yield* HttpClient.HttpClient;
53 const signBlob = yield* iamcredentials.signBlobProjectsServiceAccounts;
54 let ownEmail: string | undefined;
55
56 const serviceAccountEmail = Effect.gen(function* () {
57 if (ownEmail !== undefined) return ownEmail;
58 const response = yield* http.execute(
59 HttpClientRequest.get(EMAIL_ENDPOINT).pipe(
60 HttpClientRequest.setHeader("Metadata-Flavor", "Google"),
61 ),
62 );
63 const text = (yield* response.text).trim();
64 if (response.status !== 200 || !text.includes("@")) {
65 return yield* new SignedUrlFailed({
66 message: `GCE metadata email endpoint returned HTTP ${response.status}`,
67 });
68 }
69 ownEmail = text;
70 return text;
71 }).pipe(
72 Effect.mapError((cause) =>
73 cause instanceof SignedUrlFailed
74 ? cause
75 : new SignedUrlFailed({
76 message: `GCE metadata email endpoint is unreachable: ${String(cause)}`,
77 cause,
78 }),
79 ),
80 );
81
82 return Effect.fn(function* (bucket: Bucket) {
83 yield* bindGcpHost({
84 tag: "GCP.Storage.SignGetObjectUrl",
85 resource: bucket,
86 iam: [
87 // A signed URL carries the signer's own object permissions.
88 grantFor(
89 { role: "roles/storage.objectViewer", on: "storage.bucket" },
90 bucket.bucketName,
91 ),
92 // signBlob as itself, and on no other account.
93 {
94 role: "roles/iam.serviceAccountTokenCreator",
95 resource: {
96 kind: "iam.serviceAccount",
97 name: HOST_SERVICE_ACCOUNT,
98 },
99 },
100 ],
101 });
102 const bucketName = yield* bucket.bucketName;
103 return Effect.fn(`GCP.Storage.SignGetObjectUrl(${bucket.LogicalId})`)(
104 function* (request: SignGetObjectUrlRequest) {
105 const email = yield* serviceAccountEmail;
106 const name = yield* bucketName;
107 const now = yield* Clock.currentTimeMillis;
108 const { datetime, date } = timestamps(now);
109 const scope = `${date}/auto/storage/goog4_request`;
110 const expiresIn = Math.min(
111 Math.max(Math.floor(request.expiresIn ?? DEFAULT_EXPIRES_IN), 1),
112 MAX_EXPIRES_IN,
113 );
114 const params: Record<string, string> = {
115 "X-Goog-Algorithm": "GOOG4-RSA-SHA256",
116 "X-Goog-Credential": `${email}/${scope}`,
117 "X-Goog-Date": datetime,
118 "X-Goog-Expires": String(expiresIn),
119 "X-Goog-SignedHeaders": "host",
120 ...(request.generation !== undefined
121 ? { generation: request.generation }
122 : {}),
123 ...(request.contentType !== undefined
124 ? { "response-content-type": request.contentType }
125 : {}),
126 };
127 const query = Object.keys(params)
128 .sort()
129 .map((key) => `${encode(key)}=${encode(params[key]!)}`)
130 .join("&");
131 const path = `/${name}/${request.object
132 .split("/")
133 .map(encode)
134 .join("/")}`;
135 const canonicalRequest = [
136 "GET",
137 path,
138 query,
139 `host:${STORAGE_HOST}`,
140 "",
141 "host",
142 "UNSIGNED-PAYLOAD",
143 ].join("\n");
144 const stringToSign = [
145 "GOOG4-RSA-SHA256",
146 datetime,
147 scope,
148 yield* Effect.sync(() => sha256Hex(canonicalRequest)),
149 ].join("\n");
150 const signed = yield* signBlob({
151 name: `projects/-/serviceAccounts/${email}`,
152 body: {
153 payload: yield* Effect.sync(() =>
154 Buffer.from(stringToSign, "utf8").toString("base64"),
155 ),
156 },
157 });
158 if (signed.signedBlob === undefined) {
159 return yield* new SignedUrlFailed({
160 message: "IAM Credentials signBlob returned no signature",
161 });
162 }
163 const signature = yield* Effect.sync(() =>
164 Buffer.from(signed.signedBlob!, "base64").toString("hex"),
165 );
166 return `https://${STORAGE_HOST}${path}?${query}&X-Goog-Signature=${signature}`;
167 },
168 );
169 });
170 }),
171);