@webex/internal-plugin-encryption 2.59.3-next.1 → 2.59.4

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.
Files changed (42) hide show
  1. package/.eslintrc.js +6 -6
  2. package/README.md +42 -42
  3. package/babel.config.js +3 -3
  4. package/dist/config.js +21 -21
  5. package/dist/config.js.map +1 -1
  6. package/dist/encryption.js +57 -57
  7. package/dist/encryption.js.map +1 -1
  8. package/dist/ensure-buffer.browser.js +7 -7
  9. package/dist/ensure-buffer.browser.js.map +1 -1
  10. package/dist/ensure-buffer.js +7 -7
  11. package/dist/ensure-buffer.js.map +1 -1
  12. package/dist/index.js +2 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/kms-batcher.js +38 -38
  15. package/dist/kms-batcher.js.map +1 -1
  16. package/dist/kms-certificate-validation.js +50 -50
  17. package/dist/kms-certificate-validation.js.map +1 -1
  18. package/dist/kms-dry-error-interceptor.js +15 -15
  19. package/dist/kms-dry-error-interceptor.js.map +1 -1
  20. package/dist/kms-errors.js +16 -16
  21. package/dist/kms-errors.js.map +1 -1
  22. package/dist/kms.js +171 -171
  23. package/dist/kms.js.map +1 -1
  24. package/jest.config.js +3 -3
  25. package/package.json +19 -20
  26. package/process +1 -1
  27. package/src/config.js +50 -50
  28. package/src/encryption.js +257 -257
  29. package/src/ensure-buffer.browser.js +37 -37
  30. package/src/ensure-buffer.js +20 -20
  31. package/src/index.js +159 -159
  32. package/src/kms-batcher.js +158 -158
  33. package/src/kms-certificate-validation.js +232 -232
  34. package/src/kms-dry-error-interceptor.js +65 -65
  35. package/src/kms-errors.js +147 -147
  36. package/src/kms.js +848 -848
  37. package/test/integration/spec/encryption.js +448 -448
  38. package/test/integration/spec/kms.js +800 -800
  39. package/test/integration/spec/payload-transfom.js +97 -97
  40. package/test/unit/spec/encryption.js +82 -82
  41. package/test/unit/spec/kms-certificate-validation.js +165 -165
  42. package/test/unit/spec/kms.js +103 -103
