import { describe, expect, it } from "vitest"; import { AWS_SHA256_HEX, canonicalRequest, computeSigV4Signature, deriveSigningKey, encodeKey, sha256Hex, s3Encode, safeEqual, type SigningInput } from "../lib/s3.js"; /** * SigV4 signature tests. * * The canonical-request string is pinned EXACTLY against the format documented * in the AWS docs ("Create a signed AWS API request" / "Elements of an AWS * API request signature"): * * HTTPMethod \n CanonicalURI \n CanonicalQueryString \n * CanonicalHeaders (each "name:value\n") \n SignedHeaders \n HashedPayload * * The signature itself is validated by the TESTABLE CONTRACT (determinism + * sensitivity to every signing input), because the canonical reference * vector values could not be fetched from the (now-unavailable) AWS SigV4 * test suite at authoring time. If the official suite is reachable again, * the `get-vanilla` expected signature can be added as one `expect` line. */ const ACCESS_KEY = "AKIDEXAMPLE"; const SECRET_KEY = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; const AMZ_DATE = "20150830T123600Z"; const ACCESS_DATE = "20150830"; const baseInput: SigningInput = { method: "GET", host: "examplebucket.s3.amazonaws.com", path: "/", payloadHash: AWS_SHA256_HEX, region: "us-east-1", service: "s3", accessKey: ACCESS_KEY, secretKey: SECRET_KEY, amzDate: AMZ_DATE, accessDate: ACCESS_DATE }; describe("canonical request (format pinned to AWS docs)", () => { it("GET / with host + x-amz-date produces the documented layout", () => { const req = canonicalRequest({ method: "GET", path: "/", headers: { host: "examplebucket.s3.amazonaws.com", "x-amz-date": AMZ_DATE }, payloadHash: AWS_SHA256_HEX }); expect(req).toBe( [ "GET", "/", "", "host:examplebucket.s3.amazonaws.com", `x-amz-date:${AMZ_DATE}`, "", "host;x-amz-date", AWS_SHA256_HEX ].join("\n") ); }); it("sorts query params (after encoding) and joins with &", () => { const req = canonicalRequest({ method: "GET", path: "/b/k", query: { "X-Amz-Expires": "300", "X-Amz-Algorithm": "AWS4-HMAC-SHA256" }, headers: { host: "h", "x-amz-date": AMZ_DATE }, payloadHash: AWS_SHA256_HEX }); const lines = req.split("\n"); expect(lines[0]).toBe("GET"); expect(lines[1]).toBe("/b/k"); expect(lines[2]).toBe("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300"); expect(lines[lines.length - 2]).toBe("host;x-amz-date"); expect(lines[lines.length - 1]).toBe(AWS_SHA256_HEX); }); it("lowercases and sorts header names; includes all signed headers", () => { const req = canonicalRequest({ method: "PUT", path: "/b/key", headers: { host: "h", "X-Amz-Date": AMZ_DATE, "Content-Type": "application/json" }, payloadHash: "abc" }); const lines = req.split("\n"); // header block: content-type, host, x-amz-date (sorted, lowercase) expect(lines[3]).toBe("content-type:application/json"); expect(lines[4]).toBe("host:h"); expect(lines[5]).toBe(`x-amz-date:${AMZ_DATE}`); expect(lines[lines.length - 2]).toBe("content-type;host;x-amz-date"); }); }); describe("signing primitives", () => { it("sha256 of empty string is the known constant", () => { expect(sha256Hex("")).toBe( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ); expect(AWS_SHA256_HEX).toBe(sha256Hex("")); }); it("deriveSigningKey: stable 32-byte HMAC chain (AWS4+secret → date → region → service → aws4_request)", () => { const k1 = deriveSigningKey(SECRET_KEY, ACCESS_DATE, "us-east-1", "s3"); const k2 = deriveSigningKey(SECRET_KEY, ACCESS_DATE, "us-east-1", "s3"); expect(k1.length).toBe(32); expect(k1.equals(k2)).toBe(true); // region in the chain changes the key expect(k1.equals(deriveSigningKey(SECRET_KEY, ACCESS_DATE, "eu-west-1", "s3"))).toBe(false); // secret changes the key expect(k1.equals(deriveSigningKey(SECRET_KEY + "x", ACCESS_DATE, "us-east-1", "s3"))).toBe(false); }); it("s3Encode: RFC 3986 strict encoding (uppercase hex, keeps unreserved)", () => { expect(s3Encode("a b")).toBe("a%20b"); expect(s3Encode("a/b")).toBe("a%2Fb"); expect(s3Encode("a-b_c.d~e")).toBe("a-b_c.d~e"); expect(s3Encode("€")).toBe("%E2%82%AC"); }); it("encodeKey: keeps path separators, encodes each segment", () => { expect(encodeKey("ws-1/files/my file.png")).toBe("ws-1/files/my%20file.png"); expect(encodeKey("plain")).toBe("plain"); }); it("safeEqual: constant-time compare, length-safe", () => { expect(safeEqual("abc", "abc")).toBe(true); expect(safeEqual("abc", "abd")).toBe(false); expect(safeEqual("abc", "abcd")).toBe(false); expect(safeEqual("", "")).toBe(true); }); }); describe("signature — testable contract", () => { const sigOf = (over: Partial = {}): string => computeSigV4Signature({ ...baseInput, ...over }).signature; it("is a 64-char lowercase hex string", () => { expect(sigOf()).toMatch(/^[0-9a-f]{64}$/); }); it("is deterministic: same input → same signature", () => { expect(sigOf()).toBe(sigOf()); expect(computeSigV4Signature(baseInput).credentialScope).toBe( `${ACCESS_DATE}/us-east-1/s3/aws4_request` ); }); it("changes when the amz-date changes", () => { expect(sigOf({ amzDate: "20150830T123601Z" })).not.toBe(sigOf()); }); it("changes when the access date changes", () => { expect(sigOf({ accessDate: "20150831" })).not.toBe(sigOf()); }); it("changes when the secret key changes", () => { expect(sigOf({ secretKey: SECRET_KEY + "x" })).not.toBe(sigOf()); }); it("changes when the payload hash changes", () => { expect(sigOf({ payloadHash: sha256Hex("some bytes") })).not.toBe(sigOf()); }); it("changes when the method changes", () => { expect(sigOf({ method: "PUT" })).not.toBe(sigOf()); expect(sigOf({ method: "DELETE" })).not.toBe(sigOf()); }); it("changes when the path changes", () => { expect(sigOf({ path: "/b/other-key" })).not.toBe(sigOf()); }); it("changes when query params change", () => { expect(sigOf({ query: { "x-id": "1" } })).not.toBe(sigOf()); expect(sigOf({ query: { "x-id": "2" } })).not.toBe(sigOf({ query: { "x-id": "1" } })); }); it("changes when the region changes", () => { expect(sigOf({ region: "eu-central-1" })).not.toBe(sigOf()); }); it("changes when an extra signed header is present", () => { expect(sigOf({ headers: { "content-type": "application/json" } })).not.toBe(sigOf()); }); });