@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.
@@ -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 }