Skip to content
124 linesCodeBlameRaw
1/**
2 * AWS Signature Version 4, by hand with WebCrypto: how a self-hosted g1t
3 * signs requests to an S3-compatible store (MinIO, Ceph, Garage, AWS S3,
4 * R2's S3 API) without an SDK. Checked against AWS's published test
5 * vectors (sigv4.test.ts). Reference:
6 * https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
7 */
8
9export type Credentials = { access_key_id: string; secret_access_key: string };
10
11export type SignInput = {
12 method: string;
13 url: string;
14 /** Headers to sign besides `host` and `x-amz-date` (which are added). */
15 headers?: Record<string, string>;
16 /** The payload's SHA-256 in hex, or `UNSIGNED-PAYLOAD`. */
17 payload_hash: string;
18 region: string;
19 service: string;
20 credentials: Credentials;
21 /** When it is signed; now by default. */
22 date?: Date;
23 /**
24 * Whether path segments are encoded a second time, as every AWS service
25 * but S3 expects. S3 signs the path as sent.
26 */
27 double_encode_path?: boolean;
28};
29
30export type Signed = {
31 /** Every header to send, `authorization` included (lowercase names). */
32 headers: Record<string, string>;
33 canonical_request: string;
34 string_to_sign: string;
35 signature: string;
36};
37
38const encoder = new TextEncoder();
39
40function hex(bytes: ArrayBuffer): string {
41 return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
42}
43
44export async function sha256Hex(data: string | ArrayBuffer | Uint8Array): Promise<string> {
45 const bytes = typeof data === "string" ? encoder.encode(data) : data;
46 return hex(await crypto.subtle.digest("SHA-256", bytes as BufferSource));
47}
48
49async function hmac(key: ArrayBuffer | Uint8Array, data: string): Promise<ArrayBuffer> {
50 const k = await crypto.subtle.importKey("raw", key as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
51 return crypto.subtle.sign("HMAC", k, encoder.encode(data));
52}
53
54/** RFC 3986 encoding, as SigV4 wants it: unreserved characters stay, everything else is %XX in upper case. */
55export function uriEncode(value: string): string {
56 return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
57}
58
59/** `20150830T123600Z`. */
60export function amzDate(date: Date): string {
61 return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
62}
63
64function canonicalPath(pathname: string, double: boolean): string {
65 if (!pathname || pathname === "/") return "/";
66 return pathname
67 .split("/")
68 .map((segment) => {
69 let raw: string;
70 try {
71 raw = decodeURIComponent(segment);
72 } catch {
73 raw = segment;
74 }
75 const once = uriEncode(raw);
76 return double ? uriEncode(once) : once;
77 })
78 .join("/");
79}
80
81function canonicalQuery(search: URLSearchParams): string {
82 return [...search.entries()]
83 .map(([k, v]) => [uriEncode(k), uriEncode(v)] as const)
84 .sort(([ak, av], [bk, bv]) => (ak < bk ? -1 : ak > bk ? 1 : av < bv ? -1 : av > bv ? 1 : 0))
85 .map(([k, v]) => `${k}=${v}`)
86 .join("&");
87}
88
89/** Signs a request: the headers to send with it, and what was signed (for tests and debugging). */
90export async function sign(input: SignInput): Promise<Signed> {
91 const url = new URL(input.url);
92 const when = amzDate(input.date ?? new Date());
93 const day = when.slice(0, 8);
94 const headers: Record<string, string> = {};
95 for (const [k, v] of Object.entries(input.headers ?? {})) headers[k.toLowerCase()] = String(v).trim().replace(/\s+/g, " ");
96 headers.host = url.host;
97 headers["x-amz-date"] = when;
98 const names = Object.keys(headers).sort();
99 const signedHeaders = names.join(";");
100 const canonicalRequest = [
101 input.method.toUpperCase(),
102 canonicalPath(url.pathname, input.double_encode_path ?? input.service !== "s3"),
103 canonicalQuery(url.searchParams),
104 names.map((n) => `${n}:${headers[n]}\n`).join(""),
105 signedHeaders,
106 input.payload_hash,
107 ].join("\n");
108 const scope = `${day}/${input.region}/${input.service}/aws4_request`;
109 const stringToSign = ["AWS4-HMAC-SHA256", when, scope, await sha256Hex(canonicalRequest)].join("\n");
110 const kDate = await hmac(encoder.encode(`AWS4${input.credentials.secret_access_key}`), day);
111 const kRegion = await hmac(kDate, input.region);
112 const kService = await hmac(kRegion, input.service);
113 const kSigning = await hmac(kService, "aws4_request");
114 const signature = hex(await hmac(kSigning, stringToSign));
115 return {
116 headers: {
117 ...headers,
118 authorization: `AWS4-HMAC-SHA256 Credential=${input.credentials.access_key_id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
119 },
120 canonical_request: canonicalRequest,
121 string_to_sign: stringToSign,
122 signature,
123 };
124}