-
Notifications
You must be signed in to change notification settings - Fork 145
/
Copy pathSigV4RequestSigner.ts
215 lines (189 loc) · 9.04 KB
/
SigV4RequestSigner.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
import crypto from 'isomorphic-webcrypto';
import { QueryParams } from './QueryParams';
import { RequestSigner } from './RequestSigner';
import { Credentials } from './SignalingClient';
import { validateValueNonNil } from './internal/utils';
type Headers = { [header: string]: string };
/**
* Utility class for SigV4 signing requests. The AWS SDK cannot be used for this purpose because it does not have support for WebSocket endpoints.
*/
export class SigV4RequestSigner implements RequestSigner {
private static readonly DEFAULT_ALGORITHM = 'AWS4-HMAC-SHA256';
private static readonly DEFAULT_SERVICE = 'kinesisvideo';
private readonly region: string;
private readonly credentials: Credentials;
private readonly service: string;
public constructor(region: string, credentials: Credentials, service: string = SigV4RequestSigner.DEFAULT_SERVICE) {
this.region = region;
this.credentials = credentials;
this.service = service;
}
/**
* Creates a SigV4 signed WebSocket URL for the given host/endpoint with the given query params.
*
* @param endpoint The WebSocket service endpoint including protocol, hostname, and path (if applicable).
* @param queryParams Query parameters to include in the URL.
* @param date Date to use for request signing. Defaults to NOW.
*
* Implementation note: Query parameters should be in alphabetical order.
*
* Note from AWS docs: "When you add the X-Amz-Security-Token parameter to the query string, some services require that you include this parameter in the
* canonical (signed) request. For other services, you add this parameter at the end, after you calculate the signature. For details, see the API reference
* documentation for that service." KVS Signaling Service requires that the session token is added to the canonical request.
*
* @see https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
* @see https://gist.github.com/prestomation/24b959e51250a8723b9a5a4f70dcae08
*/
public async getSignedURL(endpoint: string, queryParams: QueryParams, date: Date = new Date()): Promise<string> {
// Refresh credentials
if (typeof this.credentials.getPromise === 'function') {
await this.credentials.getPromise();
}
validateValueNonNil(this.credentials.accessKeyId, 'credentials.accessKeyId');
validateValueNonNil(this.credentials.secretAccessKey, 'credentials.secretAccessKey');
// Prepare date strings
const datetimeString = SigV4RequestSigner.getDateTimeString(date);
const dateString = SigV4RequestSigner.getDateString(date);
// Validate and parse endpoint
const protocol = 'wss';
const urlProtocol = `${protocol}://`;
if (!endpoint.startsWith(urlProtocol)) {
throw new Error(`Endpoint '${endpoint}' is not a secure WebSocket endpoint. It should start with '${urlProtocol}'.`);
}
if (endpoint.includes('?')) {
throw new Error(`Endpoint '${endpoint}' should not contain any query parameters.`);
}
const pathStartIndex = endpoint.indexOf('/', urlProtocol.length);
let host;
let path;
if (pathStartIndex < 0) {
host = endpoint.substring(urlProtocol.length);
path = '/';
} else {
host = endpoint.substring(urlProtocol.length, pathStartIndex);
path = endpoint.substring(pathStartIndex);
}
const signedHeaders = ['host'].join(';');
// Prepare method
const method = 'GET'; // Method is always GET for signed URLs
// Prepare canonical query string
const credentialScope = dateString + '/' + this.region + '/' + this.service + '/' + 'aws4_request';
const canonicalQueryParams = Object.assign({}, queryParams, {
'X-Amz-Algorithm': SigV4RequestSigner.DEFAULT_ALGORITHM,
'X-Amz-Credential': this.credentials.accessKeyId + '/' + credentialScope,
'X-Amz-Date': datetimeString,
'X-Amz-Expires': '299',
'X-Amz-SignedHeaders': signedHeaders,
});
if (this.credentials.sessionToken) {
Object.assign(canonicalQueryParams, {
'X-Amz-Security-Token': this.credentials.sessionToken,
});
}
const canonicalQueryString = SigV4RequestSigner.createQueryString(canonicalQueryParams);
// Prepare canonical headers
const canonicalHeaders = {
host,
};
const canonicalHeadersString = SigV4RequestSigner.createHeadersString(canonicalHeaders);
// Prepare payload hash
const payloadHash = await SigV4RequestSigner.sha256('');
// Combine canonical request parts into a canonical request string and hash
const canonicalRequest = [method, path, canonicalQueryString, canonicalHeadersString, signedHeaders, payloadHash].join('\n');
const canonicalRequestHash = await SigV4RequestSigner.sha256(canonicalRequest);
// Create signature
const stringToSign = [SigV4RequestSigner.DEFAULT_ALGORITHM, datetimeString, credentialScope, canonicalRequestHash].join('\n');
const signingKey = await this.getSignatureKey(dateString);
const signature = await SigV4RequestSigner.toHex(await SigV4RequestSigner.hmac(signingKey, stringToSign));
// Add signature to query params
const signedQueryParams = Object.assign({}, canonicalQueryParams, {
'X-Amz-Signature': signature,
});
// Create signed URL
return protocol + '://' + host + path + '?' + SigV4RequestSigner.createQueryString(signedQueryParams);
}
/**
* Utility method for generating the key to use for calculating the signature. This combines together the date string, region, service name, and secret
* access key.
*
* @see https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
*/
private async getSignatureKey(dateString: string): Promise<ArrayBuffer> {
const kDate = await SigV4RequestSigner.hmac('AWS4' + this.credentials.secretAccessKey, dateString);
const kRegion = await SigV4RequestSigner.hmac(kDate, this.region);
const kService = await SigV4RequestSigner.hmac(kRegion, this.service);
return await SigV4RequestSigner.hmac(kService, 'aws4_request');
}
/**
* Utility method for converting a map of headers to a string for signing.
*/
private static createHeadersString(headers: Headers): string {
return Object.keys(headers)
.map(header => `${header}:${headers[header]}\n`)
.join();
}
/**
* Utility method for converting a map of query parameters to a string with the parameter names sorted.
*/
private static createQueryString(queryParams: QueryParams): string {
return Object.keys(queryParams)
.sort()
.map(key => `${key}=${encodeURIComponent(queryParams[key])}`)
.join('&');
}
/**
* Gets a datetime string for the given date to use for signing. For example: "20190927T165210Z"
* @param date
*/
private static getDateTimeString(date: Date): string {
return date
.toISOString()
.replace(/\.\d{3}Z$/, 'Z')
.replace(/[:\-]/g, '');
}
/**
* Gets a date string for the given date to use for signing. For example: "20190927"
* @param date
*/
private static getDateString(date: Date): string {
return this.getDateTimeString(date).substring(0, 8);
}
private static async sha256(message: string): Promise<string> {
const hashBuffer = await crypto.subtle.digest({ name: 'SHA-256' }, this.toUint8Array(message));
return this.toHex(hashBuffer);
}
private static async hmac(key: string | ArrayBuffer, message: string): Promise<ArrayBuffer> {
const keyBuffer = typeof key === 'string' ? this.toUint8Array(key).buffer : key;
const messageBuffer = this.toUint8Array(message).buffer;
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{
name: 'HMAC',
hash: {
name: 'SHA-256',
},
},
false,
['sign'],
);
return await crypto.subtle.sign({ name: 'HMAC', hash: { name: 'SHA-256' } }, cryptoKey, messageBuffer);
}
/**
* Note that this implementation does not work with two-byte characters.
* However, no inputs into a signed signaling service request should have two-byte characters.
*/
private static toUint8Array(input: string): Uint8Array {
const buf = new ArrayBuffer(input.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = input.length; i < strLen; i++) {
bufView[i] = input.charCodeAt(i);
}
return bufView;
}
private static toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
}