@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +36 -0
- package/package.json +42 -10
- package/src/client.js +552 -0
- package/src/errors.js +49 -0
- package/src/signature.js +407 -0
- package/src/storage.js +404 -0
package/src/signature.js
ADDED
|
@@ -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
|
+
};
|