/
signing.ts
329 lines (308 loc) · 10.6 KB
/
signing.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import * as errors from "./errors.ts";
import { bin2hex, getScope, makeDateLong, makeDateShort, sha256digestHex } from "./helpers.ts";
const signV4Algorithm = "AWS4-HMAC-SHA256";
/**
* Generate the Authorization header required to authenticate an S3/AWS request.
*/
export async function signV4(request: {
headers: Headers;
method: string;
path: string;
accessKey: string;
secretKey: string;
region: string;
date: Date;
}): Promise<string> {
if (!request.accessKey) {
throw new errors.AccessKeyRequiredError("accessKey is required for signing");
}
if (!request.secretKey) {
throw new errors.SecretKeyRequiredError("secretKey is required for signing");
}
const sha256sum = request.headers.get("x-amz-content-sha256");
if (sha256sum === null) {
throw new Error(
"Internal S3 client error - expected x-amz-content-sha256 header, but it's missing.",
);
}
const signedHeaders = getHeadersToSign(request.headers);
const canonicalRequest = getCanonicalRequest(
request.method,
request.path,
request.headers,
signedHeaders,
sha256sum,
);
const stringToSign = await getStringToSign(
canonicalRequest,
request.date,
request.region,
);
const signingKey = await getSigningKey(
request.date,
request.region,
request.secretKey,
);
const credential = getCredential(
request.accessKey,
request.region,
request.date,
);
const signature = bin2hex(await sha256hmac(signingKey, stringToSign))
.toLowerCase();
return `${signV4Algorithm} Credential=${credential}, SignedHeaders=${
signedHeaders.join(";").toLowerCase()
}, Signature=${signature}`;
}
/**
* Generate a pre-signed URL
*/
export async function presignV4(request: {
protocol: "http:" | "https:";
headers: Headers;
method: string;
path: string;
accessKey: string;
secretKey: string;
region: string;
date: Date;
expirySeconds: number;
}): Promise<string> {
if (!request.accessKey) {
throw new errors.AccessKeyRequiredError("accessKey is required for signing");
}
if (!request.secretKey) {
throw new errors.SecretKeyRequiredError("secretKey is required for signing");
}
if (request.expirySeconds < 1) {
throw new errors.InvalidExpiryError("expirySeconds cannot be less than 1 seconds");
}
if (request.expirySeconds > 604800) {
throw new errors.InvalidExpiryError("expirySeconds cannot be greater than 7 days");
}
if (!request.headers.has("Host")) {
throw new Error("Internal error: host header missing");
}
// Information about the future request that we're going to sign:
const resource = request.path.split("?")[0];
const queryString = request.path.split("?")[1];
const iso8601Date = makeDateLong(request.date);
const signedHeaders = getHeadersToSign(request.headers);
const credential = getCredential(request.accessKey, request.region, request.date);
const hashedPayload = "UNSIGNED-PAYLOAD";
// Build the query string for our new signed URL:
const newQuery = new URLSearchParams(queryString);
newQuery.set("X-Amz-Algorithm", signV4Algorithm);
newQuery.set("X-Amz-Credential", credential);
newQuery.set("X-Amz-Date", iso8601Date);
newQuery.set("X-Amz-Expires", request.expirySeconds.toString());
newQuery.set("X-Amz-SignedHeaders", signedHeaders.join(";").toLowerCase());
const newPath = resource + "?" + newQuery.toString().replace("+", "%20"); // Signing requires spaces become %20, never +
const canonicalRequest = getCanonicalRequest(request.method, newPath, request.headers, signedHeaders, hashedPayload);
const stringToSign = await getStringToSign(canonicalRequest, request.date, request.region);
const signingKey = await getSigningKey(request.date, request.region, request.secretKey);
const signature = bin2hex(await sha256hmac(signingKey, stringToSign)).toLowerCase();
const presignedUrl = `${request.protocol}//${request.headers.get("Host")}${newPath}&X-Amz-Signature=${signature}`;
return presignedUrl;
}
/**
* Given the set of HTTP headers that we'll be sending with an S3/AWS request, determine which
* headers will be signed, and in what order.
*/
function getHeadersToSign(headers: Headers): string[] {
// Excerpts from @lsegal - https://github.com/aws/aws-sdk-js/issues/659#issuecomment-120477258
//
// User-Agent:
//
// This is ignored from signing because signing this causes problems with generating pre-signed URLs
// (that are executed by other agents) or when customers pass requests through proxies, which may
// modify the user-agent.
//
// Content-Length:
//
// This is ignored from signing because generating a pre-signed URL should not provide a content-length
// constraint, specifically when vending a S3 pre-signed PUT URL. The corollary to this is that when
// sending regular requests (non-pre-signed), the signature contains a checksum of the body, which
// implicitly validates the payload length (since changing the number of bytes would change the checksum)
// and therefore this header is not valuable in the signature.
//
// Content-Type:
//
// Signing this header causes quite a number of problems in browser environments, where browsers
// like to modify and normalize the content-type header in different ways. There is more information
// on this in https://github.com/aws/aws-sdk-js/issues/244. Avoiding this field simplifies logic
// and reduces the possibility of future bugs
//
// Authorization:
//
// Is skipped for obvious reasons
const ignoredHeaders = [
"authorization",
"content-length",
"content-type",
"user-agent",
];
const headersToSign = [];
for (const key of headers.keys()) {
if (ignoredHeaders.includes(key.toLowerCase())) {
continue; // Ignore this header
}
headersToSign.push(key);
}
headersToSign.sort();
return headersToSign;
}
const CODES = {
A: "A".charCodeAt(0),
Z: "Z".charCodeAt(0),
a: "a".charCodeAt(0),
z: "z".charCodeAt(0),
"0": "0".charCodeAt(0),
"9": "9".charCodeAt(0),
"/": "/".charCodeAt(0),
};
const ALLOWED_BYTES = "-._~".split("").map((s) => s.charCodeAt(0));
/**
* Canonical URI encoding for signing, per AWS documentation:
* 1. URI encode every byte except the unreserved characters:
* 'A'-'Z', 'a'-'z', '0'-'9', '-', '.', '_', and '~'.
* 2. The space character must be encoded as "%20" (and not as "+").
* 3. Each URI encoded byte is formed by a '%' and the
* two-digit uppercase hexadecimal value of the byte. e.g. "%1A".
* 4. Encode the forward slash character, '/', everywhere except
* in the object key name. For example, if the object key name
* is photos/Jan/sample.jpg, the forward slash in the key name
* is not encoded.
*
* See https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
*
* @param string the string to encode.
*/
function awsUriEncode(string: string, allowSlashes = false) {
const bytes: Uint8Array = new TextEncoder().encode(string);
let encoded = "";
for (const byte of bytes) {
if (
(byte >= CODES.A && byte <= CODES.Z) ||
(byte >= CODES.a && byte <= CODES.z) ||
(byte >= CODES["0"] && byte <= CODES["9"]) ||
(ALLOWED_BYTES.includes(byte)) ||
(byte == CODES["/"] && allowSlashes)
) {
encoded += String.fromCharCode(byte);
} else {
encoded += "%" + byte.toString(16).padStart(2, "0").toUpperCase();
}
}
return encoded;
}
/**
* getCanonicalRequest generate a canonical request of style.
*
* canonicalRequest =
* <HTTPMethod>\n
* <CanonicalURI>\n
* <CanonicalQueryString>\n
* <CanonicalHeaders>\n
* <SignedHeaders>\n
* <HashedPayload>
*/
function getCanonicalRequest(
method: string,
path: string,
headers: Headers,
headersToSign: string[],
payloadHash: string,
): string {
const headersArray = headersToSign.reduce<string[]>((acc, headerKey) => {
// Trim spaces from the value (required by V4 spec)
const val = `${headers.get(headerKey)}`.replace(/ +/g, " ");
acc.push(`${headerKey.toLowerCase()}:${val}`);
return acc;
}, []);
const requestResource = path.split("?")[0];
let requestQuery = path.split("?")[1];
if (requestQuery) {
requestQuery = requestQuery
.split("&")
.sort()
.map((element) => element.indexOf("=") === -1 ? element + "=" : element)
.join("&");
} else {
requestQuery = "";
}
const canonical = [];
canonical.push(method.toUpperCase());
canonical.push(awsUriEncode(requestResource, true));
canonical.push(requestQuery);
canonical.push(headersArray.join("\n") + "\n");
canonical.push(headersToSign.join(";").toLowerCase());
canonical.push(payloadHash);
return canonical.join("\n");
}
// returns the string that needs to be signed
async function getStringToSign(
canonicalRequest: string,
requestDate: Date,
region: string,
): Promise<string> {
const hash = await sha256digestHex(canonicalRequest);
const scope = getScope(region, requestDate);
const stringToSign = [];
stringToSign.push(signV4Algorithm);
stringToSign.push(makeDateLong(requestDate));
stringToSign.push(scope);
stringToSign.push(hash);
return stringToSign.join("\n");
}
/** returns the key used for calculating signature */
async function getSigningKey(
date: Date,
region: string,
secretKey: string,
): Promise<Uint8Array> {
const dateLine = makeDateShort(date);
const hmac1 = await sha256hmac("AWS4" + secretKey, dateLine);
const hmac2 = await sha256hmac(hmac1, region);
const hmac3 = await sha256hmac(hmac2, "s3");
return await sha256hmac(hmac3, "aws4_request");
}
/** generate a credential string */
function getCredential(accessKey: string, region: string, requestDate: Date) {
return `${accessKey}/${getScope(region, requestDate)}`;
}
/**
* Given a secret key and some data, generate a HMAC of the data using SHA-256.
* @param secretKey
* @param data
* @returns
*/
async function sha256hmac(
secretKey: Uint8Array | string,
data: Uint8Array | string,
): Promise<Uint8Array> {
const enc = new TextEncoder();
const keyObject = await crypto.subtle.importKey(
"raw", // raw format of the key - should be Uint8Array
secretKey instanceof Uint8Array ? secretKey : enc.encode(secretKey),
{ name: "HMAC", hash: { name: "SHA-256" } }, // algorithm
false, // export = false
["sign", "verify"], // what this key can do
);
const signature = await crypto.subtle.sign(
"HMAC",
keyObject,
data instanceof Uint8Array ? data : enc.encode(data),
);
return new Uint8Array(signature);
}
// Export for testing purposes only
export const _internalMethods = {
awsUriEncode,
getHeadersToSign,
getCanonicalRequest,
getStringToSign,
getSigningKey,
getCredential,
sha256hmac,
};