/** * @factory/storage — presign.test.ts (SigV4 core — THE testable part). * * Known-answer vectors for SHA-256/HMAC, SigV4 signature DETERMINISM for a * fixed `now` (exact Authorization string, verified twice + against an * independent re-implementation of the key-derivation chain), canonical * query sorting/encoding, presignPut/presignGet shapes. */ import { describe, expect, it } from "vitest"; import { createHmac } from "node:crypto"; import { sha256Hex, hmacSha256, signS3Request, presignPut, presignGet, presignRequest, canonicalQuery, uriEncode, encodePath, amzDate, credentialScope, EMPTY_BODY_SHA256, objectRequestParts, buildUrl, } from "./presign.js"; import type { S3Config } from "./types.js"; const CONFIG: S3Config = { endpoint: "https://minio.example.test:9000", region: "us-east-1", bucket: "factory-files", accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCyEXAMPLEKEY", forcePathStyle: true, }; const NOW = new Date("2025-01-15T10:30:00.000Z"); describe("sha256Hex / hmacSha256 known-answer vectors", () => { it("sha256 of empty string", () => { expect(sha256Hex("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); }); it("sha256 of 'abc'", () => { expect(sha256Hex("abc")).toBe( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", ); }); it("sha256 of UTF-8 bytes", () => { expect(sha256Hex(new TextEncoder().encode("héllo"))).toBe(sha256Hex("héllo")); }); it("hmac matches node:crypto directly", () => { const ref = createHmac("sha256", "key").update("message").digest("hex"); expect(hmacSha256("key", "message")).toBe(ref); }); it("hmac of bytes with a binary key", () => { const k = new Uint8Array([1, 2, 3, 4]); const ref = createHmac("sha256", k).update("data").digest("hex"); expect(hmacSha256(k, "data")).toBe(ref); }); it("EMPTY_BODY_SHA256 is the sha256 of empty", () => { expect(EMPTY_BODY_SHA256).toBe(sha256Hex("")); }); }); describe("uriEncode / canonicalQuery (AWS RFC 3986 rules)", () => { it("encodes spaces, keeps unreserved + tilde", () => { expect(uriEncode("a b")).toBe("a%20b"); expect(uriEncode("a~b-c_d.e")).toBe("a~b-c_d.e"); expect(uriEncode("/")).toBe("%2F"); expect(uriEncode("/", false)).toBe("/"); }); it("encodes unicode to UTF-8 percent bytes", () => { expect(uriEncode("é")).toBe("%C3%A9"); }); it("sorts params by key then value", () => { expect(canonicalQuery({ b: "2", a: "1", c: "x y" })).toBe("a=1&b=2&c=x%20y"); }); it("empty params → empty string", () => { expect(canonicalQuery({})).toBe(""); }); }); describe("date + scope formatting", () => { it("amzDate is ISO basic format", () => { expect(amzDate(NOW)).toBe("20250115T103000Z"); }); it("credentialScope is date/region/service/aws4_request", () => { expect(credentialScope(NOW, "us-east-1")).toBe("20250115/us-east-1/s3/aws4_request"); }); }); describe("signS3Request — deterministic SigV4 for a fixed now", () => { const opts = { method: "PUT", path: "/factory-files/workspaces/wsp_1/files/file_1/v1/hello world.png", query: { "X-Amz-Expires": "900" }, headers: { "content-type": "image/png", "x-amz-date": amzDate(NOW), }, payloadHash: sha256Hex("hello"), config: CONFIG, now: NOW, }; it("produces an Authorization in the exact AWS4-HMAC-SHA256 shape", () => { const signed = signS3Request(opts); expect(signed.authorization).toMatch( /^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\/20250115\/us-east-1\/s3\/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=[0-9a-f]{64}$/, ); }); it("is byte-identical across two calls with the same now (determinism)", () => { const a = signS3Request(opts); const b = signS3Request(opts); expect(a.authorization).toBe(b.authorization); expect(a.stringToSign).toBe(b.stringToSign); expect(a.canonicalRequest).toBe(b.canonicalRequest); }); it("matches an INDEPENDENT recomputation of the whole chain", () => { const s = signS3Request(opts); // Independent rebuild: same canonical request from raw parts. // `host` is ALWAYS part of the S3 SigV4 signed set (path-style here: // the endpoint authority as-is). const canonicalHeaders = "content-type:image/png\nhost:minio.example.test:9000\nx-amz-date:20250115T103000Z\n"; const canonicalRequest = [ "PUT", "/factory-files/workspaces/wsp_1/files/file_1/v1/hello%20world.png", "X-Amz-Expires=900", canonicalHeaders, "content-type;host;x-amz-date", sha256Hex("hello"), ].join("\n"); expect(s.canonicalRequest).toBe(canonicalRequest); const scope = "20250115/us-east-1/s3/aws4_request"; const stringToSign = [ "AWS4-HMAC-SHA256", "20250115T103000Z", scope, sha256Hex(canonicalRequest), ].join("\n"); expect(s.stringToSign).toBe(stringToSign); // Independent HMAC chain (fresh createHmac calls, byte keys). const secret = CONFIG.secretAccessKey; const kDate = createHmac("sha256", `AWS4${secret}`).update("20250115").digest(); const kRegion = createHmac("sha256", kDate).update("us-east-1").digest(); const kService = createHmac("sha256", kRegion).update("s3").digest(); const kSigning = createHmac("sha256", kService).update("aws4_request").digest(); const expected = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); expect(s.signature).toBe(expected); expect(s.headers["Authorization"]).toBe( `AWS4-HMAC-SHA256 Credential=${CONFIG.accessKeyId}/${scope}, ` + `SignedHeaders=content-type;host;x-amz-date, Signature=${expected}`, ); }); it("different now → different signature", () => { const later = new Date("2025-01-15T11:30:00.000Z"); const a = signS3Request(opts); const b = signS3Request({ ...opts, now: later, headers: { ...opts.headers, "x-amz-date": amzDate(later) }, }); expect(a.signature).not.toBe(b.signature); }); it("different payload hash → different signature", () => { const a = signS3Request(opts); const b = signS3Request({ ...opts, payloadHash: sha256Hex("other") }); expect(a.signature).not.toBe(b.signature); }); it("forces x-amz-date + x-amz-content-sha256 when alwaysSignContentSha (default)", () => { const s = signS3Request({ ...opts, alwaysSignContentSha: true }); expect(s.headers["x-amz-date"]).toBe(amzDate(NOW)); expect(s.headers["x-amz-content-sha256"]).toBe(sha256Hex("hello")); expect(s.headers["Authorization"]).toMatch(/SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date/); }); }); describe("objectRequestParts (path-style vs virtual-host)", () => { it("path-style: bucket in path", () => { const { url, path } = objectRequestParts(CONFIG, "a/b.png"); expect(path).toBe("/factory-files/a/b.png"); expect(url).toBe("https://minio.example.test:9000/factory-files/a/b.png"); }); it("virtual-host: bucket in host (not in canonical path)", () => { const aws: S3Config = { ...CONFIG, forcePathStyle: false }; const { url, path } = objectRequestParts(aws, "a b/c.png"); // `path` is the RAW path — the signers encode it once for the canonical // URI (and the URL below encodes it for the wire). A pre-encoded path // would be double-encoded by the signers (`%20` → `%2520`) and rejected // by S3/MinIO with SignatureDoesNotMatch. expect(path).toBe("/a b/c.png"); expect(url).toBe("https://factory-files.minio.example.test:9000/a%20b/c.png"); }); }); describe("objectRequestParts — keys with special characters (SigV4 double-encoding regression)", () => { // The `ARL Budget 208384.xlsx` 403: a key containing a space used to be // pre-encoded by objectRequestParts and re-encoded by the signers, so the // signature was computed over `%2520` while the wire carried `%20`. const KEY = "files/wsp_1/file_1/1/ARL Budget 208384.xlsx"; it("path is RAW, url is single-encoded (identical encoding for both)", () => { const { url, path } = objectRequestParts(CONFIG, KEY); expect(path).toBe(`/factory-files/${KEY}`); expect(url).toBe(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx`); expect(path).not.toContain("%"); expect(url).not.toContain("%25"); }); it("presignPut signs the SAME path the URL carries (independent recomputation)", () => { const { path } = objectRequestParts(CONFIG, KEY); const p = presignPut({ config: CONFIG, key: KEY, contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", expiresSec: 900, now: NOW }); // The URL path must be the raw path encoded exactly once. expect(p.url.startsWith(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx?`)).toBe(true); expect(p.url).not.toContain("%2520"); // Independent AWS-correct recomputation: canonical URI = the wire path // decoded, then re-encoded (what S3/MinIO do when verifying). const wirePath = new URL(p.url).pathname; const canonicalUri = wirePath .split("/") .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false))) .join("/"); // The signer's canonical URI (raw path → encodePath) must agree with it. expect(encodePath(path)).toBe(canonicalUri); expect(canonicalUri).toContain("ARL%20Budget%20208384.xlsx"); expect(canonicalUri).not.toContain("%2520"); }); it("presignGet: same single-encoding guarantee for the download URL", () => { const g = presignGet({ config: CONFIG, key: KEY, expiresSec: 600, now: NOW }); expect(g.url.startsWith(`https://minio.example.test:9000/factory-files/files/wsp_1/file_1/1/ARL%20Budget%20208384.xlsx?`)).toBe(true); expect(g.url).not.toContain("%2520"); const canonicalUri = new URL(g.url).pathname .split("/") .map((seg, i) => (i === 0 ? "" : uriEncode(decodeURIComponent(seg), false))) .join("/"); expect(canonicalUri).toContain("ARL%20Budget%20208384.xlsx"); }); }); describe("encodePath", () => { it("encodes each segment once, keeps / separators", () => { expect(encodePath("/factory-files/a b/c.png")).toBe("/factory-files/a%20b/c.png"); expect(encodePath("/")).toBe("/"); expect(encodePath("/factory-files/héllo.pdf")).toBe("/factory-files/h%C3%A9llo.pdf"); }); }); describe("presignPut / presignGet", () => { it("presignPut → browser-usable query-signed URL (no Authorization header)", () => { const p = presignPut({ config: CONFIG, key: "workspaces/wsp_1/files/file_1/v1/a.png", contentType: "image/png", expiresSec: 900, now: NOW, }); expect(p.method).toBe("PUT"); expect(p.expiresInSec).toBe(900); // Credentials in the URL, not in headers. expect(p.url).toContain("X-Amz-Algorithm=AWS4-HMAC-SHA256"); // The URL carries the STRICT canonical form (same encoding as the // signature — AWS-SDK style): `/` → %2F in the credential scope. expect(p.url).toContain("X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250115%2Fus-east-1%2Fs3%2Faws4_request"); expect(p.url).toContain("X-Amz-Date=20250115T103000Z"); expect(p.url).toContain("X-Amz-Expires=900"); expect(p.url).toContain("X-Amz-SignedHeaders=content-type%3Bhost"); expect(p.url).toMatch(/X-Amz-Signature=[0-9a-f]{64}/); expect(p.url.startsWith("https://minio.example.test:9000/factory-files/workspaces/wsp_1/files/file_1/v1/a.png?")).toBe(true); expect(p.headers["Authorization"]).toBeUndefined(); expect(p.headers["content-type"]).toBe("image/png"); expect(p.headers["x-amz-date"]).toBeUndefined(); // travels in the query expect(p.headers["host"]).toBeUndefined(); // client-managed header — never returned }); it("presignPut signature matches an INDEPENDENT recomputation", () => { const p = presignPut({ config: CONFIG, key: "workspaces/wsp_1/files/file_1/v1/a.png", contentType: "image/png", expiresSec: 900, now: NOW, }); const scope = "20250115/us-east-1/s3/aws4_request"; // Every value goes through the same RFC 3986 encoding the signer uses // (`/` → %2F in the credential scope, `;` → %3B in the signed headers). const canonicalQueryStr = [ "X-Amz-Algorithm=AWS4-HMAC-SHA256", `X-Amz-Credential=${uriEncode(`${CONFIG.accessKeyId}/${scope}`)}`, "X-Amz-Date=20250115T103000Z", "X-Amz-Expires=900", `X-Amz-SignedHeaders=${uriEncode("content-type;host")}`, ].join("&"); const canonicalRequest = [ "PUT", "/factory-files/workspaces/wsp_1/files/file_1/v1/a.png", canonicalQueryStr, "content-type:image/png\nhost:minio.example.test:9000\n", "content-type;host", "UNSIGNED-PAYLOAD", ].join("\n"); const stringToSign = [ "AWS4-HMAC-SHA256", "20250115T103000Z", scope, sha256Hex(canonicalRequest), ].join("\n"); const kDate = createHmac("sha256", `AWS4${CONFIG.secretAccessKey}`).update("20250115").digest(); const kRegion = createHmac("sha256", kDate).update("us-east-1").digest(); const kService = createHmac("sha256", kRegion).update("s3").digest(); const kSigning = createHmac("sha256", kService).update("aws4_request").digest(); const expected = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); expect(p.url).toContain(`X-Amz-Signature=${expected}`); }); it("presignGet → query-signed GET URL, no client headers", () => { const g = presignGet({ config: CONFIG, key: "workspaces/wsp_1/files/file_1/v1/a.png", expiresSec: 600, now: NOW, }); expect(g.method).toBe("GET"); expect(g.expiresInSec).toBe(600); expect(g.url).toContain("X-Amz-Expires=600"); expect(g.url).toContain("X-Amz-SignedHeaders=host"); expect(g.headers).toEqual({}); }); it("presignGet signs response-content-disposition (disposition binding)", () => { const a = presignGet({ config: CONFIG, key: "files/wsp_1/f_1/1/a b.png", expiresSec: 600, now: NOW, responseContentDisposition: 'attachment; filename="a b.png"', }); const b = presignGet({ config: CONFIG, key: "files/wsp_1/f_1/1/a b.png", expiresSec: 600, now: NOW, responseContentDisposition: 'attachment; filename="evil.png"', }); expect(a.url).toContain("response-content-disposition="); // Different disposition → different signature. expect(a.url).not.toBe(b.url); }); it("presignRequest determinism for a fixed now", () => { const args = { method: "PUT", config: CONFIG, path: "/factory-files/k/1.png", headers: { "content-type": "image/png" }, expiresSec: 900, now: NOW, }; const a1 = presignRequest(args); const a2 = presignRequest(args); expect(a1.url).toBe(a2.url); }); it("buildUrl appends canonical query", () => { expect(buildUrl(CONFIG, "/factory-files", { prefix: "a b", "max-keys": "10" })).toBe( "https://minio.example.test:9000/factory-files?max-keys=10&prefix=a%20b", ); }); });