-
Notifications
You must be signed in to change notification settings - Fork 29
/
signature.ts
875 lines (775 loc) · 31.3 KB
/
signature.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
import crypto from 'k6/crypto'
import * as constants from './constants'
import { AWSError } from './error'
import { hasHeader, HTTPHeaderBag, HTTPRequest, QueryParameterBag, SignedHTTPRequest } from './http'
import { isArrayBuffer } from './utils'
/**
* SignatureV4 can be used to sign HTTP requests and presign URLs using the AWS Signature
* Version 4 signing process.
*
* It offers two signing methods:
* - sign: signs the request headers and payload
* - presign: returns a presigned (authorization information contained in the query string) URL
*
* @see https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
*/
export class SignatureV4 {
/**
* The name of the service to sign for.
*/
private readonly service: string
/**
* The name of the region to sign for.
*/
private readonly region: string
/**
* The credentials with which the request should be signed.
*/
private readonly credentials: Credentials
/**
* Whether to uri-escape the request URI path as part of computing the
* canonical request string. This is required for every AWS service, except
* Amazon S3, as of late 2017.
*
* @default [true]
*/
private readonly uriEscapePath: boolean
/**
* Whether to calculate a checksum of the request body and include it as
* either a request header (when signing) or as a query string parameter
* (when presigning). This is required for AWS Glacier and Amazon S3 and optional for
* every other AWS service as of late 2017.
*
* @default [true]
*/
private readonly applyChecksum: boolean
// TODO: uriEscapePath and applyChecksum should not be present in the constructor
constructor({
service,
region,
credentials,
uriEscapePath,
applyChecksum,
}: SignatureV4Options) {
this.service = service
this.region = region
this.credentials = credentials
this.uriEscapePath = typeof uriEscapePath === 'boolean' ? uriEscapePath : true
this.applyChecksum = typeof applyChecksum === 'boolean' ? applyChecksum : true
}
/**
* Includes AWS v4 signing information to the provided HTTP request.
*
* This method adds an Authorization header to the request, containing
* the signature and other signing information. It also returns a preformatted
* URL that can be used to make the k6 http request.
*
* This method mutates the request object.
*
* @param request {HTTPRequest} The request to sign.
* @param options {Partial<RequestSigningOptions>} Options for signing the request.
* @returns {SignedHTTPRequest} The signed request.
*/
sign(request: HTTPRequest, options: Partial<RequestSigningOptions> = {}): SignedHTTPRequest {
// Set default values for options which are not provided by the user.
const defaultOptions = {
signingDate: new Date(),
unsignableHeaders: new Set<string>(),
signableHeaders: new Set<string>(),
}
// Merge default options with the ones maybe provided by the user.
const finalOptions = { ...defaultOptions, ...options }
const { longDate, shortDate }: DateInfo = formatDate(finalOptions.signingDate)
const service = finalOptions.signingService || this.service
const region = finalOptions.signingRegion || this.region
const scope = `${shortDate}/${region}/${service}/${constants.KEY_TYPE_IDENTIFIER}`
// Required by the specification:
// "For HTTP/1.1 requests, you must include the host header at a minimum.
// Standard headers like content-type are optional.
// For HTTP/2 requests, you must include the :authority header instead of
// the host header. Different services might require other headers."
if (!request.headers[constants.HOST_HEADER]) {
request.headers[constants.HOST_HEADER] = request.endpoint.hostname
}
// Filter out headers that will be generated and managed by the signing process.
// If the user provide any of those as part of the HTTPRequest's headers, they
// will be ignored.
for (const headerName of Object.keys(request.headers)) {
if (constants.GENERATED_HEADERS.indexOf(headerName.toLowerCase()) > -1) {
delete request.headers[headerName]
}
}
request.headers[constants.AMZ_DATE_HEADER] = longDate
if (this.credentials.sessionToken) {
request.headers[constants.AMZ_TOKEN_HEADER] = this.credentials.sessionToken
}
// If the request body is a typed array, we need to convert it to a buffer
// so that we can calculate the checksum.
if (ArrayBuffer.isView(request.body)) {
request.body = request.body.buffer
}
// Ensure we avoid passing undefined to the crypto hash function.
if (!request.body) {
request.body = ''
}
const payloadHash = this.computePayloadHash(request)
if (
!hasHeader(constants.AMZ_CONTENT_SHA256_HEADER, request.headers) &&
this.applyChecksum
) {
request.headers[constants.AMZ_CONTENT_SHA256_HEADER] = payloadHash
}
const canonicalHeaders = this.computeCanonicalHeaders(
request,
finalOptions.unsignableHeaders,
finalOptions.signableHeaders
)
const signature = this.calculateSignature(
longDate,
scope,
this.deriveSigningKey(this.credentials, service, region, shortDate),
this.createCanonicalRequest(request, canonicalHeaders, payloadHash)
)
/**
* Step 4 of the signing process: add the signature to the HTTP request's headers.
*
* @see https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
*/
request.headers[constants.AUTHORIZATION_HEADER] =
`${constants.SIGNING_ALGORITHM_IDENTIFIER} ` +
`Credential=${this.credentials.accessKeyId}/${scope}, ` +
`SignedHeaders=${Object.keys(canonicalHeaders).sort().join(';')}, ` +
`Signature=${signature}`
// If a request path was provided, add it to the URL
let url = request.endpoint.href
if (request.path) {
// Ensure the URI and the request path are properly concatenated
// by adding a trailing slash to the URI if it's missing.
if (!url.endsWith('/') && !request.path.startsWith('/')) {
url += '/'
}
// Append the path to the URL
url += request.path
}
// If a request query string was provided, add it to the URL
if (request.query) {
// We exclude the signature from the query string
url += `?${this.serializeQueryParameters(request.query)}`
}
return {
url: url,
...request,
}
}
/**
* Produces a presigned URL with AWS v4 signature information for the provided HTTP request.
*
* A presigned URL is a URL that contains the authorization information
* (signature and other signing information) in the query string. This method
* returns a preformatted URL that can be used to make the k6 http request.
*
* @param originalRequest - The original request to presign.
* @param options - Options controlling the signing of the request.
* @returns A signed request, including the presigned URL.
*/
presign(originalRequest: HTTPRequest, options: PresignOptions = {}): SignedHTTPRequest {
const {
signingDate = new Date(),
expiresIn = 3600,
unsignableHeaders,
unhoistableHeaders,
signableHeaders,
signingRegion,
signingService,
} = options
const { longDate, shortDate }: DateInfo = formatDate(signingDate)
const region = signingRegion || this.region
const service = signingService || this.service
if (expiresIn > constants.MAX_PRESIGNED_TTL) {
throw new InvalidSignatureError(
"Signature version 4 presigned URLs can't be valid for more than 7 days"
)
}
const scope = `${shortDate}/${region}/${service}/${constants.KEY_TYPE_IDENTIFIER}`
const request = this.moveHeadersToQuery(originalRequest, { unhoistableHeaders })
// Required by the specification:
// "For HTTP/1.1 requests, you must include the host header at a minimum.
// Standard headers like content-type are optional.
// For HTTP/2 requests, you must include the :authority header instead of
// the host header. Different services might require other headers."
if (!request.headers[constants.HOST_HEADER]) {
request.headers[constants.HOST_HEADER] = originalRequest.endpoint.hostname
}
// If the user provided a session token, include it in the signed url query string.
if (this.credentials.sessionToken) {
request.query[constants.AMZ_TOKEN_QUERY_PARAM] = this.credentials.sessionToken
}
// Add base signing query parameters to the request, as described in the documentation
// @see https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
request.query[constants.AMZ_ALGORITHM_QUERY_PARAM] = constants.SIGNING_ALGORITHM_IDENTIFIER
request.query[
constants.AMZ_CREDENTIAL_QUERY_PARAM
] = `${this.credentials.accessKeyId}/${scope}`
request.query[constants.AMZ_DATE_QUERY_PARAM] = longDate
request.query[constants.AMZ_EXPIRES_QUERY_PARAM] = expiresIn.toString(10)
const canonicalHeaders = this.computeCanonicalHeaders(
request,
unsignableHeaders,
signableHeaders
)
request.query[constants.AMZ_SIGNED_HEADERS_QUERY_PARAM] = Object.keys(canonicalHeaders)
.sort()
.join(';')
const signingKey = this.deriveSigningKey(this.credentials, service, region, shortDate)
// Computing the payload from the original request. This is required
// in the event the user attempts to produce a presigned URL for s3,
// which requires the payload hash to be 'UNSIGNED-PAYLOAD'.
//
// To that effect, users need to set the 'x-amz-content-sha256' header,
// and mark it as unhoistable and unsignable. When setup this way,
// the computePayloadHash method will then return the string 'UNSIGNED-PAYLOAD'.
const payloadHash = this.computePayloadHash(originalRequest)
const canonicalRequest = this.createCanonicalRequest(request, canonicalHeaders, payloadHash)
request.query[constants.AMZ_SIGNATURE_QUERY_PARAM] = this.calculateSignature(
longDate,
scope,
signingKey,
canonicalRequest
)
// If a request path was provided, add it to the URL
let url = originalRequest.endpoint.href
if (request.path) {
// Ensure there is a trailing slash at the end of the URL
// so that appending the path does not result in a malformed URL.
url = url?.endsWith('/') ? url : url + '/'
// Append the path to the URL
url += request.path
}
// If a request query string was provided, add it to the URL
if (request.query) {
url += `?${this.serializeQueryParameters(request.query)}`
}
return { url: url, ...request }
}
/**
* Create a string including information from your request
* in a AWS signature v4 standardized (canonical) format.
*
* Step 1 of the signing process: create the canonical request string.
* @see https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
*
* @param request {HTTPRequest} The request to sign.
* @param canonicalHeaders {HTTPHeaderBag} The request's canonical headers.
* @param payloadHash {string} The hexadecimally encoded request's payload hash .
* @returns {string} The canonical request string.
*/
private createCanonicalRequest(
request: HTTPRequest,
canonicalHeaders: HTTPHeaderBag,
payloadHash: string
): string {
const sortedHeaders = Object.keys(canonicalHeaders).sort()
const sortedCanonicalHeaders = sortedHeaders
.map((name) => `${name}:${canonicalHeaders[name]}`)
.join('\n')
const signedHeaders = sortedHeaders.join(';')
return (
`${request.method}\n` +
`${this.computeCanonicalURI(request)}\n` +
`${this.computeCanonicalQuerystring(request)}\n` +
`${sortedCanonicalHeaders}\n\n` +
`${signedHeaders}\n` +
`${payloadHash}`
)
}
/**
* Create the "string to sign" part of the signature Version 4 protocol.
*
* The "string to sign" includes meta information about your request and
* about the canonical request that you created with `createCanonicalRequest`.
* It is used hand in hand with the signing key to create the request signature.
* Step 2 of the signing process: create the string to sign.
* @see https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
*
* @param longDate {string} The request's date in iso 8601 format.
* @param credentialScope {string} The request's credential scope.
* @param canonicalRequest {string} The request's canonical request.
* @returns {string} The "string to sign".
*/
private createStringToSign(
longDate: string,
credentialScope: string,
canonicalRequest: string
): string {
const hashedCanonicalRequest = crypto.sha256(canonicalRequest, 'hex')
return (
`${constants.SIGNING_ALGORITHM_IDENTIFIER}\n` +
`${longDate}\n` +
`${credentialScope}\n` +
`${hashedCanonicalRequest}`
)
}
/**
* Calculte the signature for AWS signature version 4.
*
* Step 3 of the signing process: create the signature.
* @see https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
*
* @param longDate {string} The request's date in iso 8601 format.
* @param credentialScope {string} The request's credential scope.
* @param signingKey {string} the signing key as computed by the deriveSigningKey method.
* @param canonicalRequest {string} The request's canonical request.
* @returns {string} The signature.
*/
private calculateSignature(
longDate: string,
credentialScope: string,
signingKey: Uint8Array,
canonicalRequest: string
): string {
const stringToSign = this.createStringToSign(longDate, credentialScope, canonicalRequest)
return crypto.hmac('sha256', signingKey, stringToSign, 'hex')
}
/**
* Derives the signing key for authenticating requests signed with
* the Signature version 4 authentication protocol.
*
* deriveSigningKey produces a signing key by creating a series of
* hash-based message authentication codes (HMACs) represented in
* a binary format.
*
* The derived signing key is specific to the date it's made at, as well as
* the service and region it targets.
*
* @param credentials {AWSCredentials} The credentials to use for signing.
* @param service {string} The service the request is targeted at.
* @param region {string} The region the request is targeted at.
* @param shortDate {string} The request's date in YYYYMMDD format.
* @returns {Uint8Array} The derived signing key.
*/
private deriveSigningKey(
credentials: Credentials,
service: string,
region: string,
shortDate: string
): Uint8Array {
const kSecret: string = credentials.secretAccessKey
/**
* crypto.hmac returns a value of type `bytes`, which is an alias for
* number[]. However, the secret argument to hmac needs to either be
* a `string` or ArrayBuffer. The only way to get around this is to
* cast the return value of hmac to any, thus, we disable the no-explicit-any
* ESLint rule for this function.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
const kDate: any = crypto.hmac('sha256', 'AWS4' + kSecret, shortDate, 'binary')
const kRegion: any = crypto.hmac('sha256', kDate, region, 'binary')
const kService: any = crypto.hmac('sha256', kRegion, service, 'binary')
const kSigning: any = crypto.hmac('sha256', kService, 'aws4_request', 'binary')
/* eslint-enable @typescript-eslint/no-explicit-any */
return kSigning
}
/**
* Create a string that includes information from your request
* in a AWS signature v4 standardized (canonical) format.
*
* @param param0 {HTTPRequest} The request to sign.
* @returns {string} The canonical URI.
*/
private computeCanonicalURI({ path }: HTTPRequest): string {
if (this.uriEscapePath) {
// Non-S3 services, we normalize the path and then double URI encode it.
// Ref: "Remove Dot Segments" https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4
const normalizedURISegments = []
for (const URISegment of path.split('/')) {
if (URISegment?.length === 0) {
continue
}
if (URISegment === '.') {
continue
}
if (URISegment === '..') {
normalizedURISegments.pop()
} else {
normalizedURISegments.push(URISegment)
}
}
// Normalize the URI
const leading = path?.startsWith('/') ? '/' : ''
const URI = normalizedURISegments.join('/')
const trailing = normalizedURISegments.length > 0 && path?.endsWith('/') ? '/' : ''
const normalizedURI = `${leading}${URI}${trailing}`
const doubleEncoded = encodeURIComponent(normalizedURI)
return doubleEncoded.replace(/%2F/g, '/')
}
// For S3, we shouldn't normalize the path. For example, object name
// my-object//example//photo.user should not be normalized to
// my-object/example/photo.user
return path
}
/**
* Serializes the request's query parameters into their canonical
* string version. If the request does not include a query parameters,
* returns an empty string.
*
* @param param0 {HTTPRequest} The request containing the query parameters.
* @returns {string} The canonical query string.
*/
private computeCanonicalQuerystring({ query = {} }: HTTPRequest): string {
const keys: Array<string> = []
const serialized: Record<string, string> = {}
for (const key of Object.keys(query).sort()) {
if (key.toLowerCase() === constants.AMZ_SIGNATURE_HEADER) {
continue
}
keys.push(key)
const value = query[key]
if (typeof value === 'string') {
serialized[key] = `${escapeURI(key)}=${escapeURI(value)}`
} else if (Array.isArray(value)) {
serialized[key] = value
.slice(0)
.sort()
.reduce(
(encoded: Array<string>, value: string) =>
encoded.concat([`${escapeURI(key)}=${escapeURI(value)}`]),
[]
)
.join('&')
}
}
return keys
.map((key) => serialized[key])
.filter((serialized) => serialized)
.join('&')
}
/**
* Create the canonical form of the request's headers.
* Canonical headers consist of all the HTTP headers you
* are including with the signed request.
*
* @param param0 {HTTPRequest} The request to compute the canonical headers of.
* @param unsignableHeaders {Set<string>} The headers that should not be signed.
* @param signableHeaders {Set<string>} The headers that should be signed.
* @returns {string} The canonical headers.
*/
private computeCanonicalHeaders(
{ headers }: HTTPRequest,
unsignableHeaders?: Set<string>,
signableHeaders?: Set<string>
): HTTPHeaderBag {
const canonicalHeaders: HTTPHeaderBag = {}
for (const headerName of Object.keys(headers).sort()) {
if (headers[headerName] == undefined) {
continue
}
const canonicalHeaderName = headerName.toLowerCase()
if (
canonicalHeaderName in constants.ALWAYS_UNSIGNABLE_HEADERS ||
unsignableHeaders?.has(canonicalHeaderName)
) {
if (
!signableHeaders ||
(signableHeaders && !signableHeaders.has(canonicalHeaderName))
) {
continue
}
}
if (typeof headers[headerName] === 'string') {
canonicalHeaders[canonicalHeaderName] = headers[headerName] = headers[headerName]
.trim()
.replace(/\s+/g, ' ')
}
}
return canonicalHeaders
}
/**
* Computes the SHA256 cryptographic hash of the request's body.
*
* If the headers contain the 'X-Amz-Content-Sha256' header, then
* the value of that header is returned instead. This proves useful
* when, for example, presiging a URL for S3, as the payload hash
* must always be equal to 'UNSIGNED-PAYLOAD'.
*
* @param param0 {HTTPRequest} The request to compute the payload hash of.
* @returns {string} The hex encoded SHA256 payload hash, or the value of the 'X-Amz-Content-Sha256' header.
*/
private computePayloadHash({ headers, body }: HTTPRequest): string {
// for (const headerName of Object.keys(headers)) {
// // If the header is present, return its value.
// // So that we let the 'UNSIGNED-PAYLOAD' value pass through.
// if (headerName.toLowerCase() === constants.AMZ_CONTENT_SHA256_HEADER) {
// return headers[headerName]
// }
// }
if (headers[constants.AMZ_CONTENT_SHA256_HEADER]) {
return headers[constants.AMZ_CONTENT_SHA256_HEADER]
}
if (body == undefined) {
return constants.EMPTY_SHA256
}
if (typeof body === 'string' || isArrayBuffer(body)) {
return crypto.sha256(body, 'hex').toLowerCase()
}
if (ArrayBuffer.isView(body)) {
// If the request body is a typed array, we need to convert it to a buffer
// so that we can calculate the checksum.
return crypto.sha256((body as DataView).buffer, 'hex').toLowerCase()
}
return constants.UNSIGNED_PAYLOAD
}
/**
* Moves a request's headers to its query parameters.
*
* The operation will ignore any amazon standard headers, prefixed
* with 'X-Amz-'. It will also ignore any headers specified as unhoistable
* by the options.
*
* The operation will delete the headers from the request.
*
* @param request {HTTPRequest} The request to move the headers from.
* @param options
* @returns {HTTPRequest} The request with the headers moved to the query parameters.
*/
private moveHeadersToQuery(
request: HTTPRequest,
options: { unhoistableHeaders?: Set<string> } = {}
): HTTPRequest & { query: QueryParameterBag } {
const requestCopy = JSON.parse(JSON.stringify(request))
const { headers, query = {} as QueryParameterBag } = requestCopy
for (const name of Object.keys(headers)) {
const lowerCaseName = name.toLowerCase()
if (
lowerCaseName.slice(0, 6) === 'x-amz-' &&
!options.unhoistableHeaders?.has(lowerCaseName)
) {
query[name] = headers[name]
delete headers[name]
}
}
return {
...requestCopy,
headers,
query,
}
}
/**
* Serializes a HTTPRequest's query parameter bag into a string.
*
* @param query {QueryParameterBag} The query parameters to serialize.
* @param ignoreKeys {Set<string>} The keys to ignore.
* @returns {string} The serialized, and ready to use in a URL, query parameters.
*/
private serializeQueryParameters(query: QueryParameterBag, ignoreKeys?: string[]): string {
const keys: Array<string> = []
const serialized: Record<string, string> = {}
for (const key of Object.keys(query).sort()) {
if (ignoreKeys?.includes(key.toLowerCase())) {
continue
}
keys.push(key)
const value = query[key]
if (typeof value === 'string') {
serialized[key] = `${escapeURI(key)}=${escapeURI(value)}`
} else if (Array.isArray(value)) {
serialized[key] = value
.slice(0)
.sort()
.reduce(
(encoded: Array<string>, value: string) =>
encoded.concat([`${escapeURI(key)}=${escapeURI(value)}`]),
[]
)
.join('&')
}
}
return keys
.map((key) => serialized[key])
.filter((serialized) => serialized)
.join('&')
}
}
/**
* Error indicating an Invalid signature has been sent to AWS services
*
* Inspired from AWS official error types, as
* described in:
* * https://aws.amazon.com/blogs/developer/service-error-handling-modular-aws-sdk-js/
* * https://github.com/aws/aws-sdk-js/blob/master/lib/error.d.ts
*/
export class InvalidSignatureError extends AWSError {
/**
* Constructs an InvalidSignatureError
*
* @param {string} message - human readable error message
*/
constructor(message: string, code?: string) {
super(message, code)
this.name = 'InvalidSignatureError'
}
}
export interface SignatureV4Options {
/**
* The name of the service to sign for.
*/
service: string
/**
* The name of the region to sign for.
*/
region: string
/**
* The credentials with which the request should be signed.
*/
credentials: Credentials
/**
* Whether to uri-escape the request URI path as part of computing the
* canonical request string. This is required for every AWS service, except
* Amazon S3, as of late 2017.
*
* @default [true]
*/
uriEscapePath?: boolean
/**
* Whether to calculate a checksum of the request body and include it as
* either a request header (when signing) or as a query string parameter
* (when presigning). This is required for AWS Glacier and Amazon S3 and optional for
* every other AWS service as of late 2017.
*
* @default [true]
*/
applyChecksum?: boolean
}
export interface SignOptions {
/**
* The date and time to be used as signature metadata. This value should be
* a Date object, a unix (epoch) timestamp, or a string that can be
* understood by the JavaScript `Date` constructor.If not supplied, the
* value returned by `new Date()` will be used.
*/
signingDate?: Date
/**
* The service signing name. It will override the service name of the signer
* in current invocation
*/
signingService?: string
/**
* The region name to sign the request. It will override the signing region of the
* signer in current invocation
*/
signingRegion?: string
}
export interface RequestSigningOptions extends SignOptions {
/**
* A set of strings whose members represents headers that cannot be signed.
* All headers in the provided request will have their names converted to
* lower case and then checked for existence in the unsignableHeaders set.
*/
unsignableHeaders?: Set<string>
/**
* A set of strings whose members represents headers that should be signed.
* Any values passed here will override those provided via unsignableHeaders,
* allowing them to be signed.
*
* All headers in the provided request will have their names converted to
* lower case before signing.
*/
signableHeaders?: Set<string>
}
export interface PresignOptions extends RequestSigningOptions {
/**
* The number of seconds before the presigned URL expires
*/
expiresIn?: number
/**
* A set of strings whose representing headers that should not be hoisted
* to presigned request's query string. If not supplied, the presigner
* moves all the AWS-specific headers (starting with `x-amz-`) to the request
* query string. If supplied, these headers remain in the presigned request's
* header.
* All headers in the provided request will have their names converted to
* lower case and then checked for existence in the unhoistableHeaders set.
*/
unhoistableHeaders?: Set<string>
}
export interface Credentials {
/**
* AWS access key ID
*/
readonly accessKeyId: string
/**
* AWS secret access key
*/
readonly secretAccessKey: string
/**
* A security or session token to use with these credentials. Usually
* present for temporary credentials.
*/
readonly sessionToken?: string
}
export interface DateInfo {
/**
* ISO8601 formatted date string
*/
longDate: string
/**
* String in the format YYYYMMDD
*/
shortDate: string
}
/**
* Escapes a URI following the AWS signature v4 escaping rules.
*
* @param URI {string} The URI to escape.
* @returns {string} The escaped URI.
*/
function escapeURI(URI: string): string {
const hexEncode = (c: string): string => {
return `%${c.charCodeAt(0).toString(16).toUpperCase()}`
}
return encodeURIComponent(URI).replace(/[!'()*]/g, hexEncode)
}
/**
* formatDate formats a Date object into a ISO8601 formatted date string
* and a string in the format YYYYMMDD.
*
* @param date {Date} The date to format.
* @returns {DateInfo} The formatted date.
*/
function formatDate(date: Date): DateInfo {
const longDate = iso8601(date).replace(/[-:]/g, '')
return {
longDate,
shortDate: longDate.slice(0, 8),
}
}
/**
* Formats a time into an ISO 8601 string.
*
* @see https://en.wikipedia.org/wiki/ISO_8601
*
* @param time {number | string | Date} The time to format.
* @returns {string} The ISO 8601 formatted time.
*/
function iso8601(time: number | string | Date): string {
return toDate(time)
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
}
/**
* Converts a time value into a Date object.
*
* @param time {number | string | Date} The time to convert.
* @returns {Date} The resulting Date object.
*/
function toDate(time: number | string | Date): Date {
if (typeof time === 'number') {
return new Date(time * 1000)
}
if (typeof time === 'string') {
if (Number(time)) {
return new Date(Number(time) * 1000)
}
return new Date(time)
}
return time
}