1
9
10import { describe, expect, it } from "vitest";
11import { createHmac } from "node:crypto";
12
13import {
14 sha256Hex,
15 hmacSha256,
16 signS3Request,
17 presignPut,
18 presignGet,
19 presignRequest,
20 canonicalQuery,
21 uriEncode,
22 encodePath,
23 amzDate,
24 credentialScope,
25 EMPTY_BODY_SHA256,
26 objectRequestParts,
27 buildUrl,
28} from "./presign.js";
29import type { S3Config } from "./types.js";
30
31const CONFIG: S3Config = {
32 endpoint: "https://minio.example.test:9000",
33 region: "us-east-1",
34 bucket: "factory-files",
35 accessKeyId: "AKIAIOSFODNN7EXAMPLE",
36 secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCyEXAMPLEKEY",
37 forcePathStyle: true,
38};
39
40const NOW = new Date("2025-01-15T10:30:00.000Z");
41
42describe("sha256Hex / hmacSha256 known-answer vectors", () => {
43 it("sha256 of empty string", () => {
44 expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
45 });
46 it("sha256 of 'abc'", () => {
47 expect(sha256Hex("abc")).toBe(
48 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
49 );
50 });
51 it("sha256 of UTF-8 bytes", () => {
52 expect(sha256Hex(new TextEncoder().encode("héllo"))).toBe(sha256Hex("héllo"));
53 });
54 it("hmac matches node:crypto directly", () => {
55 const ref = createHmac("sha256", "key").update("message").digest("hex");
56 expect(hmacSha256("key", "message")).toBe(ref);
57 });
58 it("hmac of bytes with a binary key", () => {
59 const k = new Uint8Array([1, 2, 3, 4]);
60 const ref = createHmac("sha256", k).update("data").digest("hex");
61 expect(hmacSha256(k, "data")).toBe(ref);
62 });
63 it("EMPTY_BODY_SHA256 is the sha256 of empty", () => {
64 expect(EMPTY_BODY_SHA256).toBe(sha256Hex(""));
65 });
66});
67
68describe("uriEncode / canonicalQuery (AWS RFC 3986 rules)", () => {
69 it("encodes spaces, keeps unreserved + tilde", () => {
70 expect(uriEncode("a b")).toBe("a%20b");
71 expect(uriEncode("a~b-c_d.e")).toBe("a~b-c_d.e");
72 expect(uriEncode("/")).toBe("%2F");
73 expect(uriEncode("/", false)).toBe("/");
74 });
75 it("encodes unicode to UTF-8 percent bytes", () => {
76 expect(uriEncode("é")).toBe("%C3%A9");
77 });
78 it("sorts params by key then value", () => {
79 expect(canonicalQuery({ b: "2", a: "1", c: "x y" })).toBe("a=1&b=2&c=x%20y");
80 });
81 it("empty params → empty string", () => {
82 expect(canonicalQuery({})).toBe("");
83 });
84});
85
86describe("date + scope formatting", () => {
87 it("amzDate is ISO basic format", () => {
88 expect(amzDate(NOW)).toBe("20250115T103000Z");
89 });
90 it("credentialScope is date/region/service/aws4_request", () => {
91 expect(credentialScope(NOW, "us-east-1")).toBe("20250115/us-east-1/s3/aws4_request");
92 });
93});
94
95describe("signS3Request — deterministic SigV4 for a fixed now", () => {
96 const opts = {
97 method: "PUT",
98 path: "/factory-files/workspaces/wsp_1/files/file_1/v1/hello world.png",
99 query: { "X-Amz-Expires": "900" },
100 headers: {
101 "content-type": "image/png",
102 "x-amz-date": amzDate(NOW),
103 },
104 payloadHash: sha256Hex("hello"),
105 config: CONFIG,
106 now: NOW,
107 };
108
109 it("produces an Authorization in the exact AWS4-HMAC-SHA256 shape", () => {
110 const signed = signS3Request(opts);
111 expect(signed.authorization).toMatch(
112 /^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\/20250115\/us-east-1\/s3\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/,
113 );
114 });
115
116 it("is byte-identical across two calls with the same now (determinism)", () => {
117 const a = signS3Request(opts);
118 const b = signS3Request(opts);
119 expect(a.authorization).toBe(b.authorization);
120 expect(a.stringToSign).toBe(b.stringToSign);
121 expect(a.canonicalRequest).toBe(b.canonicalRequest);
122 });
123
124 it("matches an INDEPENDENT recomputation of the whole chain", () => {
125 const s = signS3Request(opts);
126
127
128
129 const canonicalHeaders =
130 "content-type:image/png\nhost:minio.example.test:9000\nx-amz-date:20250115T103000Z\n";
131 const canonicalRequest = [
132 "PUT",
133 "/factory-files/workspaces/wsp_1/files/file_1/v1/hello%20world.png",
134 "X-Amz-Expires=900",
135 canonicalHeaders,
136 "content-type;host;x-amz-date",
137 sha256Hex("hello"),
138 ].join("\n");
139 expect(s.canonicalRequest).toBe(canonicalRequest);
140
141 const scope = "20250115/us-east-1/s3/aws4_request";
142 const stringToSign = [
143 "AWS4-HMAC-SHA256",
144 "20250115T103000Z",
145 scope,
146 sha256Hex(canonicalRequest),
147 ].join("\n");
148 expect(s.stringToSign).toBe(stringToSign);
149
150
151 const secret = CONFIG.secretAccessKey;
152 const kDate = createHmac("sha256", `AWS4${secret}`).update("20250115").digest();
153 const kRegion = createHmac("sha256", kDate).update("us-east-1").digest();
154 const kService = createHmac("sha256", kRegion).update("s3").digest();
155 const kSigning = createHmac("sha256", kService).update("aws4_request").digest();
156 const expected = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
157 expect(s.signature).toBe(expected);
158 expect(s.headers["Authorization"]).toBe(
159 `AWS4-HMAC-SHA256 Credential=${CONFIG.accessKeyId}/${scope}, ` +
160 `SignedHeaders=content-type;host;x-amz-date, Signature=${expected}`,
161 );
162 });
163
164 it("different now → different signature", () => {
165 const later = new Date("2025-01-15T11:30:00.000Z");
166 const a = signS3Request(opts);
167 const b = signS3Request({
168 ...opts,
169 now: later,
170 headers: { ...opts.headers, "x-amz-date": amzDate(later) },
171 });
172 expect(a.signature).not.toBe(b.signature);
173 });
174
175 it("different payload hash → different signature", () => {
176 const a = signS3Request(opts);
177 const b = signS3Request({ ...opts, payloadHash: sha256Hex("other") });
178 expect(a.signature).not.toBe(b.signature);
179 });
180
181 it("forces x-amz-date + x-amz-content-sha256 when alwaysSignContentSha (default)", () => {
182 const s = signS3Request({ ...opts, alwaysSignContentSha: true });
183 expect(s.headers["x-amz-date"]).toBe(amzDate(NOW));
184 expect(s.headers["x-amz-content-sha256"]).toBe(sha256Hex("hello"));
185 expect(s.headers["Authorization"]).toMatch(/SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date/);
186 });
187});
188
189describe("objectRequestParts (path-style vs virtual-host)", () => {
190 it("path-style: bucket in path", () => {
191 const { url, path } = objectRequestParts(CONFIG, "a/b.png");
192 expect(path).toBe("/factory-files/a/b.png");
193 expect(url).toBe("https://minio.example.test:9000/factory-files/a/b.png");
194 });
195 it("virtual-host: bucket in host (not in canonical path)", () => {
196 const aws: S3Config = { ...CONFIG, forcePathStyle: false };
197 const { url, path } = objectRequestParts(aws, "a b/c.png");
198
199
200
201
202 expect(path).toBe("/a b/c.png");
203 expect(url).toBe("https://factory-files.minio.example.test:9000/a%20b/c.png");
204 });
205});
206
207describe("objectRequestParts — keys with special characters (SigV4 double-encoding regression)", () => {
208
209
210
211 const KEY = "files/wsp_1/file_1/1/ARL Budget 208384.xlsx";
212
213 it("path is RAW, url is single-encoded (identical encoding for both)", () => {
214 const { url, path } = objectRequestParts(CONFIG, KEY);
215 expect(path).toBe(`/factory-files/${KEY}`);
216 expect(url).toBe(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx`);
217 expect(path).not.toContain("%");
218 expect(url).not.toContain("%25");
219 });
220
221 it("presignPut signs the SAME path the URL carries (independent recomputation)", () => {
222 const { path } = objectRequestParts(CONFIG, KEY);
223 const p = presignPut({ config: CONFIG, key: KEY, contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", expiresSec: 900, now: NOW });
224
225 expect(p.url.startsWith(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx?`)).toBe(true);
226 expect(p.url).not.toContain("%2520");
227
228
229
230 const wirePath = new URL(p.url).pathname;
231 const canonicalUri = wirePath
232 .split("/")
233 .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false)))
234 .join("/");
235
236 expect(encodePath(path)).toBe(canonicalUri);
237 expect(canonicalUri).toContain("ARL%20Budget%20208384.xlsx");
238 expect(canonicalUri).not.toContain("%2520");
239 });
240
241 it("presignGet: same single-encoding guarantee for the download URL", () => {
242 const g = presignGet({ config: CONFIG, key: KEY, expiresSec: 600, now: NOW });
243 expect(g.url.startsWith(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx?`)).toBe(true);
244 expect(g.url).not.toContain("%2520");
245 const canonicalUri = new URL(g.url).pathname
246 .split("/")
247 .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false)))
248 .join("/");
249 expect(canonicalUri).toContain("ARL%20Budget%20208384.xlsx");
250 });
251});
252
253describe("encodePath", () => {
254 it("encodes each segment once, keeps / separators", () => {
255 expect(encodePath("/factory-files/a b/c.png")).toBe("/factory-files/a%20b/c.png");
256 expect(encodePath("/")).toBe("/");
257 expect(encodePath("/factory-files/héllo.pdf")).toBe("/factory-files/h%C3%A9llo.pdf");
258 });
259});
260
261describe("presignPut / presignGet", () => {
262 it("presignPut → browser-usable query-signed URL (no Authorization header)", () => {
263 const p = presignPut({
264 config: CONFIG,
265 key: "workspaces/wsp_1/files/file_1/v1/a.png",
266 contentType: "image/png",
267 expiresSec: 900,
268 now: NOW,
269 });
270 expect(p.method).toBe("PUT");
271 expect(p.expiresInSec).toBe(900);
272
273 expect(p.url).toContain("X-Amz-Algorithm=AWS4-HMAC-SHA256");
274
275
276 expect(p.url).toContain("X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250115%2Fus-east-1%2Fs3%2Faws4_request");
277 expect(p.url).toContain("X-Amz-Date=20250115T103000Z");
278 expect(p.url).toContain("X-Amz-Expires=900");
279 expect(p.url).toContain("X-Amz-SignedHeaders=content-type%3Bhost");
280 expect(p.url).toMatch(/X-Amz-Signature=[0-9a-f]{64}/);
281 expect(p.url.startsWith("https://minio.example.test:9000/factory-files/workspaces/wsp_1/files/file_1/v1/a.png?")).toBe(true);
282 expect(p.headers["Authorization"]).toBeUndefined();
283 expect(p.headers["content-type"]).toBe("image/png");
284 expect(p.headers["x-amz-date"]).toBeUndefined();
285 expect(p.headers["host"]).toBeUndefined();
286 });
287
288 it("presignPut signature matches an INDEPENDENT recomputation", () => {
289 const p = presignPut({
290 config: CONFIG,
291 key: "workspaces/wsp_1/files/file_1/v1/a.png",
292 contentType: "image/png",
293 expiresSec: 900,
294 now: NOW,
295 });
296 const scope = "20250115/us-east-1/s3/aws4_request";
297
298
299 const canonicalQueryStr = [
300 "X-Amz-Algorithm=AWS4-HMAC-SHA256",
301 `X-Amz-Credential=${uriEncode(`${CONFIG.accessKeyId}/${scope}`)}`,
302 "X-Amz-Date=20250115T103000Z",
303 "X-Amz-Expires=900",
304 `X-Amz-SignedHeaders=${uriEncode("content-type;host")}`,
305 ].join("&");
306 const canonicalRequest = [
307 "PUT",
308 "/factory-files/workspaces/wsp_1/files/file_1/v1/a.png",
309 canonicalQueryStr,
310 "content-type:image/png\nhost:minio.example.test:9000\n",
311 "content-type;host",
312 "UNSIGNED-PAYLOAD",
313 ].join("\n");
314 const stringToSign = [
315 "AWS4-HMAC-SHA256",
316 "20250115T103000Z",
317 scope,
318 sha256Hex(canonicalRequest),
319 ].join("\n");
320 const kDate = createHmac("sha256", `AWS4${CONFIG.secretAccessKey}`).update("20250115").digest();
321 const kRegion = createHmac("sha256", kDate).update("us-east-1").digest();
322 const kService = createHmac("sha256", kRegion).update("s3").digest();
323 const kSigning = createHmac("sha256", kService).update("aws4_request").digest();
324 const expected = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
325 expect(p.url).toContain(`X-Amz-Signature=${expected}`);
326 });
327
328 it("presignGet → query-signed GET URL, no client headers", () => {
329 const g = presignGet({
330 config: CONFIG,
331 key: "workspaces/wsp_1/files/file_1/v1/a.png",
332 expiresSec: 600,
333 now: NOW,
334 });
335 expect(g.method).toBe("GET");
336 expect(g.expiresInSec).toBe(600);
337 expect(g.url).toContain("X-Amz-Expires=600");
338 expect(g.url).toContain("X-Amz-SignedHeaders=host");
339 expect(g.headers).toEqual({});
340 });
341
342 it("presignGet signs response-content-disposition (disposition binding)", () => {
343 const a = presignGet({
344 config: CONFIG,
345 key: "files/wsp_1/f_1/1/a b.png",
346 expiresSec: 600,
347 now: NOW,
348 responseContentDisposition: 'attachment; filename="a b.png"',
349 });
350 const b = presignGet({
351 config: CONFIG,
352 key: "files/wsp_1/f_1/1/a b.png",
353 expiresSec: 600,
354 now: NOW,
355 responseContentDisposition: 'attachment; filename="evil.png"',
356 });
357 expect(a.url).toContain("response-content-disposition=");
358
359 expect(a.url).not.toBe(b.url);
360 });
361
362 it("presignRequest determinism for a fixed now", () => {
363 const args = {
364 method: "PUT",
365 config: CONFIG,
366 path: "/factory-files/k/1.png",
367 headers: { "content-type": "image/png" },
368 expiresSec: 900,
369 now: NOW,
370 };
371 const a1 = presignRequest(args);
372 const a2 = presignRequest(args);
373 expect(a1.url).toBe(a2.url);
374 });
375
376 it("buildUrl appends canonical query", () => {
377 expect(buildUrl(CONFIG, "/factory-files", { prefix: "a b", "max-keys": "10" })).toBe(
378 "https://minio.example.test:9000/factory-files?max-keys=10&prefix=a%20b",
379 );
380 });
381});
382