package/src/kms.js CHANGED
@@ -1,848 +1,848 @@
1
- /*!
2
- * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
- */
4
-
5
- import querystring from 'querystring';
6
- import util from 'util';
7
-
8
- import {safeSetTimeout} from '@webex/common-timers';
9
- import {oneFlight} from '@webex/common';
10
- import {WebexPlugin} from '@webex/webex-core';
11
- import {Context, Request, Response} from 'node-kms';
12
- import jose from 'node-jose';
13
- import {omit} from 'lodash';
14
- import uuid from 'uuid';
15
-
16
- import KMSBatcher, {TIMEOUT_SYMBOL} from './kms-batcher';
17
- import validateKMS, {KMSError} from './kms-certificate-validation';
18
-
19
- const contexts = new WeakMap();
20
- const kmsDetails = new WeakMap();
21
- const partialContexts = new WeakMap();
22
-
23
- const consoleDebug = require('debug')('kms');
24
-
25
- /**
26
- * @class
27
- */
28
- const KMS = WebexPlugin.extend({
29
- namespace: 'Encryption',
30
-
31
- children: {
32
- batcher: KMSBatcher,
33
- },
34
-
35
- /**
36
- * Binds a key to a resource
37
- * @param {Object} options
38
- * @param {KMSResourceObject} options.kro
39
- * @param {string} options.kroUri
40
- * @param {Key} options.key
41
- * @param {string} options.keyUri
42
- * @returns {Promise<Key>}
43
- */
44
- bindKey({kro, kroUri, key, keyUri}) {
45
- kroUri = kroUri || kro.uri;
46
- keyUri = keyUri || key.uri;
47
-
48
- this.logger.info('kms: binding key to resource');
49
-
50
- /* istanbul ignore if */
51
- if (!kroUri) {
52
- return Promise.reject(new Error('`kro` or `kroUri` is required'));
53
- }
54
-
55
- /* istanbul ignore if */
56
- if (!keyUri) {
57
- return Promise.reject(new Error('`key` or `keyUri` is required'));
58
- }
59
-
60
- return this.request({
61
- method: 'update',
62
- resourceUri: kroUri,
63
- uri: keyUri,
64
- }).then((res) => {
65
- this.logger.info('kms: bound key to resource');
66
-
67
- return res.key;
68
- });
69
- },
70
-
71
- /**
72
- * Creates a new KMS Resource
73
- * @param {Object} options
74
- * @param {Array<string>} options.userIds
75
- * @param {Array<string>} options.keyUris
76
- * @param {Key} options.key
77
- * @param {Array<Keys>} options.keys
78
- * @returns {Promise<KMSResourceObject>}
79
- */
80
- createResource({userIds, keyUris, key, keys}) {
81
- keyUris = keyUris || [];
82
- /* istanbul ignore if */
83
- if (keys) {
84
- keyUris = keys.reduce((uris, k) => {
85
- uris.push(k.uri);
86
-
87
- return uris;
88
- }, keyUris);
89
- }
90
-
91
- /* istanbul ignore else */
92
- if (key) {
93
- keyUris.push(key.uri);
94
- }
95
-
96
- /* istanbul ignore if */
97
- if (keyUris.length === 0) {
98
- return Promise.reject(new Error('Cannot create KMS Resource without at least one keyUri'));
99
- }
100
-
101
- this.logger.info('kms: creating resource');
102
-
103
- return this.request({
104
- method: 'create',
105
- uri: '/resources',
106
- userIds,
107
- keyUris,
108
- }).then((res) => {
109
- this.logger.info('kms: created resource');
110
-
111
- return res.resource;
112
- });
113
- },
114
-
115
- /**
116
- * Authorizes a user or KRO to a KRO
117
- * @param {Object} options
118
- * @param {Array<string>} options.userIds
119
- * @param {Array<string>} options.authIds interchangable with userIds
120
- * @param {KMSResourceObject} options.kro the target kro
121
- * @param {string} options.kroUri
122
- * @returns {Promise<KMSAuthorizationObject>}
123
- */
124
- addAuthorization({userIds, authIds, kro, kroUri}) {
125
- userIds = userIds || [];
126
- kroUri = kroUri || kro.uri;
127
-
128
- if (authIds) {
129
- userIds = userIds.concat(authIds);
130
- }
131
-
132
- /* istanbul ignore if */
133
- if (userIds.length === 0) {
134
- return Promise.reject(new Error('Cannot add authorization without userIds or authIds'));
135
- }
136
-
137
- /* istanbul ignore if */
138
- if (!kroUri) {
139
- return Promise.reject(new Error('`kro` or `kroUri` is required'));
140
- }
141
-
142
- this.logger.info('kms: adding authorization to kms resource');
143
-
144
- return this.request({
145
- method: 'create',
146
- uri: '/authorizations',
147
- resourceUri: kroUri,
148
- userIds,
149
- }).then((res) => {
150
- this.logger.info('kms: added authorization');
151
-
152
- return res.authorizations;
153
- });
154
- },
155
-
156
- /**
157
- * Retrieve a list of users that have been authorized to the KRO
158
- * @param {Object} options
159
- * @param {KMSResourceObject} options.kro the target kro
160
- * @param {string} options.kroUri
161
- * @returns {Array<authId>}
162
- */
163
- listAuthorizations({kro, kroUri}) {
164
- kroUri = kroUri || kro.uri;
165
- /* istanbul ignore if */
166
- if (!kroUri) {
167
- return Promise.reject(new Error('`kro` or `kroUri` is required'));
168
- }
169
-
170
- return this.request({
171
- method: 'retrieve',
172
- uri: `${kroUri}/authorizations`,
173
- }).then((res) => {
174
- this.logger.info('kms: retrieved authorization list');
175
-
176
- return res.authorizations;
177
- });
178
- },
179
-
180
- /**
181
- * Deauthorizes a user or KRO from a KRO
182
- * @param {Object} options
183
- * @param {string} options.userId
184
- * @param {string} options.authId interchangable with userIds
185
- * @param {KMSResourceObject} options.kro the target kro
186
- * @param {string} options.kroUri
187
- * @returns {Promise<KMSAuthorizationObject>}
188
- */
189
- removeAuthorization({authId, userId, kro, kroUri}) {
190
- authId = authId || userId;
191
- kroUri = kroUri || kro.uri;
192
-
193
- /* istanbul ignore if */
194
- if (!authId) {
195
- return Promise.reject(new Error('Cannot remove authorization without authId'));
196
- }
197
-
198
- /* istanbul ignore if */
199
- if (!kroUri) {
200
- return Promise.reject(new Error('`kro` or `kroUri` is required'));
201
- }
202
-
203
- this.logger.info('kms: removing authorization from kms resource');
204
-
205
- return this.request({
206
- method: 'delete',
207
- uri: `${kroUri}/authorizations?${querystring.stringify({authId})}`,
208
- }).then((res) => {
209
- this.logger.info('kms: removed authorization');
210
-
211
- return res.authorizations;
212
- });
213
- },
214
-
215
- /**
216
- * Requests `count` unbound keys from the kms
217
- * @param {Object} options
218
- * @param {Number} options.count
219
- * @returns {Array<Key>}
220
- */
221
- createUnboundKeys({count}) {
222
- this.logger.info(`kms: request ${count} unbound keys`);
223
-
224
- /* istanbul ignore if */
225
- if (!count) {
226
- return Promise.reject(new Error('`options.count` is required'));
227
- }
228
-
229
- return this.request({
230
- method: 'create',
231
- uri: '/keys',
232
- count,
233
- }).then((res) => {
234
- this.logger.info('kms: received unbound keys');
235
-
236
- return Promise.all(res.keys.map(this.asKey));
237
- });
238
- },
239
-
240
- /**
241
- * @typedef {Object} FetchPublicKeyResponse
242
- * @property {number} status 200,400(Bad Request: Request payload missing info),404(Not Found: HSM Public Key not found),501(Not Implemented: This KMS does not support BYOK),502(Bad Gateway: KMS could not communicate with HSM)
243
- * @property {UUID} requestId this is should be unique, used for debug.
244
- * @property {string} publicKey
245
- */
246
- /**
247
- * get public key from kms
248
- * @param {Object} options
249
- * @param {UUID} options.assignedOrgId the orgId
250
- * @returns {Promise.<FetchPublicKeyResponse>} response of get public key api
251
- */
252
- fetchPublicKey({assignedOrgId}) {
253
- this.logger.info('kms: fetch public key for byok');
254
-
255
- return this.request({
256
- method: 'retrieve',
257
- uri: '/publicKey',
258
- assignedOrgId,
259
- }).then((res) => {
260
- this.logger.info('kms: received public key');
261
-
262
- return res.publicKey;
263
- });
264
- },
265
-
266
- /**
267
- * @typedef {Object} UploadCmkResponse
268
- * @property {number} status
269
- * @property {UUID} requestId
270
- * @property {string} uri
271
- * @property {string} keysState
272
- */
273
- /**
274
- * upload master key for one org.
275
- * @param {Object} options
276
- * @param {UUID} options.assignedOrgId the orgId
277
- * @param {string} options.customerMasterKey the master key
278
- * @param {boolean} options.awsKms enable amazon aws keys
279
- * @returns {Promise.<UploadCmkResponse>} response of upload CMK api
280
- */
281
- uploadCustomerMasterKey({assignedOrgId, customerMasterKey, awsKms = false}) {
282
- this.logger.info('kms: upload customer master key for byok');
283
-
284
- return this.request({
285
- method: 'create',
286
- uri: awsKms ? '/awsKmsCmk' : '/cmk',
287
- assignedOrgId,
288
- customerMasterKey,
289
- requestId: uuid.v4(),
290
- }).then((res) => {
291
- this.logger.info('kms: finish to upload customer master key');
292
-
293
- return res;
294
- });
295
- },
296
-
297
- /**
298
- * get all customer master keys for one org.
299
- * @param {Object} options
300
- * @param {UUID} options.assignedOrgId the orgId
301
- * @param {boolean} options.awsKms enable amazon aws keys
302
- * @returns {Promise.<ActivateCmkResponse>} response of list CMKs api
303
- */
304
- listAllCustomerMasterKey({assignedOrgId, awsKms = false}) {
305
- this.logger.info('kms: get all customer master keys for byok');
306
-
307
- return this.request({
308
- method: 'retrieve',
309
- uri: awsKms ? '/awsKmsCmk' : '/cmk',
310
- assignedOrgId,
311
- requestId: uuid.v4(),
312
- }).then((res) => {
313
- this.logger.info('kms: finish to get all customer master keys');
314
-
315
- return res;
316
- });
317
- },
318
-
319
- /**
320
- * @typedef {Object} ActivateCmkResponse
321
- * @property {number} status
322
- * @property {UUID} requestId
323
- * @property {Array<CMK>} customerMasterKeys
324
- */
325
- /**
326
- *
327
- * @typedef {Object} CMK
328
- * @property {string} usageState
329
- * @property {UUID} assignedOrgId
330
- * @property {string} uri
331
- * @property {string} source
332
- * @property {Date | undefined} stateUpdatedOn
333
- * @property {Date | undefined} rotation
334
- */
335
- /**
336
- * change one customer master key state for one org.
337
- * delete pending key, then the keyState should be 'removedclean';
338
- * active pending key, then the keyState should be 'active';
339
- *
340
- * @param {Object} options
341
- * @param {string} options.keyId the id of one customer master key, it should be a url
342
- * @param {string} options.keyState one of the following: PENDING, RECOVERING,ACTIVE,REVOKED,DEACTIVATED,REENCRYPTING,RETIRED,DELETED,DISABLED,REMOVEDCLEAN,REMOVEDDIRTY;
343
- * @param {UUID} options.assignedOrgId the orgId
344
- * @returns {Promise.<ActivateCmkResponse>} response of list CMKs api
345
- */
346
- changeCustomerMasterKeyState({keyId, keyState, assignedOrgId}) {
347
- this.logger.info('kms: change one customer master key state for byok');
348
-
349
- return this.request({
350
- method: 'update',
351
- uri: keyId,
352
- keyState,
353
- assignedOrgId,
354
- requestId: uuid.v4(),
355
- }).then((res) => {
356
- this.logger.info('kms: finish to change the customer master key state to {}', keyState);
357
-
358
- return res;
359
- });
360
- },
361
-
362
- /**
363
- * this is for test case. it will delete all CMKs, no matter what their status is. This is mainly for test purpose
364
- * @param {Object} options
365
- * @param {UUID} options.assignedOrgId the orgId
366
- * @param {boolean} options.awsKms enable amazon aws keys
367
- * @returns {Promise.<{status, requestId}>}
368
- */
369
- deleteAllCustomerMasterKeys({assignedOrgId, awsKms = false}) {
370
- this.logger.info('kms: delete all customer master keys at the same time');
371
-
372
- return this.request({
373
- method: 'delete',
374
- uri: awsKms ? '/awsKmsCmk' : '/cmk',
375
- assignedOrgId,
376
- requestId: uuid.v4(),
377
- }).then((res) => {
378
- this.logger.info('kms: finish to delete all customer master keys');
379
-
380
- return res;
381
- });
382
- },
383
-
384
- /**
385
- * return to use global master key for one org.
386
- * @param {Object} options
387
- * @param {UUID} options.assignedOrgId the orgId
388
- * @returns {Promise.<ActivateCmkResponse>} response of activate CMK api
389
- */
390
- useGlobalMasterKey({assignedOrgId}) {
391
- this.logger.info('kms: return to use global master key');
392
-
393
- return this.request({
394
- method: 'update',
395
- uri: 'default',
396
- keyState: 'ACTIVE',
397
- assignedOrgId,
398
- requestId: uuid.v4(),
399
- }).then((res) => {
400
- this.logger.info('kms: finish to return to global master key');
401
-
402
- return res;
403
- });
404
- },
405
-
406
- /**
407
- * Fetches the specified key from the kms
408
- * @param {Object} options
409
- * @param {string} options.uri
410
- * @param {string} options.onBehalfOf The id of a user, upon whose behalf, the key is to be retrieved or undefined if retrieval is for the active user
411
- * @returns {Promise<Key>}
412
- */
413
- // Ideally, this would be done via the kms batcher, but other than request id,
414
- // there isn't any other userful key in a kms response to match it to a
415
- // request. as such, we need the batcher to group requests, but one flight to
416
- // make sure we don't make the same request multiple times.
417
- @oneFlight({
418
- keyFactory: ({uri, onBehalfOf}) => `${uri}/${onBehalfOf}`,
419
- })
420
- fetchKey({uri, onBehalfOf}) {
421
- /* istanbul ignore if */
422
- if (!uri) {
423
- return Promise.reject(new Error('`options.uri` is required'));
424
- }
425
-
426
- this.logger.info('kms: fetching key');
427
-
428
- return this.request(
429
- {
430
- method: 'retrieve',
431
- uri,
432
- },
433
- {onBehalfOf}
434
- ).then((res) => {
435
- this.logger.info('kms: fetched key');
436
-
437
- return this.asKey(res.key);
438
- });
439
- },
440
-
441
- /**
442
- * Pings the kms. Mostly for testing
443
- * @returns {Promise}
444
- */
445
- ping() {
446
- return this.request({
447
- method: 'update',
448
- uri: '/ping',
449
- });
450
- },
451
-
452
- /**
453
- * Ensures a key obect is Key instance
454
- * @param {Object} key
455
- * @returns {Promise<Key>}
456
- */
457
- asKey(key) {
458
- return jose.JWK.asKey(key.jwk).then((jwk) => {
459
- key.jwk = jwk;
460
-
461
- return key;
462
- });
463
- },
464
-
465
- /**
466
- * Adds appropriate metadata to the KMS request
467
- * @param {Object} payload
468
- * @param {Object} onBehalfOf Optional parameter to prepare the request on behalf of another user
469
- * @returns {Promise<KMS.Request>}
470
- */
471
- prepareRequest(payload, onBehalfOf) {
472
- const isECDHRequest = payload.method === 'create' && payload.uri.includes('/ecdhe');
473
-
474
- return Promise.resolve(isECDHRequest ? partialContexts.get(this) : this._getContext()).then(
475
- (context) => {
476
- this.logger.info(`kms: wrapping ${isECDHRequest ? 'ephemeral key' : 'kms'} request`);
477
- const req = new Request(payload);
478
- let requestContext = context;
479
-
480
- if (onBehalfOf) {
481
- requestContext = this._contextOnBehalfOf(context, onBehalfOf);
482
- }
483
-
484
- return req.wrap(requestContext, {serverKey: isECDHRequest}).then(() => {
485
- /* istanbul ignore else */
486
- if (process.env.NODE_ENV !== 'production') {
487
- this.logger.info(
488
- 'kms: request payload',
489
- util.inspect(omit(JSON.parse(JSON.stringify(req)), 'wrapped'), {depth: null})
490
- );
491
- }
492
-
493
- return req;
494
- });
495
- }
496
- );
497
- },
498
-
499
- /**
500
- * Accepts a kms message event, decrypts it, and passes it to the batcher
501
- * @param {Object} event
502
- * @returns {Promise<Object>}
503
- */
504
- processKmsMessageEvent(event) {
505
- this.logger.info('kms: received kms message');
506
-
507
- return Promise.all(
508
- event.encryption.kmsMessages.map((kmsMessage, index) =>
509
- this._isECDHEMessage(kmsMessage).then((isECDHMessage) => {
510
- this.logger.info(`kms: received ${isECDHMessage ? 'ecdhe' : 'normal'} message`);
511
- const res = new Response(kmsMessage);
512
-
513
- return (
514
- Promise.resolve(isECDHMessage ? partialContexts.get(this) : contexts.get(this))
515
- // eslint-disable-next-line max-nested-callbacks
516
- .then((context) => res.unwrap(context))
517
- // eslint-disable-next-line max-nested-callbacks
518
- .then(() => {
519
- if (process.env.NODE_ENV !== 'production') {
520
- this.logger.info(
521
- 'kms: response payload',
522
- util.inspect(omit(JSON.parse(JSON.stringify(res)), 'wrapped'), {depth: null})
523
- );
524
- }
525
- })
526
- // eslint-disable-next-line max-nested-callbacks
527
- .then(() => {
528
- event.encryption.kmsMessages[index] = res;
529
- })
530
- // eslint-disable-next-line max-nested-callbacks
531
- .then(() => res)
532
- );
533
- })
534
- )
535
- )
536
- .then(() => this.batcher.processKmsMessageEvent(event))
537
- .catch((reason) => {
538
- this.logger.error('kms: decrypt failed', reason.stack);
539
-
540
- return Promise.reject(reason);
541
- })
542
- .then(() => event);
543
- },
544
-
545
- /**
546
- * Decrypts a kms message
547
- * @param {Object} kmsMessage
548
- * @returns {Promise<Object>}
549
- */
550
- decryptKmsMessage(kmsMessage) {
551
- const res = new Response(kmsMessage);
552
-
553
- return contexts
554
- .get(this)
555
- .then((context) => res.unwrap(context))
556
- .then(() => res.body);
557
- },
558
-
559
- /**
560
- * Determines if the kms message is an ecdhe message or a normal message
561
- * @param {Object} kmsMessage
562
- * @returns {Promise<boolean>}
563
- */
564
- _isECDHEMessage(kmsMessage) {
565
- return this._getKMSStaticPubKey().then((kmsStaticPubKey) => {
566
- const fields = kmsMessage.split('.');
567
-
568
- if (fields.length !== 3) {
569
- return false;
570
- }
571
-
572
- const header = JSON.parse(jose.util.base64url.decode(fields[0]));
573
-
574
- return header.kid === kmsStaticPubKey.kid;
575
- });
576
- },
577
-
578
- /**
579
- * Sends a request to the kms
580
- * @param {Object} payload
581
- * @param {Object} options
582
- * @param {Number} options.timeout (internal)
583
- * @param {string} options.onBehalfOf Run the request on behalf of another user (UUID), used in compliance scenarios
584
- * @returns {Promise<Object>}
585
- */
586
- request(payload, {timeout, onBehalfOf} = {}) {
587
- timeout = timeout || this.config.kmsInitialTimeout;
588
-
589
- // Note: this should only happen when we're using the async kms batcher;
590
- // once we implement the sync batcher, this'll need to be smarter.
591
- return (
592
- this.webex.internal.mercury
593
- .connect()
594
- .then(() => this.prepareRequest(payload, onBehalfOf))
595
- .then((req) => {
596
- req[TIMEOUT_SYMBOL] = timeout;
597
-
598
- return this.batcher.request(req);
599
- })
600
- // High complexity is due to attempt at test mode resiliency
601
- // eslint-disable-next-line complexity
602
- .catch((reason) => {
603
- if (
604
- process.env.NODE_ENV === 'test' &&
605
- (reason.status === 403 || reason.statusCode === 403) &&
606
- reason.message.match(
607
- /Failed to resolve authorization token in KmsMessage request for user/
608
- )
609
- ) {
610
- this.logger.warn('kms: rerequested key due to test-mode kms auth failure');
611
-
612
- return this.request(payload, {onBehalfOf});
613
- }
614
-
615
- // KMS Error. Notify the user
616
- if (reason instanceof KMSError) {
617
- this.webex.trigger('client:InvalidRequestError');
618
-
619
- return Promise.reject(reason);
620
- }
621
-
622
- // Ideally, most or all of the code below would go in kms-batcher, but
623
- // but batching needs at least one more round of refactoring for that to
624
- // work.
625
- if (!reason.statusCode && !reason.status) {
626
- /* istanbul ignore else */
627
- if (process.env.NODE_ENV !== 'production') {
628
- /* istanbul ignore next: reason.stack vs stack difficult to control in test */
629
- this.logger.info('kms: request error', reason.stack || reason);
630
- }
631
-
632
- consoleDebug(`timeout ${timeout}`);
633
- timeout *= 2;
634
-
635
- if (timeout >= this.config.ecdhMaxTimeout) {
636
- this.logger.info('kms: exceeded maximum KMS request retries');
637
-
638
- return Promise.reject(reason);
639
- }
640
-
641
- // Peek ahead to make sure we don't reset the timeout if the next timeout
642
- // will exceed the maximum timeout for renegotiating ECDH keys.
643
- const nextTimeout = timeout * 2;
644
-
645
- if (timeout >= this.config.kmsMaxTimeout && nextTimeout < this.config.ecdhMaxTimeout) {
646
- this.logger.info(
647
- 'kms: exceeded maximum KMS request retries; negotiating new ecdh key'
648
- );
649
-
650
- /* istanbul ignore else */
651
- if (process.env.NODE_ENV !== 'production') {
652
- this.logger.info('kms: timeout/maxtimeout', timeout, this.config.kmsMaxTimeout);
653
- }
654
-
655
- contexts.delete(this);
656
- timeout = 0;
657
- }
658
-
659
- return this.request(payload, {timeout, onBehalfOf});
660
- }
661
-
662
- return Promise.reject(reason);
663
- })
664
- );
665
- },
666
-
667
- /**
668
- * @private
669
- * @returns {Promise<string>}
670
- */
671
- _getAuthorization() {
672
- return this.webex.credentials.getUserToken('spark:kms').then((token) => token.access_token);
673
- },
674
-
675
- @oneFlight
676
- /**
677
- * @private
678
- * @param {String} onBehalfOf create context on behalf of another user, undefined when this is not necessary
679
- * @returns {Promise<Object>}
680
- */
681
- _getContext() {
682
- let promise = contexts.get(this);
683
-
684
- if (!promise) {
685
- promise = this._prepareContext();
686
- contexts.set(this, promise);
687
- promise.then((context) => {
688
- const expiresIn = context.ephemeralKey.expirationDate - Date.now() - 30000;
689
-
690
- safeSetTimeout(() => contexts.delete(this), expiresIn);
691
- });
692
- }
693
-
694
- return Promise.all([promise, this._getAuthorization()]).then(([context, authorization]) => {
695
- context.clientInfo.credential.bearer = authorization;
696
-
697
- return context;
698
- });
699
- },
700
-
701
- /**
702
- * @private
703
- * @returns {Promise<Object>}
704
- */
705
- _getKMSCluster() {
706
- this.logger.info('kms: retrieving KMS cluster');
707
-
708
- return this._getKMSDetails().then(({kmsCluster}) => kmsCluster);
709
- },
710
-
711
- /**
712
- * @private
713
- * @returns {Promise<Object>}
714
- */
715
- _getKMSDetails() {
716
- let details = kmsDetails.get(this);
717
-
718
- if (!details) {
719
- this.logger.info('kms: fetching KMS details');
720
- details = this.webex
721
- .request({
722
- service: 'encryption',
723
- resource: `/kms/${this.webex.internal.device.userId}`,
724
- })
725
- .then((res) => {
726
- this.logger.info('kms: fetched KMS details');
727
- const {body} = res;
728
-
729
- body.rsaPublicKey = JSON.parse(body.rsaPublicKey);
730
-
731
- return body;
732
- })
733
- .catch((reason) => {
734
- this.logger.error('kms: failed to fetch KMS details', reason);
735
-
736
- return Promise.reject(reason);
737
- });
738
-
739
- kmsDetails.set(this, details);
740
- }
741
-
742
- return details;
743
- },
744
-
745
- /**
746
- * @private
747
- * @returns {Promise<Object>}
748
- */
749
- _getKMSStaticPubKey() {
750
- this.logger.info('kms: retrieving KMS static public key');
751
-
752
- return this._getKMSDetails().then(({rsaPublicKey}) => rsaPublicKey);
753
- },
754
-
755
- /**
756
- * @private
757
- * @returns {Promise<Object>}
758
- */
759
- _prepareContext() {
760
- this.logger.info('kms: creating context');
761
- const context = new Context();
762
-
763
- return Promise.all([
764
- this._getKMSStaticPubKey().then(validateKMS(this.config.caroots)),
765
- this._getAuthorization(),
766
- ])
767
- .then(([kmsStaticPubKey, authorization]) => {
768
- context.clientInfo = {
769
- clientId: this.webex.internal.device.url,
770
- credential: {
771
- userId: this.webex.internal.device.userId,
772
- bearer: authorization,
773
- },
774
- };
775
-
776
- context.serverInfo = {
777
- key: kmsStaticPubKey,
778
- };
779
-
780
- this.logger.info('kms: creating local ephemeral key');
781
-
782
- return context.createECDHKey();
783
- })
784
- .then((localECDHKey) => {
785
- context.ephemeralKey = localECDHKey;
786
- partialContexts.set(this, context);
787
-
788
- return Promise.all([localECDHKey.asKey(), this._getKMSCluster()]);
789
- })
790
- .then(([localECDHKey, cluster]) => {
791
- this.logger.info('kms: submitting ephemeral key request');
792
-
793
- return this.request({
794
- uri: `${cluster}/ecdhe`,
795
- method: 'create',
796
- jwk: localECDHKey.toJSON(),
797
- });
798
- })
799
- .then((res) => {
800
- this.logger.info('kms: deriving final ephemeral key');
801
-
802
- return context.deriveEphemeralKey(res.key);
803
- })
804
- .then((key) => {
805
- context.ephemeralKey = key;
806
- partialContexts.delete(this);
807
- this.logger.info('kms: derived final ephemeral key');
808
-
809
- return context;
810
- })
811
- .catch((reason) => {
812
- this.logger.error('kms: failed to negotiate ephemeral key', reason);
813
-
814
- return Promise.reject(reason);
815
- });
816
- },
817
-
818
- /**
819
- * KMS 'retrieve' requests can be made on behalf of another user. This is useful
820
- * for scenarios such as eDiscovery. i.e. Where an authorized compliance officer is
821
- * entitled to retrieve content generated by any organisational user.
822
- * As the KMSContext is cached, updating it will affect separate requests. Hence when
823
- * making a request onBehalfOf another user create a new context for just this request.
824
- * However this context will be 'light' as it only needs to change one field.
825
- * @param {Object} originalContext - The base context to 'copy'
826
- * @param {String} onBehalfOf - The user specified in the new context
827
- * @returns {Context} A 'copy' of the existing context with a new user specified
828
- * @private
829
- */
830
- _contextOnBehalfOf(originalContext, onBehalfOf) {
831
- const context = new Context();
832
-
833
- context.clientInfo = context.clientInfo = {
834
- clientId: originalContext.clientInfo.clientId,
835
- credential: {
836
- userId: onBehalfOf,
837
- onBehalfOf, // Supports running onBehalfOf self. i.e. A CO which calls onBehalfOf with CO.id.
838
- bearer: originalContext.clientInfo.credential.bearer,
839
- },
840
- };
841
- context.serverInfo = originalContext.serverInfo;
842
- context.ephemeralKey = originalContext.ephemeralKey;
843
-
844
- return context;
845
- },
846
- });
847
-
848
- export default KMS;
1
+ /*!
2
+ * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file.
3
+ */
4
+
5
+ import querystring from 'querystring';
6
+ import util from 'util';
7
+
8
+ import {safeSetTimeout} from '@webex/common-timers';
9
+ import {oneFlight} from '@webex/common';
10
+ import {WebexPlugin} from '@webex/webex-core';
11
+ import {Context, Request, Response} from 'node-kms';
12
+ import jose from 'node-jose';
13
+ import {omit} from 'lodash';
14
+ import uuid from 'uuid';
15
+
16
+ import KMSBatcher, {TIMEOUT_SYMBOL} from './kms-batcher';
17
+ import validateKMS, {KMSError} from './kms-certificate-validation';
18
+
19
+ const contexts = new WeakMap();
20
+ const kmsDetails = new WeakMap();
21
+ const partialContexts = new WeakMap();
22
+
23
+ const consoleDebug = require('debug')('kms');
24
+
25
+ /**
26
+ * @class
27
+ */
28
+ const KMS = WebexPlugin.extend({
29
+ namespace: 'Encryption',
30
+
31
+ children: {
32
+ batcher: KMSBatcher,
33
+ },
34
+
35
+ /**
36
+ * Binds a key to a resource
37
+ * @param {Object} options
38
+ * @param {KMSResourceObject} options.kro
39
+ * @param {string} options.kroUri
40
+ * @param {Key} options.key
41
+ * @param {string} options.keyUri
42
+ * @returns {Promise<Key>}
43
+ */
44
+ bindKey({kro, kroUri, key, keyUri}) {
45
+ kroUri = kroUri || kro.uri;
46
+ keyUri = keyUri || key.uri;
47
+
48
+ this.logger.info('kms: binding key to resource');
49
+
50
+ /* istanbul ignore if */
51
+ if (!kroUri) {
52
+ return Promise.reject(new Error('`kro` or `kroUri` is required'));
53
+ }
54
+
55
+ /* istanbul ignore if */
56
+ if (!keyUri) {
57
+ return Promise.reject(new Error('`key` or `keyUri` is required'));
58
+ }
59
+
60
+ return this.request({
61
+ method: 'update',
62
+ resourceUri: kroUri,
63
+ uri: keyUri,
64
+ }).then((res) => {
65
+ this.logger.info('kms: bound key to resource');
66
+
67
+ return res.key;
68
+ });
69
+ },
70
+
71
+ /**
72
+ * Creates a new KMS Resource
73
+ * @param {Object} options
74
+ * @param {Array<string>} options.userIds
75
+ * @param {Array<string>} options.keyUris
76
+ * @param {Key} options.key
77
+ * @param {Array<Keys>} options.keys
78
+ * @returns {Promise<KMSResourceObject>}
79
+ */
80
+ createResource({userIds, keyUris, key, keys}) {
81
+ keyUris = keyUris || [];
82
+ /* istanbul ignore if */
83
+ if (keys) {
84
+ keyUris = keys.reduce((uris, k) => {
85
+ uris.push(k.uri);
86
+
87
+ return uris;
88
+ }, keyUris);
89
+ }
90
+
91
+ /* istanbul ignore else */
92
+ if (key) {
93
+ keyUris.push(key.uri);
94
+ }
95
+
96
+ /* istanbul ignore if */
97
+ if (keyUris.length === 0) {
98
+ return Promise.reject(new Error('Cannot create KMS Resource without at least one keyUri'));
99
+ }
100
+
101
+ this.logger.info('kms: creating resource');
102
+
103
+ return this.request({
104
+ method: 'create',
105
+ uri: '/resources',
106
+ userIds,
107
+ keyUris,
108
+ }).then((res) => {
109
+ this.logger.info('kms: created resource');
110
+
111
+ return res.resource;
112
+ });
113
+ },
114
+
115
+ /**
116
+ * Authorizes a user or KRO to a KRO
117
+ * @param {Object} options
118
+ * @param {Array<string>} options.userIds
119
+ * @param {Array<string>} options.authIds interchangable with userIds
120
+ * @param {KMSResourceObject} options.kro the target kro
121
+ * @param {string} options.kroUri
122
+ * @returns {Promise<KMSAuthorizationObject>}
123
+ */
124
+ addAuthorization({userIds, authIds, kro, kroUri}) {
125
+ userIds = userIds || [];
126
+ kroUri = kroUri || kro.uri;
127
+
128
+ if (authIds) {
129
+ userIds = userIds.concat(authIds);
130
+ }
131
+
132
+ /* istanbul ignore if */
133
+ if (userIds.length === 0) {
134
+ return Promise.reject(new Error('Cannot add authorization without userIds or authIds'));
135
+ }
136
+
137
+ /* istanbul ignore if */
138
+ if (!kroUri) {
139
+ return Promise.reject(new Error('`kro` or `kroUri` is required'));
140
+ }
141
+
142
+ this.logger.info('kms: adding authorization to kms resource');
143
+
144
+ return this.request({
145
+ method: 'create',
146
+ uri: '/authorizations',
147
+ resourceUri: kroUri,
148
+ userIds,
149
+ }).then((res) => {
150
+ this.logger.info('kms: added authorization');
151
+
152
+ return res.authorizations;
153
+ });
154
+ },
155
+
156
+ /**
157
+ * Retrieve a list of users that have been authorized to the KRO
158
+ * @param {Object} options
159
+ * @param {KMSResourceObject} options.kro the target kro
160
+ * @param {string} options.kroUri
161
+ * @returns {Array<authId>}
162
+ */
163
+ listAuthorizations({kro, kroUri}) {
164
+ kroUri = kroUri || kro.uri;
165
+ /* istanbul ignore if */
166
+ if (!kroUri) {
167
+ return Promise.reject(new Error('`kro` or `kroUri` is required'));
168
+ }
169
+
170
+ return this.request({
171
+ method: 'retrieve',
172
+ uri: `${kroUri}/authorizations`,
173
+ }).then((res) => {
174
+ this.logger.info('kms: retrieved authorization list');
175
+
176
+ return res.authorizations;
177
+ });
178
+ },
179
+
180
+ /**
181
+ * Deauthorizes a user or KRO from a KRO
182
+ * @param {Object} options
183
+ * @param {string} options.userId
184
+ * @param {string} options.authId interchangable with userIds
185
+ * @param {KMSResourceObject} options.kro the target kro
186
+ * @param {string} options.kroUri
187
+ * @returns {Promise<KMSAuthorizationObject>}
188
+ */
189
+ removeAuthorization({authId, userId, kro, kroUri}) {
190
+ authId = authId || userId;
191
+ kroUri = kroUri || kro.uri;
192
+
193
+ /* istanbul ignore if */
194
+ if (!authId) {
195
+ return Promise.reject(new Error('Cannot remove authorization without authId'));
196
+ }
197
+
198
+ /* istanbul ignore if */
199
+ if (!kroUri) {
200
+ return Promise.reject(new Error('`kro` or `kroUri` is required'));
201
+ }
202
+
203
+ this.logger.info('kms: removing authorization from kms resource');
204
+
205
+ return this.request({
206
+ method: 'delete',
207
+ uri: `${kroUri}/authorizations?${querystring.stringify({authId})}`,
208
+ }).then((res) => {
209
+ this.logger.info('kms: removed authorization');
210
+
211
+ return res.authorizations;
212
+ });
213
+ },
214
+
215
+ /**
216
+ * Requests `count` unbound keys from the kms
217
+ * @param {Object} options
218
+ * @param {Number} options.count
219
+ * @returns {Array<Key>}
220
+ */
221
+ createUnboundKeys({count}) {
222
+ this.logger.info(`kms: request ${count} unbound keys`);
223
+
224
+ /* istanbul ignore if */
225
+ if (!count) {
226
+ return Promise.reject(new Error('`options.count` is required'));
227
+ }
228
+
229
+ return this.request({
230
+ method: 'create',
231
+ uri: '/keys',
232
+ count,
233
+ }).then((res) => {
234
+ this.logger.info('kms: received unbound keys');
235
+
236
+ return Promise.all(res.keys.map(this.asKey));
237
+ });
238
+ },
239
+
240
+ /**
241
+ * @typedef {Object} FetchPublicKeyResponse
242
+ * @property {number} status 200,400(Bad Request: Request payload missing info),404(Not Found: HSM Public Key not found),501(Not Implemented: This KMS does not support BYOK),502(Bad Gateway: KMS could not communicate with HSM)
243
+ * @property {UUID} requestId this is should be unique, used for debug.
244
+ * @property {string} publicKey
245
+ */
246
+ /**
247
+ * get public key from kms
248
+ * @param {Object} options
249
+ * @param {UUID} options.assignedOrgId the orgId
250
+ * @returns {Promise.<FetchPublicKeyResponse>} response of get public key api
251
+ */
252
+ fetchPublicKey({assignedOrgId}) {
253
+ this.logger.info('kms: fetch public key for byok');
254
+
255
+ return this.request({
256
+ method: 'retrieve',
257
+ uri: '/publicKey',
258
+ assignedOrgId,
259
+ }).then((res) => {
260
+ this.logger.info('kms: received public key');
261
+
262
+ return res.publicKey;
263
+ });
264
+ },
265
+
266
+ /**
267
+ * @typedef {Object} UploadCmkResponse
268
+ * @property {number} status
269
+ * @property {UUID} requestId
270
+ * @property {string} uri
271
+ * @property {string} keysState
272
+ */
273
+ /**
274
+ * upload master key for one org.
275
+ * @param {Object} options
276
+ * @param {UUID} options.assignedOrgId the orgId
277
+ * @param {string} options.customerMasterKey the master key
278
+ * @param {boolean} options.awsKms enable amazon aws keys
279
+ * @returns {Promise.<UploadCmkResponse>} response of upload CMK api
280
+ */
281
+ uploadCustomerMasterKey({assignedOrgId, customerMasterKey, awsKms = false}) {
282
+ this.logger.info('kms: upload customer master key for byok');
283
+
284
+ return this.request({
285
+ method: 'create',
286
+ uri: awsKms ? '/awsKmsCmk' : '/cmk',
287
+ assignedOrgId,
288
+ customerMasterKey,
289
+ requestId: uuid.v4(),
290
+ }).then((res) => {
291
+ this.logger.info('kms: finish to upload customer master key');
292
+
293
+ return res;
294
+ });
295
+ },
296
+
297
+ /**
298
+ * get all customer master keys for one org.
299
+ * @param {Object} options
300
+ * @param {UUID} options.assignedOrgId the orgId
301
+ * @param {boolean} options.awsKms enable amazon aws keys
302
+ * @returns {Promise.<ActivateCmkResponse>} response of list CMKs api
303
+ */
304
+ listAllCustomerMasterKey({assignedOrgId, awsKms = false}) {
305
+ this.logger.info('kms: get all customer master keys for byok');
306
+
307
+ return this.request({
308
+ method: 'retrieve',
309
+ uri: awsKms ? '/awsKmsCmk' : '/cmk',
310
+ assignedOrgId,
311
+ requestId: uuid.v4(),
312
+ }).then((res) => {
313
+ this.logger.info('kms: finish to get all customer master keys');
314
+
315
+ return res;
316
+ });
317
+ },
318
+
319
+ /**
320
+ * @typedef {Object} ActivateCmkResponse
321
+ * @property {number} status
322
+ * @property {UUID} requestId
323
+ * @property {Array<CMK>} customerMasterKeys
324
+ */
325
+ /**
326
+ *
327
+ * @typedef {Object} CMK
328
+ * @property {string} usageState
329
+ * @property {UUID} assignedOrgId
330
+ * @property {string} uri
331
+ * @property {string} source
332
+ * @property {Date | undefined} stateUpdatedOn
333
+ * @property {Date | undefined} rotation
334
+ */
335
+ /**
336
+ * change one customer master key state for one org.
337
+ * delete pending key, then the keyState should be 'removedclean';
338
+ * active pending key, then the keyState should be 'active';
339
+ *
340
+ * @param {Object} options
341
+ * @param {string} options.keyId the id of one customer master key, it should be a url
342
+ * @param {string} options.keyState one of the following: PENDING, RECOVERING,ACTIVE,REVOKED,DEACTIVATED,REENCRYPTING,RETIRED,DELETED,DISABLED,REMOVEDCLEAN,REMOVEDDIRTY;
343
+ * @param {UUID} options.assignedOrgId the orgId
344
+ * @returns {Promise.<ActivateCmkResponse>} response of list CMKs api
345
+ */
346
+ changeCustomerMasterKeyState({keyId, keyState, assignedOrgId}) {
347
+ this.logger.info('kms: change one customer master key state for byok');
348
+
349
+ return this.request({
350
+ method: 'update',
351
+ uri: keyId,
352
+ keyState,
353
+ assignedOrgId,
354
+ requestId: uuid.v4(),
355
+ }).then((res) => {
356
+ this.logger.info('kms: finish to change the customer master key state to {}', keyState);
357
+
358
+ return res;
359
+ });
360
+ },
361
+
362
+ /**
363
+ * this is for test case. it will delete all CMKs, no matter what their status is. This is mainly for test purpose
364
+ * @param {Object} options
365
+ * @param {UUID} options.assignedOrgId the orgId
366
+ * @param {boolean} options.awsKms enable amazon aws keys
367
+ * @returns {Promise.<{status, requestId}>}
368
+ */
369
+ deleteAllCustomerMasterKeys({assignedOrgId, awsKms = false}) {
370
+ this.logger.info('kms: delete all customer master keys at the same time');
371
+
372
+ return this.request({
373
+ method: 'delete',
374
+ uri: awsKms ? '/awsKmsCmk' : '/cmk',
375
+ assignedOrgId,
376
+ requestId: uuid.v4(),
377
+ }).then((res) => {
378
+ this.logger.info('kms: finish to delete all customer master keys');
379
+
380
+ return res;
381
+ });
382
+ },
383
+
384
+ /**
385
+ * return to use global master key for one org.
386
+ * @param {Object} options
387
+ * @param {UUID} options.assignedOrgId the orgId
388
+ * @returns {Promise.<ActivateCmkResponse>} response of activate CMK api
389
+ */
390
+ useGlobalMasterKey({assignedOrgId}) {
391
+ this.logger.info('kms: return to use global master key');
392
+
393
+ return this.request({
394
+ method: 'update',
395
+ uri: 'default',
396
+ keyState: 'ACTIVE',
397
+ assignedOrgId,
398
+ requestId: uuid.v4(),
399
+ }).then((res) => {
400
+ this.logger.info('kms: finish to return to global master key');
401
+
402
+ return res;
403
+ });
404
+ },
405
+
406
+ /**
407
+ * Fetches the specified key from the kms
408
+ * @param {Object} options
409
+ * @param {string} options.uri
410
+ * @param {string} options.onBehalfOf The id of a user, upon whose behalf, the key is to be retrieved or undefined if retrieval is for the active user
411
+ * @returns {Promise<Key>}
412
+ */
413
+ // Ideally, this would be done via the kms batcher, but other than request id,
414
+ // there isn't any other userful key in a kms response to match it to a
415
+ // request. as such, we need the batcher to group requests, but one flight to
416
+ // make sure we don't make the same request multiple times.
417
+ @oneFlight({
418
+ keyFactory: ({uri, onBehalfOf}) => `${uri}/${onBehalfOf}`,
419
+ })
420
+ fetchKey({uri, onBehalfOf}) {
421
+ /* istanbul ignore if */
422
+ if (!uri) {
423
+ return Promise.reject(new Error('`options.uri` is required'));
424
+ }
425
+
426
+ this.logger.info('kms: fetching key');
427
+
428
+ return this.request(
429
+ {
430
+ method: 'retrieve',
431
+ uri,
432
+ },
433
+ {onBehalfOf}
434
+ ).then((res) => {
435
+ this.logger.info('kms: fetched key');
436
+
437
+ return this.asKey(res.key);
438
+ });
439
+ },
440
+
441
+ /**
442
+ * Pings the kms. Mostly for testing
443
+ * @returns {Promise}
444
+ */
445
+ ping() {
446
+ return this.request({
447
+ method: 'update',
448
+ uri: '/ping',
449
+ });
450
+ },
451
+
452
+ /**
453
+ * Ensures a key obect is Key instance
454
+ * @param {Object} key
455
+ * @returns {Promise<Key>}
456
+ */
457
+ asKey(key) {
458
+ return jose.JWK.asKey(key.jwk).then((jwk) => {
459
+ key.jwk = jwk;
460
+
461
+ return key;
462
+ });
463
+ },
464
+
465
+ /**
466
+ * Adds appropriate metadata to the KMS request
467
+ * @param {Object} payload
468
+ * @param {Object} onBehalfOf Optional parameter to prepare the request on behalf of another user
469
+ * @returns {Promise<KMS.Request>}
470
+ */
471
+ prepareRequest(payload, onBehalfOf) {
472
+ const isECDHRequest = payload.method === 'create' && payload.uri.includes('/ecdhe');
473
+
474
+ return Promise.resolve(isECDHRequest ? partialContexts.get(this) : this._getContext()).then(
475
+ (context) => {
476
+ this.logger.info(`kms: wrapping ${isECDHRequest ? 'ephemeral key' : 'kms'} request`);
477
+ const req = new Request(payload);
478
+ let requestContext = context;
479
+
480
+ if (onBehalfOf) {
481
+ requestContext = this._contextOnBehalfOf(context, onBehalfOf);
482
+ }
483
+
484
+ return req.wrap(requestContext, {serverKey: isECDHRequest}).then(() => {
485
+ /* istanbul ignore else */
486
+ if (process.env.NODE_ENV !== 'production') {
487
+ this.logger.info(
488
+ 'kms: request payload',
489
+ util.inspect(omit(JSON.parse(JSON.stringify(req)), 'wrapped'), {depth: null})
490
+ );
491
+ }
492
+
493
+ return req;
494
+ });
495
+ }
496
+ );
497
+ },
498
+
499
+ /**
500
+ * Accepts a kms message event, decrypts it, and passes it to the batcher
501
+ * @param {Object} event
502
+ * @returns {Promise<Object>}
503
+ */
504
+ processKmsMessageEvent(event) {
505
+ this.logger.info('kms: received kms message');
506
+
507
+ return Promise.all(
508
+ event.encryption.kmsMessages.map((kmsMessage, index) =>
509
+ this._isECDHEMessage(kmsMessage).then((isECDHMessage) => {
510
+ this.logger.info(`kms: received ${isECDHMessage ? 'ecdhe' : 'normal'} message`);
511
+ const res = new Response(kmsMessage);
512
+
513
+ return (
514
+ Promise.resolve(isECDHMessage ? partialContexts.get(this) : contexts.get(this))
515
+ // eslint-disable-next-line max-nested-callbacks
516
+ .then((context) => res.unwrap(context))
517
+ // eslint-disable-next-line max-nested-callbacks
518
+ .then(() => {
519
+ if (process.env.NODE_ENV !== 'production') {
520
+ this.logger.info(
521
+ 'kms: response payload',
522
+ util.inspect(omit(JSON.parse(JSON.stringify(res)), 'wrapped'), {depth: null})
523
+ );
524
+ }
525
+ })
526
+ // eslint-disable-next-line max-nested-callbacks
527
+ .then(() => {
528
+ event.encryption.kmsMessages[index] = res;
529
+ })
530
+ // eslint-disable-next-line max-nested-callbacks
531
+ .then(() => res)
532
+ );
533
+ })
534
+ )
535
+ )
536
+ .then(() => this.batcher.processKmsMessageEvent(event))
537
+ .catch((reason) => {
538
+ this.logger.error('kms: decrypt failed', reason.stack);
539
+
540
+ return Promise.reject(reason);
541
+ })
542
+ .then(() => event);
543
+ },
544
+
545
+ /**
546
+ * Decrypts a kms message
547
+ * @param {Object} kmsMessage
548
+ * @returns {Promise<Object>}
549
+ */
550
+ decryptKmsMessage(kmsMessage) {
551
+ const res = new Response(kmsMessage);
552
+
553
+ return contexts
554
+ .get(this)
555
+ .then((context) => res.unwrap(context))
556
+ .then(() => res.body);
557
+ },
558
+
559
+ /**
560
+ * Determines if the kms message is an ecdhe message or a normal message
561
+ * @param {Object} kmsMessage
562
+ * @returns {Promise<boolean>}
563
+ */
564
+ _isECDHEMessage(kmsMessage) {
565
+ return this._getKMSStaticPubKey().then((kmsStaticPubKey) => {
566
+ const fields = kmsMessage.split('.');
567
+
568
+ if (fields.length !== 3) {
569
+ return false;
570
+ }
571
+
572
+ const header = JSON.parse(jose.util.base64url.decode(fields[0]));
573
+
574
+ return header.kid === kmsStaticPubKey.kid;
575
+ });
576
+ },
577
+
578
+ /**
579
+ * Sends a request to the kms
580
+ * @param {Object} payload
581
+ * @param {Object} options
582
+ * @param {Number} options.timeout (internal)
583
+ * @param {string} options.onBehalfOf Run the request on behalf of another user (UUID), used in compliance scenarios
584
+ * @returns {Promise<Object>}
585
+ */
586
+ request(payload, {timeout, onBehalfOf} = {}) {
587
+ timeout = timeout || this.config.kmsInitialTimeout;
588
+
589
+ // Note: this should only happen when we're using the async kms batcher;
590
+ // once we implement the sync batcher, this'll need to be smarter.
591
+ return (
592
+ this.webex.internal.mercury
593
+ .connect()
594
+ .then(() => this.prepareRequest(payload, onBehalfOf))
595
+ .then((req) => {
596
+ req[TIMEOUT_SYMBOL] = timeout;
597
+
598
+ return this.batcher.request(req);
599
+ })
600
+ // High complexity is due to attempt at test mode resiliency
601
+ // eslint-disable-next-line complexity
602
+ .catch((reason) => {
603
+ if (
604
+ process.env.NODE_ENV === 'test' &&
605
+ (reason.status === 403 || reason.statusCode === 403) &&
606
+ reason.message.match(
607
+ /Failed to resolve authorization token in KmsMessage request for user/
608
+ )
609
+ ) {
610
+ this.logger.warn('kms: rerequested key due to test-mode kms auth failure');
611
+
612
+ return this.request(payload, {onBehalfOf});
613
+ }
614
+
615
+ // KMS Error. Notify the user
616
+ if (reason instanceof KMSError) {
617
+ this.webex.trigger('client:InvalidRequestError');
618
+
619
+ return Promise.reject(reason);
620
+ }
621
+
622
+ // Ideally, most or all of the code below would go in kms-batcher, but
623
+ // but batching needs at least one more round of refactoring for that to
624
+ // work.
625
+ if (!reason.statusCode && !reason.status) {
626
+ /* istanbul ignore else */
627
+ if (process.env.NODE_ENV !== 'production') {
628
+ /* istanbul ignore next: reason.stack vs stack difficult to control in test */
629
+ this.logger.info('kms: request error', reason.stack || reason);
630
+ }
631
+
632
+ consoleDebug(`timeout ${timeout}`);
633
+ timeout *= 2;
634
+
635
+ if (timeout >= this.config.ecdhMaxTimeout) {
636
+ this.logger.info('kms: exceeded maximum KMS request retries');
637
+
638
+ return Promise.reject(reason);
639
+ }
640
+
641
+ // Peek ahead to make sure we don't reset the timeout if the next timeout
642
+ // will exceed the maximum timeout for renegotiating ECDH keys.
643
+ const nextTimeout = timeout * 2;
644
+
645
+ if (timeout >= this.config.kmsMaxTimeout && nextTimeout < this.config.ecdhMaxTimeout) {
646
+ this.logger.info(
647
+ 'kms: exceeded maximum KMS request retries; negotiating new ecdh key'
648
+ );
649
+
650
+ /* istanbul ignore else */
651
+ if (process.env.NODE_ENV !== 'production') {
652
+ this.logger.info('kms: timeout/maxtimeout', timeout, this.config.kmsMaxTimeout);
653
+ }
654
+
655
+ contexts.delete(this);
656
+ timeout = 0;
657
+ }
658
+
659
+ return this.request(payload, {timeout, onBehalfOf});
660
+ }
661
+
662
+ return Promise.reject(reason);
663
+ })
664
+ );
665
+ },
666
+
667
+ /**
668
+ * @private
669
+ * @returns {Promise<string>}
670
+ */
671
+ _getAuthorization() {
672
+ return this.webex.credentials.getUserToken('spark:kms').then((token) => token.access_token);
673
+ },
674
+
675
+ @oneFlight
676
+ /**
677
+ * @private
678
+ * @param {String} onBehalfOf create context on behalf of another user, undefined when this is not necessary
679
+ * @returns {Promise<Object>}
680
+ */
681
+ _getContext() {
682
+ let promise = contexts.get(this);
683
+
684
+ if (!promise) {
685
+ promise = this._prepareContext();
686
+ contexts.set(this, promise);
687
+ promise.then((context) => {
688
+ const expiresIn = context.ephemeralKey.expirationDate - Date.now() - 30000;
689
+
690
+ safeSetTimeout(() => contexts.delete(this), expiresIn);
691
+ });
692
+ }
693
+
694
+ return Promise.all([promise, this._getAuthorization()]).then(([context, authorization]) => {
695
+ context.clientInfo.credential.bearer = authorization;
696
+
697
+ return context;
698
+ });
699
+ },
700
+
701
+ /**
702
+ * @private
703
+ * @returns {Promise<Object>}
704
+ */
705
+ _getKMSCluster() {
706
+ this.logger.info('kms: retrieving KMS cluster');
707
+
708
+ return this._getKMSDetails().then(({kmsCluster}) => kmsCluster);
709
+ },
710
+
711
+ /**
712
+ * @private
713
+ * @returns {Promise<Object>}
714
+ */
715
+ _getKMSDetails() {
716
+ let details = kmsDetails.get(this);
717
+
718
+ if (!details) {
719
+ this.logger.info('kms: fetching KMS details');
720
+ details = this.webex
721
+ .request({
722
+ service: 'encryption',
723
+ resource: `/kms/${this.webex.internal.device.userId}`,
724
+ })
725
+ .then((res) => {
726
+ this.logger.info('kms: fetched KMS details');
727
+ const {body} = res;
728
+
729
+ body.rsaPublicKey = JSON.parse(body.rsaPublicKey);
730
+
731
+ return body;
732
+ })
733
+ .catch((reason) => {
734
+ this.logger.error('kms: failed to fetch KMS details', reason);
735
+
736
+ return Promise.reject(reason);
737
+ });
738
+
739
+ kmsDetails.set(this, details);
740
+ }
741
+
742
+ return details;
743
+ },
744
+
745
+ /**
746
+ * @private
747
+ * @returns {Promise<Object>}
748
+ */
749
+ _getKMSStaticPubKey() {
750
+ this.logger.info('kms: retrieving KMS static public key');
751
+
752
+ return this._getKMSDetails().then(({rsaPublicKey}) => rsaPublicKey);
753
+ },
754
+
755
+ /**
756
+ * @private
757
+ * @returns {Promise<Object>}
758
+ */
759
+ _prepareContext() {
760
+ this.logger.info('kms: creating context');
761
+ const context = new Context();
762
+
763
+ return Promise.all([
764
+ this._getKMSStaticPubKey().then(validateKMS(this.config.caroots)),
765
+ this._getAuthorization(),
766
+ ])
767
+ .then(([kmsStaticPubKey, authorization]) => {
768
+ context.clientInfo = {
769
+ clientId: this.webex.internal.device.url,
770
+ credential: {
771
+ userId: this.webex.internal.device.userId,
772
+ bearer: authorization,
773
+ },
774
+ };
775
+
776
+ context.serverInfo = {
777
+ key: kmsStaticPubKey,
778
+ };
779
+
780
+ this.logger.info('kms: creating local ephemeral key');
781
+
782
+ return context.createECDHKey();
783
+ })
784
+ .then((localECDHKey) => {
785
+ context.ephemeralKey = localECDHKey;
786
+ partialContexts.set(this, context);
787
+
788
+ return Promise.all([localECDHKey.asKey(), this._getKMSCluster()]);
789
+ })
790
+ .then(([localECDHKey, cluster]) => {
791
+ this.logger.info('kms: submitting ephemeral key request');
792
+
793
+ return this.request({
794
+ uri: `${cluster}/ecdhe`,
795
+ method: 'create',
796
+ jwk: localECDHKey.toJSON(),
797
+ });
798
+ })
799
+ .then((res) => {
800
+ this.logger.info('kms: deriving final ephemeral key');
801
+
802
+ return context.deriveEphemeralKey(res.key);
803
+ })
804
+ .then((key) => {
805
+ context.ephemeralKey = key;
806
+ partialContexts.delete(this);
807
+ this.logger.info('kms: derived final ephemeral key');
808
+
809
+ return context;
810
+ })
811
+ .catch((reason) => {
812
+ this.logger.error('kms: failed to negotiate ephemeral key', reason);
813
+
814
+ return Promise.reject(reason);
815
+ });
816
+ },
817
+
818
+ /**
819
+ * KMS 'retrieve' requests can be made on behalf of another user. This is useful
820
+ * for scenarios such as eDiscovery. i.e. Where an authorized compliance officer is
821
+ * entitled to retrieve content generated by any organisational user.
822
+ * As the KMSContext is cached, updating it will affect separate requests. Hence when
823
+ * making a request onBehalfOf another user create a new context for just this request.
824
+ * However this context will be 'light' as it only needs to change one field.
825
+ * @param {Object} originalContext - The base context to 'copy'
826
+ * @param {String} onBehalfOf - The user specified in the new context
827
+ * @returns {Context} A 'copy' of the existing context with a new user specified
828
+ * @private
829
+ */
830
+ _contextOnBehalfOf(originalContext, onBehalfOf) {
831
+ const context = new Context();
832
+
833
+ context.clientInfo = context.clientInfo = {
834
+ clientId: originalContext.clientInfo.clientId,
835
+ credential: {
836
+ userId: onBehalfOf,
837
+ onBehalfOf, // Supports running onBehalfOf self. i.e. A CO which calls onBehalfOf with CO.id.
838
+ bearer: originalContext.clientInfo.credential.bearer,
839
+ },
840
+ };
841
+ context.serverInfo = originalContext.serverInfo;
842
+ context.ephemeralKey = originalContext.ephemeralKey;
843
+
844
+ return context;
845
+ },
846
+ });
847
+
848
+ export default KMS;