1import { describe, expect, it } from "vitest";
2import {
3 AWS_SHA256_HEX,
4 canonicalRequest,
5 computeSigV4Signature,
6 deriveSigningKey,
7 encodeKey,
8 sha256Hex,
9 s3Encode,
10 safeEqual,
11 type SigningInput
12} from "../lib/s3.js";
13
14
30
31const ACCESS_KEY = "AKIDEXAMPLE";
32const SECRET_KEY = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
33const AMZ_DATE = "20150830T123600Z";
34const ACCESS_DATE = "20150830";
35
36const baseInput: SigningInput = {
37 method: "GET",
38 host: "examplebucket.s3.amazonaws.com",
39 path: "/",
40 payloadHash: AWS_SHA256_HEX,
41 region: "us-east-1",
42 service: "s3",
43 accessKey: ACCESS_KEY,
44 secretKey: SECRET_KEY,
45 amzDate: AMZ_DATE,
46 accessDate: ACCESS_DATE
47};
48
49describe("canonical request (format pinned to AWS docs)", () => {
50 it("GET / with host + x-amz-date produces the documented layout", () => {
51 const req = canonicalRequest({
52 method: "GET",
53 path: "/",
54 headers: {
55 host: "examplebucket.s3.amazonaws.com",
56 "x-amz-date": AMZ_DATE
57 },
58 payloadHash: AWS_SHA256_HEX
59 });
60 expect(req).toBe(
61 [
62 "GET",
63 "/",
64 "",
65 "host:examplebucket.s3.amazonaws.com",
66 `x-amz-date:${AMZ_DATE}`,
67 "",
68 "host;x-amz-date",
69 AWS_SHA256_HEX
70 ].join("\n")
71 );
72 });
73
74 it("sorts query params (after encoding) and joins with &", () => {
75 const req = canonicalRequest({
76 method: "GET",
77 path: "/b/k",
78 query: { "X-Amz-Expires": "300", "X-Amz-Algorithm": "AWS4-HMAC-SHA256" },
79 headers: { host: "h", "x-amz-date": AMZ_DATE },
80 payloadHash: AWS_SHA256_HEX
81 });
82 const lines = req.split("\n");
83 expect(lines[0]).toBe("GET");
84 expect(lines[1]).toBe("/b/k");
85 expect(lines[2]).toBe("X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300");
86 expect(lines[lines.length - 2]).toBe("host;x-amz-date");
87 expect(lines[lines.length - 1]).toBe(AWS_SHA256_HEX);
88 });
89
90 it("lowercases and sorts header names; includes all signed headers", () => {
91 const req = canonicalRequest({
92 method: "PUT",
93 path: "/b/key",
94 headers: {
95 host: "h",
96 "X-Amz-Date": AMZ_DATE,
97 "Content-Type": "application/json"
98 },
99 payloadHash: "abc"
100 });
101 const lines = req.split("\n");
102
103 expect(lines[3]).toBe("content-type:application/json");
104 expect(lines[4]).toBe("host:h");
105 expect(lines[5]).toBe(`x-amz-date:${AMZ_DATE}`);
106 expect(lines[lines.length - 2]).toBe("content-type;host;x-amz-date");
107 });
108});
109
110describe("signing primitives", () => {
111 it("sha256 of empty string is the known constant", () => {
112 expect(sha256Hex("")).toBe(
113 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
114 );
115 expect(AWS_SHA256_HEX).toBe(sha256Hex(""));
116 });
117
118 it("deriveSigningKey: stable 32-byte HMAC chain (AWS4+secret → date → region → service → aws4_request)", () => {
119 const k1 = deriveSigningKey(SECRET_KEY, ACCESS_DATE, "us-east-1", "s3");
120 const k2 = deriveSigningKey(SECRET_KEY, ACCESS_DATE, "us-east-1", "s3");
121 expect(k1.length).toBe(32);
122 expect(k1.equals(k2)).toBe(true);
123
124 expect(k1.equals(deriveSigningKey(SECRET_KEY, ACCESS_DATE, "eu-west-1", "s3"))).toBe(false);
125
126 expect(k1.equals(deriveSigningKey(SECRET_KEY + "x", ACCESS_DATE, "us-east-1", "s3"))).toBe(false);
127 });
128
129 it("s3Encode: RFC 3986 strict encoding (uppercase hex, keeps unreserved)", () => {
130 expect(s3Encode("a b")).toBe("a%20b");
131 expect(s3Encode("a/b")).toBe("a%2Fb");
132 expect(s3Encode("a-b_c.d~e")).toBe("a-b_c.d~e");
133 expect(s3Encode("€")).toBe("%E2%82%AC");
134 });
135
136 it("encodeKey: keeps path separators, encodes each segment", () => {
137 expect(encodeKey("ws-1/files/my file.png")).toBe("ws-1/files/my%20file.png");
138 expect(encodeKey("plain")).toBe("plain");
139 });
140
141 it("safeEqual: constant-time compare, length-safe", () => {
142 expect(safeEqual("abc", "abc")).toBe(true);
143 expect(safeEqual("abc", "abd")).toBe(false);
144 expect(safeEqual("abc", "abcd")).toBe(false);
145 expect(safeEqual("", "")).toBe(true);
146 });
147});
148
149describe("signature — testable contract", () => {
150 const sigOf = (over: Partial<typeof baseInput> = {}): string =>
151 computeSigV4Signature({ ...baseInput, ...over }).signature;
152
153 it("is a 64-char lowercase hex string", () => {
154 expect(sigOf()).toMatch(/^[0-9a-f]{64}$/);
155 });
156
157 it("is deterministic: same input → same signature", () => {
158 expect(sigOf()).toBe(sigOf());
159 expect(computeSigV4Signature(baseInput).credentialScope).toBe(
160 `${ACCESS_DATE}/us-east-1/s3/aws4_request`
161 );
162 });
163
164 it("changes when the amz-date changes", () => {
165 expect(sigOf({ amzDate: "20150830T123601Z" })).not.toBe(sigOf());
166 });
167
168 it("changes when the access date changes", () => {
169 expect(sigOf({ accessDate: "20150831" })).not.toBe(sigOf());
170 });
171
172 it("changes when the secret key changes", () => {
173 expect(sigOf({ secretKey: SECRET_KEY + "x" })).not.toBe(sigOf());
174 });
175
176 it("changes when the payload hash changes", () => {
177 expect(sigOf({ payloadHash: sha256Hex("some bytes") })).not.toBe(sigOf());
178 });
179
180 it("changes when the method changes", () => {
181 expect(sigOf({ method: "PUT" })).not.toBe(sigOf());
182 expect(sigOf({ method: "DELETE" })).not.toBe(sigOf());
183 });
184
185 it("changes when the path changes", () => {
186 expect(sigOf({ path: "/b/other-key" })).not.toBe(sigOf());
187 });
188
189 it("changes when query params change", () => {
190 expect(sigOf({ query: { "x-id": "1" } })).not.toBe(sigOf());
191 expect(sigOf({ query: { "x-id": "2" } })).not.toBe(sigOf({ query: { "x-id": "1" } }));
192 });
193
194 it("changes when the region changes", () => {
195 expect(sigOf({ region: "eu-central-1" })).not.toBe(sigOf());
196 });
197
198 it("changes when an extra signed header is present", () => {
199 expect(sigOf({ headers: { "content-type": "application/json" } })).not.toBe(sigOf());
200 });
201});
202