@usehenri/s3 0.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * AWS Signature Version 4, the two forms an object store needs.
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * Why this is written here rather than installed
6
+ * ---------------------------------------------------------------------------
7
+ *
8
+ * `@aws-sdk/client-s3` is a hundred packages, a credential provider chain, a
9
+ * middleware stack and a retry policy, and it exists to speak all of S3 --
10
+ * multipart uploads, inventories, lifecycle rules, replication. This package
11
+ * needs five verbs (`PUT`, `GET`, `HEAD`, `DELETE` and a presigned `GET`) on
12
+ * one bucket, against whichever endpoint the application named. What stands
13
+ * between those five verbs and the network is one signature, and it is the
14
+ * same shape `@usehenri/webhooks` already writes by hand for Standard
15
+ * Webhooks: a canonical string, an HMAC chain and a header.
16
+ *
17
+ * So it is written out, under two hundred lines, with `node:crypto` and
18
+ * nothing else -- and checked against the vectors AWS publishes
19
+ * (`__tests__/signature.spec.js`), which is a stronger statement about
20
+ * correctness than "the SDK was imported".
21
+ *
22
+ * ---------------------------------------------------------------------------
23
+ * The two forms
24
+ * ---------------------------------------------------------------------------
25
+ *
26
+ * **Header signing** (`sign()`) is what a request the application makes
27
+ * carries: the signature goes in `Authorization`, and it covers the method,
28
+ * the path, the query, a named list of headers -- including
29
+ * `x-amz-content-sha256`, the digest of the body -- and the moment. The body
30
+ * is therefore signed: a proxy that changed one byte of an upload invalidates
31
+ * it.
32
+ *
33
+ * **Query signing** (`presign()`) is what a browser is handed: the same
34
+ * canonical request, with the parameters in the query string instead of in
35
+ * headers and `UNSIGNED-PAYLOAD` where the body digest would be, because
36
+ * there is no body to a `GET`. `X-Amz-Expires` is inside the signed string,
37
+ * so the window is not editable, and so is the path, so the url of one
38
+ * object is not the url of another.
39
+ */
40
+ const crypto = require('node:crypto');
41
+
42
+ /** The algorithm, as it appears in every string this file builds */
43
+ const ALGORITHM = 'AWS4-HMAC-SHA256';
44
+
45
+ /** The terminator of a credential scope */
46
+ const TERMINATOR = 'aws4_request';
47
+
48
+ /** The service every signature here is for */
49
+ const SERVICE = 's3';
50
+
51
+ /** What a payload hash says when there is nothing to hash */
52
+ const UNSIGNED = 'UNSIGNED-PAYLOAD';
53
+
54
+ /** The sha256 of zero bytes, which is what an empty body hashes to */
55
+ const EMPTY =
56
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
57
+
58
+ /**
59
+ * The longest window a presigned url may be given.
60
+ *
61
+ * Seven days is not a policy of henri's, it is the number S3 refuses past:
62
+ * a signing key's scope is a date, and the provider will not honour one
63
+ * older than that. Asking for more is a mistake worth naming rather than a
64
+ * url that stops working next week for no visible reason.
65
+ */
66
+ const MAX_EXPIRES = 7 * 24 * 60 * 60;
67
+
68
+ /**
69
+ * RFC 3986 percent encoding, which is not `encodeURIComponent`.
70
+ *
71
+ * `encodeURIComponent` leaves `!`, `'`, `(`, `)` and `*` alone; AWS's
72
+ * canonical form does not, and a key holding one of them would sign one
73
+ * string and be requested as another. The unreserved set is exactly
74
+ * `A-Za-z0-9-_.~`, plus `/` when a path is being encoded rather than a
75
+ * value.
76
+ *
77
+ * @param {string} value what to encode
78
+ * @param {boolean} [slashes=false] true to leave `/` alone (a path)
79
+ * @returns {string} the encoded value
80
+ */
81
+ function encode(value, slashes = false) {
82
+ const encoded = encodeURIComponent(String(value)).replace(
83
+ /[!'()*]/gu,
84
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
85
+ );
86
+
87
+ return slashes ? encoded.replace(/%2F/gu, '/') : encoded;
88
+ }
89
+
90
+ /**
91
+ * The two timestamps a signature is built from: `20130524T000000Z` and the
92
+ * `20130524` its scope is dated with
93
+ *
94
+ * @param {Date} [now=new Date()] the moment
95
+ * @returns {{date: string, stamp: string}} the two forms
96
+ */
97
+ function moment(now = new Date()) {
98
+ const stamp = now.toISOString().replace(/[:-]|\.\d{3}/gu, '');
99
+
100
+ return { date: stamp, stamp: stamp.slice(0, 8) };
101
+ }
102
+
103
+ /**
104
+ * An HMAC-SHA256, as bytes
105
+ *
106
+ * @param {(Buffer|string)} key the key
107
+ * @param {string} value what to sign
108
+ * @returns {Buffer} the digest
109
+ */
110
+ const hmac = (key, value) =>
111
+ crypto.createHmac('sha256', key).update(value, 'utf8').digest();
112
+
113
+ /**
114
+ * A sha256, as lowercase hexadecimal
115
+ *
116
+ * @param {(Buffer|string)} value what to hash
117
+ * @returns {string} the digest
118
+ */
119
+ const sha256 = (value) =>
120
+ crypto.createHash('sha256').update(value).digest('hex');
121
+
122
+ /**
123
+ * The key a request of this day, region and service is signed with.
124
+ *
125
+ * Four HMACs, each one keyed by the last: the date, then the region, then
126
+ * the service, then the terminator. The result is what the signature is
127
+ * computed with, and it is why a signature made for one day cannot be
128
+ * replayed against another -- the key itself is dated.
129
+ *
130
+ * @param {string} secret the secret access key
131
+ * @param {string} stamp the date, as `20130524`
132
+ * @param {string} region the region
133
+ * @returns {Buffer} the signing key
134
+ */
135
+ const signingKey = (secret, stamp, region) =>
136
+ hmac(hmac(hmac(hmac(`AWS4${secret}`, stamp), region), SERVICE), TERMINATOR);
137
+
138
+ /**
139
+ * Sorts two `[name, value]` pairs by name, and by value when the names are
140
+ * equal.
141
+ *
142
+ * By code unit, not by `localeCompare`: the canonical form is defined in
143
+ * bytes, and a locale-aware comparison puts `response-content-disposition`
144
+ * before `X-Amz-Algorithm` because it ignores case. That is a signature of a
145
+ * different request, and the only place it shows is a provider answering
146
+ * `SignatureDoesNotMatch` for a url that looks perfectly reasonable.
147
+ *
148
+ * @param {Array<string>} one the first pair
149
+ * @param {Array<string>} two the second
150
+ * @returns {number} the order
151
+ */
152
+ function byteOrder(one, two) {
153
+ if (one[0] !== two[0]) {
154
+ return one[0] < two[0] ? -1 : 1;
155
+ }
156
+
157
+ if (one[1] === two[1]) {
158
+ return 0;
159
+ }
160
+
161
+ return one[1] < two[1] ? -1 : 1;
162
+ }
163
+
164
+ /**
165
+ * The canonical query string: every parameter encoded and sorted by name.
166
+ *
167
+ * Sorted by the *encoded* name and, for a repeated name, by the encoded
168
+ * value, because that is the order the other side sorts them in -- and the
169
+ * two have to agree byte for byte or the signature is of a different
170
+ * request.
171
+ *
172
+ * @param {object} query the parameters
173
+ * @returns {string} the canonical query string
174
+ */
175
+ function canonicalQuery(query) {
176
+ const pairs = [];
177
+
178
+ for (const [name, value] of Object.entries(query)) {
179
+ if (value === undefined || value === null) {
180
+ continue;
181
+ }
182
+
183
+ for (const item of Array.isArray(value) ? value : [value]) {
184
+ pairs.push([encode(name), encode(item)]);
185
+ }
186
+ }
187
+
188
+ pairs.sort(byteOrder);
189
+
190
+ return pairs.map(([name, value]) => `${name}=${value}`).join('&');
191
+ }
192
+
193
+ /**
194
+ * The canonical headers and the list of their names
195
+ *
196
+ * @param {object} headers the headers
197
+ * @returns {{canonical: string, signed: string}} the two halves
198
+ */
199
+ function canonicalHeaders(headers) {
200
+ const entries = Object.entries(headers)
201
+ .filter(([, value]) => value !== undefined && value !== null)
202
+ .map(([name, value]) => [
203
+ name.toLowerCase(),
204
+ String(value).trim().replace(/\s+/gu, ' '),
205
+ ])
206
+ .sort(byteOrder);
207
+
208
+ return {
209
+ canonical: entries.map(([name, value]) => `${name}:${value}\n`).join(''),
210
+ signed: entries.map(([name]) => name).join(';'),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The canonical request: the five lines and the payload digest that every
216
+ * signature here is ultimately of
217
+ *
218
+ * @param {object} options the request
219
+ * @param {string} options.method the method
220
+ * @param {string} options.path the path, already `/`-separated
221
+ * @param {object} options.query the query parameters
222
+ * @param {object} options.headers the headers to sign
223
+ * @param {string} options.payload the payload digest, or `UNSIGNED-PAYLOAD`
224
+ * @returns {{request: string, signed: string}} the canonical request and the
225
+ * list of header names it covers
226
+ */
227
+ function canonicalRequest({ headers, method, path, payload, query }) {
228
+ const { canonical, signed } = canonicalHeaders(headers);
229
+
230
+ return {
231
+ request: [
232
+ method.toUpperCase(),
233
+ encode(path, true),
234
+ canonicalQuery(query),
235
+ canonical,
236
+ signed,
237
+ payload,
238
+ ].join('\n'),
239
+ signed,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * The string a signature is of: the algorithm, the moment, the scope and the
245
+ * digest of the canonical request
246
+ *
247
+ * @param {string} date the timestamp, as `20130524T000000Z`
248
+ * @param {string} scope the credential scope
249
+ * @param {string} request the canonical request
250
+ * @returns {string} the string to sign
251
+ */
252
+ const stringToSign = (date, scope, request) =>
253
+ [ALGORITHM, date, scope, sha256(request)].join('\n');
254
+
255
+ /**
256
+ * Signs a request by adding an `Authorization` header to it.
257
+ *
258
+ * The body digest is signed too, which is why `payload` is required rather
259
+ * than defaulted to `UNSIGNED-PAYLOAD`: an upload whose bytes are not
260
+ * covered by the signature is an upload a proxy can rewrite.
261
+ *
262
+ * @param {object} options the request
263
+ * @param {object} options.credentials `{ accessKeyId, secretAccessKey, sessionToken }`
264
+ * @param {string} options.region the region
265
+ * @param {string} options.method the method
266
+ * @param {string} options.host the host header
267
+ * @param {string} options.path the path
268
+ * @param {object} [options.query={}] the query parameters
269
+ * @param {object} [options.headers={}] the headers to sign, beyond host and date
270
+ * @param {string} options.payload the sha256 of the body, hex, or `UNSIGNED-PAYLOAD`
271
+ * @param {Date} [options.now=new Date()] the moment
272
+ * @returns {object} every header the request must carry, `Authorization` included
273
+ */
274
+ function sign({
275
+ credentials,
276
+ headers = {},
277
+ host,
278
+ method,
279
+ now = new Date(),
280
+ path,
281
+ payload,
282
+ query = {},
283
+ region,
284
+ }) {
285
+ const { date, stamp } = moment(now);
286
+ const scope = `${stamp}/${region}/${SERVICE}/${TERMINATOR}`;
287
+ const full = Object.assign({}, headers, {
288
+ host,
289
+ 'x-amz-content-sha256': payload,
290
+ 'x-amz-date': date,
291
+ });
292
+
293
+ if (credentials.sessionToken) {
294
+ full['x-amz-security-token'] = credentials.sessionToken;
295
+ }
296
+
297
+ const { request, signed } = canonicalRequest({
298
+ headers: full,
299
+ method,
300
+ path,
301
+ payload,
302
+ query,
303
+ });
304
+ const signature = hmac(
305
+ signingKey(credentials.secretAccessKey, stamp, region),
306
+ stringToSign(date, scope, request)
307
+ ).toString('hex');
308
+
309
+ full.authorization = `${ALGORITHM} Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${signature}`;
310
+
311
+ return full;
312
+ }
313
+
314
+ /**
315
+ * A presigned url: the same canonical request with the credentials in the
316
+ * query string.
317
+ *
318
+ * What the signature covers, and therefore what cannot be changed after the
319
+ * fact: the method (`GET`), the host, the path -- so a url for one key is
320
+ * not a url for another -- every query parameter, `X-Amz-Date` and
321
+ * `X-Amz-Expires` -- so the window cannot be widened or moved -- and the
322
+ * credential scope, which is dated. Editing any of them changes the
323
+ * canonical request, and the provider computes the signature of what it
324
+ * received rather than trusting what it was told.
325
+ *
326
+ * @param {object} options the request
327
+ * @param {object} options.credentials `{ accessKeyId, secretAccessKey, sessionToken }`
328
+ * @param {string} options.region the region
329
+ * @param {string} [options.method='GET'] the method
330
+ * @param {string} options.origin the scheme and host (`https://bucket.s3.amazonaws.com`)
331
+ * @param {string} options.host the host header, which is what is signed
332
+ * @param {string} options.path the path
333
+ * @param {object} [options.query={}] the query parameters to sign as well
334
+ * @param {number} options.expiresIn how many seconds the url is good for
335
+ * @param {Date} [options.now=new Date()] the moment
336
+ * @returns {string} the url
337
+ * @throws {RangeError} when the window is longer than the provider honours
338
+ */
339
+ function presign({
340
+ credentials,
341
+ expiresIn,
342
+ host,
343
+ method = 'GET',
344
+ now = new Date(),
345
+ origin,
346
+ path,
347
+ query = {},
348
+ region,
349
+ }) {
350
+ const seconds = Math.floor(Number(expiresIn));
351
+
352
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > MAX_EXPIRES) {
353
+ throw new RangeError(
354
+ `a presigned url lasts between 1 and ${MAX_EXPIRES} seconds, not ${expiresIn}`
355
+ );
356
+ }
357
+
358
+ const { date, stamp } = moment(now);
359
+ const scope = `${stamp}/${region}/${SERVICE}/${TERMINATOR}`;
360
+ const parameters = Object.assign({}, query, {
361
+ 'X-Amz-Algorithm': ALGORITHM,
362
+ 'X-Amz-Credential': `${credentials.accessKeyId}/${scope}`,
363
+ 'X-Amz-Date': date,
364
+ 'X-Amz-Expires': String(seconds),
365
+ 'X-Amz-SignedHeaders': 'host',
366
+ });
367
+
368
+ if (credentials.sessionToken) {
369
+ parameters['X-Amz-Security-Token'] = credentials.sessionToken;
370
+ }
371
+
372
+ const { request } = canonicalRequest({
373
+ headers: { host },
374
+ method,
375
+ path,
376
+ payload: UNSIGNED,
377
+ query: parameters,
378
+ });
379
+ const signature = hmac(
380
+ signingKey(credentials.secretAccessKey, stamp, region),
381
+ stringToSign(date, scope, request)
382
+ ).toString('hex');
383
+
384
+ return `${origin}${encode(path, true)}?${canonicalQuery(
385
+ parameters
386
+ )}&X-Amz-Signature=${signature}`;
387
+ }
388
+
389
+ module.exports = {
390
+ ALGORITHM,
391
+ EMPTY,
392
+ MAX_EXPIRES,
393
+ SERVICE,
394
+ TERMINATOR,
395
+ UNSIGNED,
396
+ byteOrder,
397
+ canonicalHeaders,
398
+ canonicalQuery,
399
+ canonicalRequest,
400
+ encode,
401
+ moment,
402
+ presign,
403
+ sha256,
404
+ sign,
405
+ signingKey,
406
+ stringToSign,
407
+ };