@felipedsvit/s3node 0.1.2
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/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/s3node.js +101 -0
- package/docs/plan.md +340 -0
- package/package.json +50 -0
- package/src/auth/chunked.js +277 -0
- package/src/auth/credentials.js +41 -0
- package/src/auth/sigv4.js +293 -0
- package/src/errors.js +65 -0
- package/src/features/cors.js +115 -0
- package/src/features/encryption.js +201 -0
- package/src/features/formdata.js +169 -0
- package/src/features/lifecycle.js +175 -0
- package/src/features/notifications.js +161 -0
- package/src/features/policy.js +0 -0
- package/src/features/postpolicy.js +150 -0
- package/src/features/tagging.js +64 -0
- package/src/handlers/bucket.js +197 -0
- package/src/handlers/config.js +129 -0
- package/src/handlers/index.js +6 -0
- package/src/handlers/multipart.js +109 -0
- package/src/handlers/object.js +211 -0
- package/src/handlers/post.js +106 -0
- package/src/handlers/service.js +10 -0
- package/src/handlers/shared.js +84 -0
- package/src/http.js +243 -0
- package/src/index.js +30 -0
- package/src/router.js +128 -0
- package/src/server.js +282 -0
- package/src/storage/blobs.js +135 -0
- package/src/storage/metadata.js +616 -0
- package/src/storage/store.js +548 -0
- package/src/util/bytes.js +35 -0
- package/src/util/crc.js +54 -0
- package/src/util/hash.js +64 -0
- package/src/xml.js +205 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { Transform } from 'node:stream'
|
|
3
|
+
import { S3Error } from '../errors.js'
|
|
4
|
+
import {
|
|
5
|
+
EMPTY_SHA256,
|
|
6
|
+
calculateSignature,
|
|
7
|
+
chunkStringToSign,
|
|
8
|
+
signaturesMatch,
|
|
9
|
+
trailerStringToSign,
|
|
10
|
+
} from './sigv4.js'
|
|
11
|
+
|
|
12
|
+
const CRLF = Buffer.from('\r\n')
|
|
13
|
+
const MAX_LINE_BYTES = 8192
|
|
14
|
+
const MAX_CHUNK_BYTES = 1024 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Strips `aws-chunked` framing from a request body.
|
|
18
|
+
*
|
|
19
|
+
* The aws-cli does not send object bytes raw — it wraps them in
|
|
20
|
+
* `<hex-size>;chunk-signature=<sig>\r\n<data>\r\n` frames. Writing the request
|
|
21
|
+
* body straight to disk therefore stores the framing alongside the data and
|
|
22
|
+
* silently corrupts every object the CLI uploads. See docs/plan.md 4.1.
|
|
23
|
+
*
|
|
24
|
+
* Chunk signatures are verified as each frame completes. Data is forwarded
|
|
25
|
+
* before its frame is verified, which is safe because object metadata is only
|
|
26
|
+
* committed after the whole body succeeds — a failed verification destroys the
|
|
27
|
+
* stream and leaves nothing but an orphaned temp file (docs/plan.md 8).
|
|
28
|
+
*/
|
|
29
|
+
export class ChunkedDecoder extends Transform {
|
|
30
|
+
constructor({
|
|
31
|
+
signed = false,
|
|
32
|
+
seedSignature = null,
|
|
33
|
+
signingKey = null,
|
|
34
|
+
scope = null,
|
|
35
|
+
amzDate = null,
|
|
36
|
+
expectedLength = null,
|
|
37
|
+
} = {}, options = {}) {
|
|
38
|
+
super(options)
|
|
39
|
+
this.signed = signed
|
|
40
|
+
this.previousSignature = seedSignature
|
|
41
|
+
this.signingKey = signingKey
|
|
42
|
+
this.scope = scope
|
|
43
|
+
this.amzDate = amzDate
|
|
44
|
+
this.expectedLength = expectedLength
|
|
45
|
+
|
|
46
|
+
this.trailers = Object.create(null)
|
|
47
|
+
this.decodedLength = 0
|
|
48
|
+
|
|
49
|
+
this._pending = Buffer.alloc(0)
|
|
50
|
+
this._state = 'size'
|
|
51
|
+
this._remaining = 0
|
|
52
|
+
this._chunkHash = null
|
|
53
|
+
this._chunkSignature = null
|
|
54
|
+
this._trailerBytes = ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_transform(chunk, _encoding, callback) {
|
|
58
|
+
this._pending = this._pending.length ? Buffer.concat([this._pending, chunk]) : chunk
|
|
59
|
+
try {
|
|
60
|
+
this._drain()
|
|
61
|
+
callback()
|
|
62
|
+
} catch (err) {
|
|
63
|
+
callback(err)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_flush(callback) {
|
|
68
|
+
try {
|
|
69
|
+
this._drain()
|
|
70
|
+
if (this._state === 'trailer' && this._pending.length) {
|
|
71
|
+
this._consumeTrailerLine(this._pending.toString('latin1').replace(/\r?\n$/, ''))
|
|
72
|
+
this._pending = Buffer.alloc(0)
|
|
73
|
+
}
|
|
74
|
+
if (this._state !== 'done' && this._state !== 'trailer') {
|
|
75
|
+
throw new S3Error('IncompleteBody', 'The request body terminated before the final chunk')
|
|
76
|
+
}
|
|
77
|
+
this._verifyTrailerSignature()
|
|
78
|
+
if (this.expectedLength !== null && this.decodedLength !== this.expectedLength) {
|
|
79
|
+
throw new S3Error(
|
|
80
|
+
'IncompleteBody',
|
|
81
|
+
`Decoded body length ${this.decodedLength} does not match x-amz-decoded-content-length ${this.expectedLength}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
callback()
|
|
85
|
+
} catch (err) {
|
|
86
|
+
callback(err)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_takeLine() {
|
|
91
|
+
const index = this._pending.indexOf(CRLF)
|
|
92
|
+
if (index === -1) {
|
|
93
|
+
if (this._pending.length > MAX_LINE_BYTES) {
|
|
94
|
+
throw new S3Error('InvalidRequest', 'Malformed aws-chunked framing: line too long')
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
const line = this._pending.subarray(0, index).toString('latin1')
|
|
99
|
+
this._pending = this._pending.subarray(index + 2)
|
|
100
|
+
return line
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_drain() {
|
|
104
|
+
for (;;) {
|
|
105
|
+
if (this._state === 'done') return
|
|
106
|
+
|
|
107
|
+
if (this._state === 'size') {
|
|
108
|
+
const line = this._takeLine()
|
|
109
|
+
if (line === null) return
|
|
110
|
+
const [sizeField, ...extensions] = line.split(';')
|
|
111
|
+
const size = Number.parseInt(sizeField.trim(), 16)
|
|
112
|
+
if (!Number.isInteger(size) || size < 0 || size > MAX_CHUNK_BYTES) {
|
|
113
|
+
throw new S3Error('InvalidRequest', `Malformed aws-chunked framing: bad chunk size "${sizeField}"`)
|
|
114
|
+
}
|
|
115
|
+
this._chunkSignature = null
|
|
116
|
+
for (const extension of extensions) {
|
|
117
|
+
const [name, value] = extension.split('=')
|
|
118
|
+
if (name?.trim() === 'chunk-signature') this._chunkSignature = value?.trim() ?? null
|
|
119
|
+
}
|
|
120
|
+
if (this.signed && !this._chunkSignature) {
|
|
121
|
+
throw new S3Error('SignatureDoesNotMatch', 'Chunk is missing chunk-signature')
|
|
122
|
+
}
|
|
123
|
+
if (size === 0) {
|
|
124
|
+
this._verifyChunk(EMPTY_SHA256)
|
|
125
|
+
this._state = 'trailer'
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
this._remaining = size
|
|
129
|
+
this._chunkHash = this.signed ? createHash('sha256') : null
|
|
130
|
+
this._state = 'data'
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (this._state === 'data') {
|
|
135
|
+
if (this._pending.length === 0) return
|
|
136
|
+
const take = Math.min(this._remaining, this._pending.length)
|
|
137
|
+
const data = this._pending.subarray(0, take)
|
|
138
|
+
this._pending = this._pending.subarray(take)
|
|
139
|
+
this._remaining -= take
|
|
140
|
+
this.decodedLength += take
|
|
141
|
+
if (this._chunkHash) this._chunkHash.update(data)
|
|
142
|
+
this.push(data)
|
|
143
|
+
if (this._remaining === 0) this._state = 'chunk-end'
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (this._state === 'chunk-end') {
|
|
148
|
+
if (this._pending.length < 2) return
|
|
149
|
+
if (this._pending[0] !== 0x0d || this._pending[1] !== 0x0a) {
|
|
150
|
+
throw new S3Error('InvalidRequest', 'Malformed aws-chunked framing: missing CRLF after chunk data')
|
|
151
|
+
}
|
|
152
|
+
this._pending = this._pending.subarray(2)
|
|
153
|
+
this._verifyChunk(this._chunkHash ? this._chunkHash.digest('hex') : null)
|
|
154
|
+
this._state = 'size'
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this._state === 'trailer') {
|
|
159
|
+
const line = this._takeLine()
|
|
160
|
+
if (line === null) return
|
|
161
|
+
if (line === '') {
|
|
162
|
+
this._state = 'done'
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
this._consumeTrailerLine(line)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_consumeTrailerLine(line) {
|
|
174
|
+
if (!line) return
|
|
175
|
+
const separator = line.indexOf(':')
|
|
176
|
+
if (separator === -1) {
|
|
177
|
+
throw new S3Error('InvalidRequest', 'Malformed aws-chunked trailer')
|
|
178
|
+
}
|
|
179
|
+
const name = line.slice(0, separator).trim().toLowerCase()
|
|
180
|
+
const value = line.slice(separator + 1).trim()
|
|
181
|
+
if (name === 'x-amz-trailer-signature') {
|
|
182
|
+
this._trailerSignature = value
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
this.trailers[name] = value
|
|
186
|
+
this._trailerBytes += `${name}:${value}\n`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_verifyChunk(chunkSha256) {
|
|
190
|
+
if (!this.signed) return
|
|
191
|
+
const stringToSign = chunkStringToSign({
|
|
192
|
+
amzDate: this.amzDate,
|
|
193
|
+
scope: this.scope,
|
|
194
|
+
previousSignature: this.previousSignature,
|
|
195
|
+
chunkSha256,
|
|
196
|
+
})
|
|
197
|
+
const expected = calculateSignature(this.signingKey, stringToSign)
|
|
198
|
+
if (!signaturesMatch(expected, this._chunkSignature)) {
|
|
199
|
+
throw new S3Error('SignatureDoesNotMatch', 'The chunk signature does not match')
|
|
200
|
+
}
|
|
201
|
+
this.previousSignature = expected
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_verifyTrailerSignature() {
|
|
205
|
+
if (!this.signed || !this._trailerSignature) return
|
|
206
|
+
const stringToSign = trailerStringToSign({
|
|
207
|
+
amzDate: this.amzDate,
|
|
208
|
+
scope: this.scope,
|
|
209
|
+
previousSignature: this.previousSignature,
|
|
210
|
+
trailerSha256: createHash('sha256').update(this._trailerBytes, 'utf8').digest('hex'),
|
|
211
|
+
})
|
|
212
|
+
const expected = calculateSignature(this.signingKey, stringToSign)
|
|
213
|
+
if (!signaturesMatch(expected, this._trailerSignature)) {
|
|
214
|
+
throw new S3Error('SignatureDoesNotMatch', 'The trailer signature does not match')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Encodes a body as `aws-chunked`. Used by the test client to exercise the
|
|
221
|
+
* decoder the way the aws-cli does.
|
|
222
|
+
*/
|
|
223
|
+
export function encodeChunked(body, { signed = false, seedSignature, signingKey, scope, amzDate, chunkSize = 65536, trailers = null } = {}) {
|
|
224
|
+
const source = Buffer.isBuffer(body) ? body : Buffer.from(body)
|
|
225
|
+
const frames = []
|
|
226
|
+
let previousSignature = seedSignature
|
|
227
|
+
|
|
228
|
+
const frame = (data) => {
|
|
229
|
+
let extension = ''
|
|
230
|
+
if (signed) {
|
|
231
|
+
const stringToSign = chunkStringToSign({
|
|
232
|
+
amzDate,
|
|
233
|
+
scope,
|
|
234
|
+
previousSignature,
|
|
235
|
+
chunkSha256: createHash('sha256').update(data).digest('hex'),
|
|
236
|
+
})
|
|
237
|
+
previousSignature = calculateSignature(signingKey, stringToSign)
|
|
238
|
+
extension = `;chunk-signature=${previousSignature}`
|
|
239
|
+
}
|
|
240
|
+
frames.push(Buffer.from(`${data.length.toString(16)}${extension}\r\n`), data, CRLF)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (let offset = 0; offset < source.length; offset += chunkSize) {
|
|
244
|
+
frame(source.subarray(offset, Math.min(offset + chunkSize, source.length)))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Final zero-length frame.
|
|
248
|
+
let finalExtension = ''
|
|
249
|
+
if (signed) {
|
|
250
|
+
const stringToSign = chunkStringToSign({
|
|
251
|
+
amzDate, scope, previousSignature, chunkSha256: EMPTY_SHA256,
|
|
252
|
+
})
|
|
253
|
+
previousSignature = calculateSignature(signingKey, stringToSign)
|
|
254
|
+
finalExtension = `;chunk-signature=${previousSignature}`
|
|
255
|
+
}
|
|
256
|
+
frames.push(Buffer.from(`0${finalExtension}\r\n`))
|
|
257
|
+
|
|
258
|
+
if (trailers) {
|
|
259
|
+
let trailerBytes = ''
|
|
260
|
+
for (const [name, value] of Object.entries(trailers)) {
|
|
261
|
+
frames.push(Buffer.from(`${name}:${value}\r\n`))
|
|
262
|
+
trailerBytes += `${name}:${value}\n`
|
|
263
|
+
}
|
|
264
|
+
if (signed) {
|
|
265
|
+
const stringToSign = trailerStringToSign({
|
|
266
|
+
amzDate,
|
|
267
|
+
scope,
|
|
268
|
+
previousSignature,
|
|
269
|
+
trailerSha256: createHash('sha256').update(trailerBytes, 'utf8').digest('hex'),
|
|
270
|
+
})
|
|
271
|
+
frames.push(Buffer.from(`x-amz-trailer-signature:${calculateSignature(signingKey, stringToSign)}\r\n`))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
frames.push(CRLF)
|
|
276
|
+
return Buffer.concat(frames)
|
|
277
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory credential store.
|
|
5
|
+
*
|
|
6
|
+
* SigV4 recomputes an HMAC from the secret, so the secret cannot be stored as a
|
|
7
|
+
* one-way hash — the store always holds recoverable material and must be
|
|
8
|
+
* treated accordingly (docs/plan.md section 7).
|
|
9
|
+
*/
|
|
10
|
+
export class CredentialStore {
|
|
11
|
+
constructor(credentials = []) {
|
|
12
|
+
this.credentials = new Map()
|
|
13
|
+
for (const credential of credentials) this.add(credential)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
add({ accessKeyId, secretAccessKey }) {
|
|
17
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
18
|
+
throw new TypeError('A credential requires both accessKeyId and secretAccessKey')
|
|
19
|
+
}
|
|
20
|
+
this.credentials.set(accessKeyId, { accessKeyId, secretAccessKey })
|
|
21
|
+
return this
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(accessKeyId) {
|
|
25
|
+
return this.credentials.get(accessKeyId)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get size() {
|
|
29
|
+
return this.credentials.size
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Bound lookup suitable for passing into `verifyRequest`. */
|
|
33
|
+
lookup = (accessKeyId) => this.get(accessKeyId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function generateCredential() {
|
|
37
|
+
return {
|
|
38
|
+
accessKeyId: randomBytes(10).toString('hex').toUpperCase(),
|
|
39
|
+
secretAccessKey: randomBytes(30).toString('base64url'),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { S3Error } from '../errors.js'
|
|
3
|
+
|
|
4
|
+
export const ALGORITHM = 'AWS4-HMAC-SHA256'
|
|
5
|
+
export const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
6
|
+
|
|
7
|
+
export const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD'
|
|
8
|
+
export const STREAMING_SIGNED = 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
|
|
9
|
+
export const STREAMING_SIGNED_TRAILER = 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER'
|
|
10
|
+
export const STREAMING_UNSIGNED_TRAILER = 'STREAMING-UNSIGNED-PAYLOAD-TRAILER'
|
|
11
|
+
|
|
12
|
+
export const STREAMING_PAYLOADS = new Set([
|
|
13
|
+
STREAMING_SIGNED,
|
|
14
|
+
STREAMING_SIGNED_TRAILER,
|
|
15
|
+
STREAMING_UNSIGNED_TRAILER,
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
const MAX_SKEW_MS = 15 * 60 * 1000
|
|
19
|
+
const MAX_PRESIGN_EXPIRY = 7 * 24 * 60 * 60
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* RFC 3986 percent-encoding. `encodeURIComponent` leaves !'()* alone, which
|
|
23
|
+
* SigV4 does not — getting this wrong breaks every key containing a space or
|
|
24
|
+
* an accent (docs/plan.md section 4.5).
|
|
25
|
+
*/
|
|
26
|
+
export function uriEncode(value, encodeSlash = true) {
|
|
27
|
+
const bytes = Buffer.from(String(value), 'utf8')
|
|
28
|
+
let out = ''
|
|
29
|
+
for (const byte of bytes) {
|
|
30
|
+
const char = String.fromCharCode(byte)
|
|
31
|
+
if ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) ||
|
|
32
|
+
(byte >= 0x30 && byte <= 0x39) || char === '-' || char === '.' ||
|
|
33
|
+
char === '_' || char === '~') {
|
|
34
|
+
out += char
|
|
35
|
+
} else if (char === '/' && !encodeSlash) {
|
|
36
|
+
out += char
|
|
37
|
+
} else {
|
|
38
|
+
out += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hmac(key, data) {
|
|
45
|
+
return createHmac('sha256', key).update(data, 'utf8').digest()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const signingKeyCache = new Map()
|
|
49
|
+
const SIGNING_KEY_CACHE_LIMIT = 512
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* kSigning = HMAC(HMAC(HMAC(HMAC("AWS4"+secret, date), region), service), "aws4_request").
|
|
53
|
+
* Only changes daily, so it is cached per (accessKey, date, region, service).
|
|
54
|
+
*/
|
|
55
|
+
export function deriveSigningKey(secretAccessKey, date, region, service = 's3', accessKeyId = '') {
|
|
56
|
+
// The secret is part of the cache key (via its digest) so that rotating a
|
|
57
|
+
// secret under the same access key id cannot serve a stale signing key.
|
|
58
|
+
const secretFingerprint = createHash('sha256').update(secretAccessKey, 'utf8').digest('base64')
|
|
59
|
+
const cacheKey = `${accessKeyId}/${date}/${region}/${service}/${secretFingerprint}`
|
|
60
|
+
const cached = signingKeyCache.get(cacheKey)
|
|
61
|
+
if (cached) return cached
|
|
62
|
+
const kDate = hmac(`AWS4${secretAccessKey}`, date)
|
|
63
|
+
const kRegion = hmac(kDate, region)
|
|
64
|
+
const kService = hmac(kRegion, service)
|
|
65
|
+
const kSigning = hmac(kService, 'aws4_request')
|
|
66
|
+
if (signingKeyCache.size >= SIGNING_KEY_CACHE_LIMIT) signingKeyCache.clear()
|
|
67
|
+
signingKeyCache.set(cacheKey, kSigning)
|
|
68
|
+
return kSigning
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeHeaderValue(value) {
|
|
72
|
+
const raw = Array.isArray(value) ? value.join(',') : String(value ?? '')
|
|
73
|
+
return raw.trim().replace(/\s+/g, ' ')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Canonical query string: parameters decoded, then re-encoded per RFC 3986 and
|
|
78
|
+
* sorted by encoded name, then encoded value.
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalQueryString(pairs) {
|
|
81
|
+
return pairs
|
|
82
|
+
.map(([name, value]) => [uriEncode(name), uriEncode(value ?? '')])
|
|
83
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
84
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
85
|
+
.join('&')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildCanonicalRequest({ method, canonicalUri, queryPairs, headers, signedHeaders, payloadHash }) {
|
|
89
|
+
const canonicalHeaders = signedHeaders
|
|
90
|
+
.map((name) => `${name}:${normalizeHeaderValue(headers[name])}\n`)
|
|
91
|
+
.join('')
|
|
92
|
+
return [
|
|
93
|
+
method,
|
|
94
|
+
canonicalUri || '/',
|
|
95
|
+
canonicalQueryString(queryPairs),
|
|
96
|
+
canonicalHeaders,
|
|
97
|
+
signedHeaders.join(';'),
|
|
98
|
+
payloadHash,
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function buildStringToSign({ amzDate, scope, canonicalRequest }) {
|
|
103
|
+
return [
|
|
104
|
+
ALGORITHM,
|
|
105
|
+
amzDate,
|
|
106
|
+
scope,
|
|
107
|
+
createHash('sha256').update(canonicalRequest, 'utf8').digest('hex'),
|
|
108
|
+
].join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function calculateSignature(signingKey, stringToSign) {
|
|
112
|
+
return createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Per-chunk signature for `aws-chunked` bodies. Each chunk chains off the
|
|
117
|
+
* previous signature, seeded with the header signature (docs/plan.md 4.1).
|
|
118
|
+
*/
|
|
119
|
+
export function chunkStringToSign({ amzDate, scope, previousSignature, chunkSha256 }) {
|
|
120
|
+
return [
|
|
121
|
+
'AWS4-HMAC-SHA256-PAYLOAD',
|
|
122
|
+
amzDate,
|
|
123
|
+
scope,
|
|
124
|
+
previousSignature,
|
|
125
|
+
EMPTY_SHA256,
|
|
126
|
+
chunkSha256,
|
|
127
|
+
].join('\n')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function trailerStringToSign({ amzDate, scope, previousSignature, trailerSha256 }) {
|
|
131
|
+
return [
|
|
132
|
+
'AWS4-HMAC-SHA256-TRAILER',
|
|
133
|
+
amzDate,
|
|
134
|
+
scope,
|
|
135
|
+
previousSignature,
|
|
136
|
+
trailerSha256,
|
|
137
|
+
].join('\n')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function signaturesMatch(expected, provided) {
|
|
141
|
+
if (typeof provided !== 'string' || expected.length !== provided.length) return false
|
|
142
|
+
return timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(provided, 'utf8'))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parse `YYYYMMDDTHHMMSSZ`. */
|
|
146
|
+
export function parseAmzDate(amzDate) {
|
|
147
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(amzDate ?? '')
|
|
148
|
+
if (!match) return null
|
|
149
|
+
const [, y, mo, d, h, mi, s] = match
|
|
150
|
+
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseCredentialScope(credential) {
|
|
154
|
+
const parts = String(credential ?? '').split('/')
|
|
155
|
+
if (parts.length !== 5 || parts[4] !== 'aws4_request') {
|
|
156
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'Credential should be scoped with a valid terminator')
|
|
157
|
+
}
|
|
158
|
+
const [accessKeyId, date, region, service] = parts
|
|
159
|
+
return { accessKeyId, date, region, service, scope: parts.slice(1).join('/') }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseAuthorizationHeader(header) {
|
|
163
|
+
if (!header.startsWith(`${ALGORITHM} `)) {
|
|
164
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'Unsupported signature algorithm')
|
|
165
|
+
}
|
|
166
|
+
const fields = {}
|
|
167
|
+
for (const part of header.slice(ALGORITHM.length + 1).split(',')) {
|
|
168
|
+
const idx = part.indexOf('=')
|
|
169
|
+
if (idx === -1) continue
|
|
170
|
+
fields[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
|
|
171
|
+
}
|
|
172
|
+
if (!fields.Credential || !fields.SignedHeaders || !fields.Signature) {
|
|
173
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'Authorization header is missing required fields')
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
...parseCredentialScope(fields.Credential),
|
|
177
|
+
signedHeaders: fields.SignedHeaders.split(';').map((h) => h.toLowerCase()).filter(Boolean),
|
|
178
|
+
signature: fields.Signature,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assertFreshness(requestDate, now, code = 'RequestTimeTooSkewed') {
|
|
183
|
+
if (!requestDate) throw new S3Error('AuthorizationHeaderMalformed', 'Invalid x-amz-date')
|
|
184
|
+
if (Math.abs(now - requestDate.getTime()) > MAX_SKEW_MS) {
|
|
185
|
+
throw new S3Error(code, 'The difference between the request time and the current time is too large')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Verify a request. `ctx` carries the raw path (already percent-encoded by the
|
|
191
|
+
* client — reusing it verbatim is what makes the canonical URI match), the raw
|
|
192
|
+
* query pairs and the lower-cased headers.
|
|
193
|
+
*
|
|
194
|
+
* Returns the auth result including the seed signature and scope, which the
|
|
195
|
+
* chunked decoder needs to validate the body.
|
|
196
|
+
*/
|
|
197
|
+
export function verifyRequest(ctx, { lookupCredential, now = Date.now(), region = 'us-east-1' }) {
|
|
198
|
+
const headers = ctx.headers
|
|
199
|
+
const authorization = headers.authorization
|
|
200
|
+
const presigned = !authorization && ctx.query.get('X-Amz-Signature') !== undefined
|
|
201
|
+
|
|
202
|
+
if (!authorization && !presigned) {
|
|
203
|
+
throw new S3Error('AccessDenied', 'Anonymous access is not supported')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let parsed
|
|
207
|
+
let amzDate
|
|
208
|
+
let payloadHash
|
|
209
|
+
let queryPairs = ctx.queryPairs
|
|
210
|
+
|
|
211
|
+
if (presigned) {
|
|
212
|
+
const credential = ctx.query.get('X-Amz-Credential')
|
|
213
|
+
const signedHeaders = (ctx.query.get('X-Amz-SignedHeaders') ?? '')
|
|
214
|
+
.split(';').map((h) => h.toLowerCase()).filter(Boolean)
|
|
215
|
+
parsed = {
|
|
216
|
+
...parseCredentialScope(credential),
|
|
217
|
+
signedHeaders,
|
|
218
|
+
signature: ctx.query.get('X-Amz-Signature'),
|
|
219
|
+
}
|
|
220
|
+
if (ctx.query.get('X-Amz-Algorithm') !== ALGORITHM) {
|
|
221
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'Unsupported signature algorithm')
|
|
222
|
+
}
|
|
223
|
+
amzDate = ctx.query.get('X-Amz-Date')
|
|
224
|
+
const requestDate = parseAmzDate(amzDate)
|
|
225
|
+
if (!requestDate) throw new S3Error('AuthorizationHeaderMalformed', 'Invalid X-Amz-Date')
|
|
226
|
+
const expires = Number.parseInt(ctx.query.get('X-Amz-Expires') ?? '', 10)
|
|
227
|
+
if (!Number.isInteger(expires) || expires <= 0 || expires > MAX_PRESIGN_EXPIRY) {
|
|
228
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'X-Amz-Expires must be between 1 and 604800 seconds')
|
|
229
|
+
}
|
|
230
|
+
if (now > requestDate.getTime() + expires * 1000) {
|
|
231
|
+
throw new S3Error('AccessDenied', 'Request has expired')
|
|
232
|
+
}
|
|
233
|
+
if (requestDate.getTime() - now > MAX_SKEW_MS) {
|
|
234
|
+
throw new S3Error('RequestTimeTooSkewed', 'Request is dated too far in the future')
|
|
235
|
+
}
|
|
236
|
+
payloadHash = headers['x-amz-content-sha256'] ?? UNSIGNED_PAYLOAD
|
|
237
|
+
queryPairs = ctx.queryPairs.filter(([name]) => name !== 'X-Amz-Signature')
|
|
238
|
+
} else {
|
|
239
|
+
parsed = parseAuthorizationHeader(authorization)
|
|
240
|
+
amzDate = headers['x-amz-date'] ?? ''
|
|
241
|
+
assertFreshness(parseAmzDate(amzDate), now)
|
|
242
|
+
payloadHash = headers['x-amz-content-sha256']
|
|
243
|
+
if (!payloadHash) {
|
|
244
|
+
throw new S3Error('InvalidRequest', 'Missing required header x-amz-content-sha256')
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!parsed.signedHeaders.includes('host')) {
|
|
249
|
+
throw new S3Error('AuthorizationHeaderMalformed', 'The host header must be signed')
|
|
250
|
+
}
|
|
251
|
+
if (parsed.service !== 's3') {
|
|
252
|
+
throw new S3Error('AuthorizationHeaderMalformed', `Credential should be scoped to service: s3`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const credential = lookupCredential(parsed.accessKeyId)
|
|
256
|
+
if (!credential) {
|
|
257
|
+
throw new S3Error('InvalidAccessKeyId', 'The AWS Access Key Id you provided does not exist in our records')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const canonicalRequest = buildCanonicalRequest({
|
|
261
|
+
method: ctx.method,
|
|
262
|
+
canonicalUri: ctx.rawPath,
|
|
263
|
+
queryPairs,
|
|
264
|
+
headers,
|
|
265
|
+
signedHeaders: parsed.signedHeaders,
|
|
266
|
+
payloadHash,
|
|
267
|
+
})
|
|
268
|
+
const stringToSign = buildStringToSign({ amzDate, scope: parsed.scope, canonicalRequest })
|
|
269
|
+
const signingKey = deriveSigningKey(
|
|
270
|
+
credential.secretAccessKey, parsed.date, parsed.region, parsed.service, parsed.accessKeyId,
|
|
271
|
+
)
|
|
272
|
+
const expected = calculateSignature(signingKey, stringToSign)
|
|
273
|
+
|
|
274
|
+
if (!signaturesMatch(expected, parsed.signature)) {
|
|
275
|
+
const error = new S3Error('SignatureDoesNotMatch')
|
|
276
|
+
error.detail.canonicalRequest = canonicalRequest
|
|
277
|
+
error.detail.stringToSign = stringToSign
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
accessKeyId: parsed.accessKeyId,
|
|
283
|
+
region: parsed.region,
|
|
284
|
+
scope: parsed.scope,
|
|
285
|
+
amzDate,
|
|
286
|
+
payloadHash,
|
|
287
|
+
presigned,
|
|
288
|
+
seedSignature: expected,
|
|
289
|
+
signingKey,
|
|
290
|
+
canonicalRequest,
|
|
291
|
+
stringToSign,
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 error codes. The `<Code>` value drives SDK retry logic, so these strings
|
|
3
|
+
* are a wire contract — see docs/plan.md section 4.7.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const CODES = {
|
|
7
|
+
AccessDenied: [403, 'Access Denied'],
|
|
8
|
+
AccessForbidden: [403, 'CORSResponse: This CORS request is not allowed'],
|
|
9
|
+
AuthorizationHeaderMalformed: [400, 'The authorization header is malformed'],
|
|
10
|
+
BadDigest: [400, 'The Content-MD5 you specified did not match what we received'],
|
|
11
|
+
BucketAlreadyExists: [409, 'The requested bucket name is not available'],
|
|
12
|
+
BucketAlreadyOwnedByYou: [409, 'You already own this bucket'],
|
|
13
|
+
BucketNotEmpty: [409, 'The bucket you tried to delete is not empty'],
|
|
14
|
+
EntityTooLarge: [400, 'Your proposed upload exceeds the maximum allowed object size'],
|
|
15
|
+
EntityTooSmall: [400, 'Your proposed upload is smaller than the minimum allowed object size'],
|
|
16
|
+
IllegalVersioningConfigurationException: [400, 'The versioning configuration specified is invalid'],
|
|
17
|
+
IncompleteBody: [400, 'You did not provide the number of bytes specified by the Content-Length HTTP header'],
|
|
18
|
+
InternalError: [500, 'We encountered an internal error. Please try again.'],
|
|
19
|
+
InvalidAccessKeyId: [403, 'The AWS Access Key Id you provided does not exist in our records'],
|
|
20
|
+
InvalidArgument: [400, 'Invalid Argument'],
|
|
21
|
+
InvalidBucketName: [400, 'The specified bucket is not valid'],
|
|
22
|
+
InvalidDigest: [400, 'The Content-MD5 you specified is not valid'],
|
|
23
|
+
InvalidPart: [400, 'One or more of the specified parts could not be found'],
|
|
24
|
+
InvalidPartNumber: [416, 'The requested partnumber is not satisfiable'],
|
|
25
|
+
InvalidPartOrder: [400, 'The list of parts was not in ascending order'],
|
|
26
|
+
InvalidRange: [416, 'The requested range is not satisfiable'],
|
|
27
|
+
InvalidRequest: [400, 'Invalid Request'],
|
|
28
|
+
InvalidTag: [400, 'The tag provided was not valid'],
|
|
29
|
+
MalformedPolicy: [400, 'The policy is not in the valid JSON format'],
|
|
30
|
+
NoSuchBucketPolicy: [404, 'The bucket policy does not exist'],
|
|
31
|
+
NoSuchCORSConfiguration: [404, 'The CORS configuration does not exist'],
|
|
32
|
+
NoSuchLifecycleConfiguration: [404, 'The lifecycle configuration does not exist'],
|
|
33
|
+
NoSuchTagSet: [404, 'The TagSet does not exist'],
|
|
34
|
+
NoSuchVersion: [404, 'The specified version does not exist'],
|
|
35
|
+
KeyTooLongError: [400, 'Your key is too long'],
|
|
36
|
+
MalformedXML: [400, 'The XML you provided was not well-formed or did not validate against our published schema'],
|
|
37
|
+
MethodNotAllowed: [405, 'The specified method is not allowed against this resource'],
|
|
38
|
+
MissingContentLength: [411, 'You must provide the Content-Length HTTP header'],
|
|
39
|
+
NoSuchBucket: [404, 'The specified bucket does not exist'],
|
|
40
|
+
NoSuchKey: [404, 'The specified key does not exist'],
|
|
41
|
+
NoSuchUpload: [404, 'The specified multipart upload does not exist'],
|
|
42
|
+
NotImplemented: [501, 'A header you provided implies functionality that is not implemented'],
|
|
43
|
+
PreconditionFailed: [412, 'At least one of the pre-conditions you specified did not hold'],
|
|
44
|
+
RequestTimeTooSkewed: [403, 'The difference between the request time and the current time is too large'],
|
|
45
|
+
SignatureDoesNotMatch: [403, 'The request signature we calculated does not match the signature you provided'],
|
|
46
|
+
SlowDown: [503, 'Please reduce your request rate'],
|
|
47
|
+
XAmzContentSHA256Mismatch: [400, 'The provided x-amz-content-sha256 header does not match what was computed'],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class S3Error extends Error {
|
|
51
|
+
constructor(code, message, detail = {}) {
|
|
52
|
+
const known = CODES[code] ?? [400, code]
|
|
53
|
+
super(message ?? known[1])
|
|
54
|
+
this.name = 'S3Error'
|
|
55
|
+
this.code = code
|
|
56
|
+
this.statusCode = detail.statusCode ?? known[0]
|
|
57
|
+
this.detail = detail
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isS3Error(err) {
|
|
62
|
+
return err instanceof S3Error
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { CODES as ERROR_CODES }
|