1
21
22import { createHash, createHmac } from "node:crypto";
23
24import type { S3Config } from "./types.js";
25
26
27export function sha256Hex(data: Uint8Array | string): string {
28 const hash = createHash("sha256");
29 hash.update(data);
30 return hash.digest("hex");
31}
32
33
34export function hmacSha256(key: string | Uint8Array, data: string | Uint8Array): string {
35 return createHmac("sha256", key).update(data).digest("hex");
36}
37
38
39export function sha256Bytes(data: Uint8Array | string): Uint8Array {
40 return new Uint8Array(createHash("sha256").update(data).digest());
41}
42
43
44export const EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
45
46
48export function uriEncode(input: string, encodeSlash = true): string {
49 let out = "";
50 for (const ch of input) {
51 const hex = ch.codePointAt(0)!;
52
53
54
55 if (
56 (hex >= 0x30 && hex <= 0x39) ||
57 (hex >= 0x41 && hex <= 0x5a) ||
58 (hex >= 0x61 && hex <= 0x7a) ||
59 ch === "-" ||
60 ch === "." ||
61 ch === "_" ||
62 ch === "~"
63 ) {
64 out += ch;
65 } else if (ch === "/" && !encodeSlash) {
66 out += "/";
67 } else {
68 for (const byte of new TextEncoder().encode(ch)) {
69 out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
70 }
71 }
72 }
73 return out;
74}
75
76
88export function encodePath(path: string): string {
89 return (
90 path
91 .split("/")
92 .map((seg, i) => (i === 0 ? "" : uriEncode(seg, false)))
93 .join("/") || "/"
94 );
95}
96
97
101export function canonicalQuery(params: Record<string, string>): string {
102 const entries = Object.entries(params)
103 .map(([k, v]) => [uriEncode(k), uriEncode(v)] as const)
104 .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
105 return entries.map(([k, v]) => `${k}=${v}`).join("&");
106}
107
108
109export function amzDate(now: Date): string {
110 return now
111 .toISOString()
112 .replace(/[-:]/g, "")
113 .replace(/\.\d{3}/, "");
114}
115
116
117export function amzDateDay(now: Date): string {
118 return amzDate(now).slice(0, 8);
119}
120
121
122export function credentialScope(now: Date, region: string, service = "s3"): string {
123 return `${amzDateDay(now)}/${region}/${service}/aws4_request`;
124}
125
126export interface SignS3RequestOptions {
127 method: string;
128
129 path: string;
130
131 query?: Record<string, string>;
132
133 headers: Record<string, string>;
134
135 payloadHash: string;
136 config: S3Config;
137
138 now: Date;
139
147 alwaysSignContentSha?: boolean;
148}
149
150export interface SignedS3Request {
151
153 headers: Record<string, string>;
154
155 authorization: string;
156
157 canonicalRequest: string;
158 stringToSign: string;
159 signature: string;
160}
161
162
167export function signS3Request(opts: SignS3RequestOptions): SignedS3Request {
168 const { config, now } = opts;
169 const method = opts.method.toUpperCase();
170 const query = opts.query ?? {};
171
172
173
174
175 const canonicalUri = encodePath(opts.path);
176
177 const canonicalQs = canonicalQuery(query);
178 const date = amzDate(now);
179
180
181
182
183
184
185
186
187
188
189 const alwaysSign = opts.alwaysSignContentSha === true;
190 const lowerHeaders: Record<string, string> = {};
191 for (const [k, v] of Object.entries(opts.headers)) {
192 const key = k.toLowerCase();
193 lowerHeaders[key] = v.replace(/\s+/g, " ").trim();
194 }
195 if (!("host" in lowerHeaders)) {
196 lowerHeaders["host"] = requestHost(config);
197 }
198 if (alwaysSign) {
199 lowerHeaders["x-amz-date"] = date;
200 lowerHeaders["x-amz-content-sha256"] = opts.payloadHash;
201 }
202 const headerNames = Object.keys(lowerHeaders).sort();
203 const canonicalHeaders = headerNames.map((n) => `${n}:${lowerHeaders[n]}\n`).join("");
204 const signedHeaders = headerNames.join(";");
205
206 const canonicalRequest = [
207 method,
208 canonicalUri,
209 canonicalQs,
210 canonicalHeaders,
211 signedHeaders,
212 opts.payloadHash,
213 ].join("\n");
214
215 const scope = credentialScope(now, config.region);
216 const stringToSign = ["AWS4-HMAC-SHA256", amzDate(now), scope, sha256Hex(canonicalRequest)].join(
217 "\n",
218 );
219
220
221 const kDate = createHmac("sha256", `AWS4${config.secretAccessKey}`)
222 .update(amzDateDay(now))
223 .digest();
224 const kRegion = createHmac("sha256", kDate).update(config.region).digest();
225 const kService = createHmac("sha256", kRegion).update("s3").digest();
226 const kSigning = createHmac("sha256", kService).update("aws4_request").digest();
227 const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
228
229 const headers: Record<string, string> = { ...lowerHeaders };
230 headers["Authorization"] =
231 `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${scope}, ` +
232 `SignedHeaders=${signedHeaders}, Signature=${signature}`;
233
234 return {
235 headers,
236 authorization: headers["Authorization"]!,
237 canonicalRequest,
238 stringToSign,
239 signature,
240 };
241}
242
243
249export function buildUrl(config: S3Config, path: string, query?: Record<string, string>): string {
250 const origin = config.endpoint.replace(/\/+$/, "");
251 const qs = query && Object.keys(query).length > 0 ? `?${canonicalQuery(query)}` : "";
252 return `${origin}${encodePath(path)}${qs}`;
253}
254
255
264
272export function objectRequestParts(config: S3Config, key: string): { url: string; path: string } {
273 if (config.forcePathStyle === true) {
274 const path = `/${config.bucket}/${key}`;
275 return { url: buildUrl(config, path), path };
276 }
277
278 const origin = config.endpoint.replace(/\/+$/, "");
279 const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]+)(.*)$/i.exec(origin);
280 if (m) {
281 const proto = m[1]!;
282 const host = m[2]!;
283 const rest = m[3] ?? "";
284
285
286
287 return { url: `${proto}${config.bucket}.${host}${rest}/${encodePath(`/${key}`).slice(1)}`, path: `/${key}` };
288 }
289
290 const path = `/${config.bucket}/${key}`;
291 return { url: buildUrl(config, path), path };
292}
293
294const PRE_SIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
295
296
307export function requestHost(config: S3Config): string {
308 const origin = config.endpoint.replace(/\/+$/, "");
309 const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]+)(.*)$/i.exec(origin);
310 if (!m) return origin;
311 const authority = m[2]!;
312 if (config.forcePathStyle === true) return authority;
313
314 return `${config.bucket}.${authority}`;
315}
316
317
336export function presignRequest(opts: {
337 method: string;
338 config: S3Config;
339
340 path: string;
341
342 headers: Record<string, string>;
343
344 expiresSec: number;
345
346 now: Date;
347
349 extraQuery?: Record<string, string> | undefined;
350}): {
351 url: string;
352
358 headers: Record<string, string>;
359 method: string;
360 expiresInSec: number;
361} {
362 const { config, now, expiresSec } = opts;
363 const method = opts.method.toUpperCase();
364 const date = amzDate(now);
365
366 const lowerHeaders: Record<string, string> = {};
367 for (const [k, v] of Object.entries(opts.headers)) {
368 lowerHeaders[k.toLowerCase()] = v.replace(/\s+/g, " ").trim();
369 }
370
371
372
373 lowerHeaders["host"] = requestHost(config);
374 const signedHeaderNames = Object.keys(lowerHeaders).sort();
375 const signedHeaders = signedHeaderNames.join(";");
376
377 const query: Record<string, string> = {
378 "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
379 "X-Amz-Credential": `${config.accessKeyId}/${credentialScope(now, config.region)}`,
380 "X-Amz-Date": date,
381 "X-Amz-Expires": String(expiresSec),
382 "X-Amz-SignedHeaders": signedHeaders,
383 ...(opts.extraQuery ?? {}),
384 };
385
386 const canonicalUri = encodePath(opts.path);
387 const canonicalQs = canonicalQuery(query);
388 const canonicalHeaders = signedHeaderNames.map((n) => `${n}:${lowerHeaders[n]}\n`).join("");
389 const canonicalRequest = [
390 method,
391 canonicalUri,
392 canonicalQs,
393 canonicalHeaders,
394 signedHeaders,
395 PRE_SIGNED_PAYLOAD,
396 ].join("\n");
397
398 const stringToSign = [
399 "AWS4-HMAC-SHA256",
400 date,
401 credentialScope(now, config.region),
402 sha256Hex(canonicalRequest),
403 ].join("\n");
404
405 const kDate = createHmac("sha256", `AWS4${config.secretAccessKey}`)
406 .update(amzDateDay(now))
407 .digest();
408 const kRegion = createHmac("sha256", kDate).update(config.region).digest();
409 const kService = createHmac("sha256", kRegion).update("s3").digest();
410 const kSigning = createHmac("sha256", kService).update("aws4_request").digest();
411 const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
412
413 query["X-Amz-Signature"] = signature;
414
415
416
417
418
419 const urlQuery = canonicalQuery(query);
420 const origin = config.endpoint.replace(/\/+$/, "");
421
422
423
424
425 const { "x-amz-date": _omitDate, host: _omitHost, ...clientHeaders } = lowerHeaders;
426 void _omitDate;
427 void _omitHost;
428
429 return {
430
431
432
433 url: `${origin}${encodePath(opts.path)}?${urlQuery}`,
434 headers: clientHeaders,
435 method,
436 expiresInSec: expiresSec,
437 };
438}
439
440export interface PresignPutOptions {
441 config: S3Config;
442 key: string;
443
444 contentType: string;
445
446 expiresSec: number;
447
448 now: Date;
449}
450
451
459export function presignPut(opts: PresignPutOptions): {
460 url: string;
461 headers: Record<string, string>;
462 method: "PUT";
463 expiresInSec: number;
464} {
465 const { config, key, contentType, expiresSec, now } = opts;
466 const { path } = objectRequestParts(config, key);
467 const signed = presignRequest({
468 method: "PUT",
469 path,
470 headers: { "content-type": contentType },
471 expiresSec,
472 now,
473 config,
474 });
475 return { ...signed, method: "PUT" };
476}
477
478
486export function presignGet(opts: {
487 config: S3Config;
488 key: string;
489 expiresSec: number;
490 now: Date;
491
492 responseContentDisposition?: string;
493}): { url: string; headers: Record<string, string>; method: "GET"; expiresInSec: number } {
494 const { config, key, expiresSec, now, responseContentDisposition } = opts;
495 const { path } = objectRequestParts(config, key);
496 const extraQuery: Record<string, string> = {};
497 if (responseContentDisposition) {
498 extraQuery["response-content-disposition"] = responseContentDisposition;
499 }
500 const signed = presignRequest({
501 method: "GET",
502 path,
503 headers: {},
504 expiresSec,
505 now,
506 config,
507 extraQuery: Object.keys(extraQuery).length > 0 ? extraQuery : undefined,
508 });
509 return { ...signed, method: "GET" };
510}
511