@bedrock/kms 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ /*!
2
+ * Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import * as bedrock from '@bedrock/core';
5
+ import * as database from '@bedrock/mongodb';
6
+ import assert from 'assert-plus';
7
+ import pAll from 'p-all';
8
+ import {createRequire} from 'module';
9
+ const require = createRequire(import.meta.url);
10
+ const {LruCache} = require('@digitalbazaar/lru-memoize');
11
+
12
+ const {util: {BedrockError}} = bedrock;
13
+
14
+ // load config defaults
15
+ import './config.js';
16
+
17
+ const USAGE_COUNTER_MAX_CONCURRENCY = 100;
18
+ let KEYSTORE_CONFIG_CACHE;
19
+
20
+ bedrock.events.on('bedrock.init', async () => {
21
+ const cfg = bedrock.config.kms;
22
+ KEYSTORE_CONFIG_CACHE = new LruCache({
23
+ max: cfg.keystoreConfigCache.maxSize,
24
+ maxAge: cfg.keystoreConfigCache.maxAge
25
+ });
26
+ });
27
+
28
+ bedrock.events.on('bedrock-mongodb.ready', async () => {
29
+ await database.openCollections(['kms-keystore']);
30
+
31
+ await database.createIndexes([{
32
+ // cover queries keystore by ID
33
+ collection: 'kms-keystore',
34
+ fields: {'config.id': 1},
35
+ options: {unique: true, background: false}
36
+ }, {
37
+ // cover config queries by controller
38
+ collection: 'kms-keystore',
39
+ fields: {'config.controller': 1},
40
+ options: {unique: false, background: false}
41
+ }, {
42
+ // ensure config uniqueness of reference ID per controller
43
+ collection: 'kms-keystore',
44
+ fields: {'config.controller': 1, 'config.referenceId': 1},
45
+ options: {
46
+ partialFilterExpression: {
47
+ 'config.referenceId': {$exists: true}
48
+ },
49
+ unique: true,
50
+ background: false
51
+ }
52
+ }, {
53
+ // cover counting keystores in use by meter ID, if present
54
+ collection: 'kms-keystore',
55
+ fields: {'config.meterId': 1},
56
+ options: {
57
+ partialFilterExpression: {
58
+ 'config.meterId': {$exists: true}
59
+ },
60
+ unique: false, background: false
61
+ }
62
+ }]);
63
+ });
64
+
65
+ /**
66
+ * An object containing information on the query plan.
67
+ *
68
+ * @typedef {object} ExplainObject
69
+ */
70
+
71
+ /**
72
+ * Establishes a new keystore by inserting its configuration into storage.
73
+ *
74
+ * @param {object} options - The options to use.
75
+ * @param {object} options.config - The keystore configuration.
76
+ *
77
+ * @returns {Promise<object>} The database record.
78
+ */
79
+ export async function insert({config} = {}) {
80
+ assert.object(config, 'config');
81
+ assert.string(config.id, 'config.id');
82
+ assert.string(config.controller, 'config.controller');
83
+ assert.string(config.kmsModule, 'config.kmsModule');
84
+
85
+ // require starting sequence to be 0
86
+ if(config.sequence !== 0) {
87
+ throw new BedrockError(
88
+ 'Keystore config sequence must be "0".',
89
+ 'DataError', {
90
+ public: true,
91
+ httpStatusCode: 400
92
+ });
93
+ }
94
+
95
+ // insert the configuration and get the updated record
96
+ const now = Date.now();
97
+ const meta = {created: now, updated: now};
98
+ const record = {
99
+ meta,
100
+ config
101
+ };
102
+ try {
103
+ const result = await database.collections['kms-keystore'].insertOne(
104
+ record, database.writeOptions);
105
+ return result.ops[0];
106
+ } catch(e) {
107
+ if(!database.isDuplicateError(e)) {
108
+ throw e;
109
+ }
110
+ throw new BedrockError(
111
+ 'Duplicate keystore configuration.',
112
+ 'DuplicateError', {
113
+ public: true,
114
+ httpStatusCode: 409
115
+ }, e);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Retrieves all keystore configs matching the given query.
121
+ *
122
+ * @param {object} options - The options to use.
123
+ * @param {string} options.controller - The controller for the keystores to
124
+ * retrieve.
125
+ * @param {object} [options.query={}] - The query to use.
126
+ * @param {object} [options.options={}] - The query options (eg: 'sort').
127
+ * @param {boolean} [options.explain=false] - An optional explain boolean.
128
+ *
129
+ * @returns {Promise<Array | ExplainObject>} Resolves with the records that
130
+ * matched the query or an ExplainObject if `explain=true`.
131
+ */
132
+ export async function find({
133
+ controller, query = {}, options = {}, explain = false
134
+ } = {}) {
135
+ assert.string(controller, 'options.controller');
136
+ const collection = database.collections['kms-keystore'];
137
+
138
+ // force controller ID
139
+ query['config.controller'] = controller;
140
+ const cursor = await collection.find(query, options);
141
+
142
+ if(explain) {
143
+ return cursor.explain('executionStats');
144
+ }
145
+
146
+ return cursor.toArray();
147
+ }
148
+
149
+ /**
150
+ * Updates a keystore config if its sequence number is next.
151
+ *
152
+ * @param {object} options - The options to use.
153
+ * @param {object} options.config - The keystore configuration.
154
+ * @param {boolean} [options.explain=false] - An optional explain boolean.
155
+ *
156
+ * @returns {Promise<boolean | ExplainObject>} Resolves with `true` on update
157
+ * success or an ExplainObject if `explain=true`.
158
+ */
159
+ export async function update({config, explain = false} = {}) {
160
+ assert.object(config, 'config');
161
+
162
+ const collection = database.collections['kms-keystore'];
163
+ const query = {
164
+ 'config.id': config.id,
165
+ 'config.sequence': config.sequence - 1,
166
+ // it is illegal to change the `kmsModule`, so it must match
167
+ 'config.kmsModule': config.kmsModule
168
+ };
169
+
170
+ if(explain) {
171
+ // 'find().limit(1)' is used here because 'updateOne()' doesn't return a
172
+ // cursor which allows the use of the explain function.
173
+ const cursor = await collection.find(query).limit(1);
174
+ return cursor.explain('executionStats');
175
+ }
176
+
177
+ // updates the keystore configuration
178
+ const result = await collection.updateOne(query, {
179
+ $set: {
180
+ config,
181
+ 'meta.updated': Date.now()
182
+ }
183
+ }, database.writeOptions);
184
+
185
+ if(result.result.n === 0) {
186
+ // no records changed...
187
+ throw new BedrockError(
188
+ 'Could not update keystore configuration. ' +
189
+ 'Record sequence and "kmsModule" do not match or keystore does ' +
190
+ 'not exist.',
191
+ 'InvalidStateError', {
192
+ id: config.id,
193
+ sequence: config.sequence,
194
+ httpStatusCode: 409,
195
+ public: true
196
+ });
197
+ }
198
+
199
+ // delete record from cache
200
+ KEYSTORE_CONFIG_CACHE.delete(config.id);
201
+
202
+ return true;
203
+ }
204
+
205
+ /**
206
+ * Gets a keystore configuration.
207
+ *
208
+ * @param {object} options - The options to use.
209
+ * @param {string} options.id - The ID of the keystore.
210
+ *
211
+ * @returns {Promise<object>} Resolves to `{config, meta}`.
212
+ */
213
+ export async function get({id} = {}) {
214
+ assert.string(id, 'id');
215
+ const fn = () => _getUncachedRecord({id});
216
+ return KEYSTORE_CONFIG_CACHE.memoize({key: id, fn});
217
+ }
218
+
219
+ /**
220
+ * Gets storage statistics for the given meter. This includes the total number
221
+ * of keystores and keys associated with a meter ID, represented as storage
222
+ * units according to this module's configuration.
223
+ *
224
+ * @param {object} options - The options to use.
225
+ * @param {string} options.meterId - The ID of the meter to get.
226
+ * @param {object} options.moduleManager - The KMS module manager to use.
227
+ * @param {AbortSignal} [options.signal] - An abort signal to check.
228
+ * @param {Function} [options.aggregate] - An aggregate function that will
229
+ * receive each keystore config that matches the `meterId` and the current
230
+ * usage; this function may be called to add custom additional usage
231
+ * associated with a keystore.
232
+ * @param {boolean} [options.explain=false] - An optional explain boolean.
233
+ *
234
+ * @returns {Promise<object | ExplainObject>} Resolves with the storage usage
235
+ * for the meter or an ExplainObject if `explain=true`.
236
+ */
237
+ export async function getStorageUsage({
238
+ meterId, moduleManager, signal, aggregate, explain = false
239
+ } = {}) {
240
+ // find all keystores with the given meter ID
241
+ const cursor = await database.collections['kms-keystore'].find(
242
+ {'config.meterId': meterId},
243
+ {projection: {_id: 0, config: 1}});
244
+
245
+ if(explain) {
246
+ return cursor.explain('executionStats');
247
+ }
248
+
249
+ const {storageCost} = bedrock.config.kms;
250
+ const usage = {storage: 0};
251
+ const counters = [];
252
+ while(await cursor.hasNext()) {
253
+ // get next keystore config
254
+ const {config} = await cursor.next();
255
+
256
+ // add storage units for keystore
257
+ usage.storage += storageCost.keystore;
258
+
259
+ // if custom aggregator has been given, call it
260
+ if(aggregate) {
261
+ counters.push(() => {
262
+ _checkComputeStorageSignal({signal, meterId});
263
+ return aggregate({meterId, config, usage});
264
+ });
265
+ }
266
+
267
+ // add storage units for keys in keystore
268
+ const {id: keystoreId, kmsModule} = config;
269
+
270
+ // get KMS module API and ensure its keys can be counted
271
+ const moduleApi = await moduleManager.get({id: kmsModule});
272
+ if(!(moduleApi && typeof moduleApi.getKeyCount === 'function')) {
273
+ throw new BedrockError(
274
+ 'Bedrock KMS Module API is missing "getKeyCount()".',
275
+ 'NotFoundError',
276
+ {kmsModule, httpStatusCode: 404, public: true});
277
+ }
278
+
279
+ // start counting keys in keystore
280
+ counters.push(() => {
281
+ _checkComputeStorageSignal({signal, meterId});
282
+ return _addKeyCount({usage, moduleApi, keystoreId, signal});
283
+ });
284
+
285
+ _checkComputeStorageSignal({signal, meterId});
286
+ }
287
+
288
+ // await any counters that didn't complete
289
+ await pAll(counters, {concurrency: USAGE_COUNTER_MAX_CONCURRENCY});
290
+
291
+ return usage;
292
+ }
293
+
294
+ function _checkComputeStorageSignal({signal, meterId}) {
295
+ if(signal && signal.abort) {
296
+ throw new BedrockError(
297
+ 'Computing metered storage aborted.',
298
+ 'AbortError',
299
+ {meterId, httpStatusCode: 503, public: true});
300
+ }
301
+ }
302
+
303
+ async function _addKeyCount({usage, moduleApi, keystoreId}) {
304
+ const {storageCost} = bedrock.config.kms;
305
+ const {count} = await moduleApi.getKeyCount({keystoreId});
306
+ usage.storage += count * storageCost.key;
307
+ }
308
+
309
+ // exported for testing purposes
310
+ export async function _getUncachedRecord({id, explain = false} = {}) {
311
+ const collection = database.collections['kms-keystore'];
312
+ const query = {'config.id': id};
313
+ const projection = {_id: 0, config: 1, meta: 1};
314
+
315
+ if(explain) {
316
+ // 'find().limit(1)' is used here because 'findOne()' doesn't return a
317
+ // cursor which allows the use of the explain function.
318
+ const cursor = await collection.find(query, {projection}).limit(1);
319
+ return cursor.explain('executionStats');
320
+ }
321
+
322
+ const record = await collection.findOne(query, {projection});
323
+ if(!record) {
324
+ throw new BedrockError(
325
+ 'Keystore configuration not found.',
326
+ 'NotFoundError',
327
+ {keystoreId: id, httpStatusCode: 404, public: true});
328
+ }
329
+ return record;
330
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@bedrock/kms",
3
+ "version": "9.0.0",
4
+ "type": "module",
5
+ "description": "Key management for Bedrock applications",
6
+ "main": "./lib/index.js",
7
+ "scripts": {
8
+ "lint": "eslint ."
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/digitalbazaar/bedrock-kms"
13
+ },
14
+ "keywords": [
15
+ "bedrock"
16
+ ],
17
+ "author": {
18
+ "name": "Digital Bazaar, Inc.",
19
+ "email": "support@digitalbazaar.com",
20
+ "url": "https://digitalbazaar.com"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/digitalbazaar/bedrock-kms/issues"
24
+ },
25
+ "engines": {
26
+ "node": ">=14"
27
+ },
28
+ "homepage": "https://github.com/digitalbazaar/bedrock-kms",
29
+ "dependencies": {
30
+ "@digitalbazaar/lru-memoize": "^2.0.0",
31
+ "p-all": "^4.0.0"
32
+ },
33
+ "peerDependencies": {
34
+ "@bedrock/core": "^5.0.0",
35
+ "@bedrock/did-context": "^3.0.0",
36
+ "@bedrock/did-io": "^7.0.0",
37
+ "@bedrock/jsonld-document-loader": "^2.0.0",
38
+ "@bedrock/mongodb": "^9.0.0",
39
+ "@bedrock/package-manager": "^2.0.0",
40
+ "@bedrock/security-context": "^6.0.0",
41
+ "@bedrock/veres-one-context": "^13.0.0"
42
+ },
43
+ "directories": {
44
+ "lib": "./lib"
45
+ },
46
+ "devDependencies": {
47
+ "eslint": "^7.32.0",
48
+ "eslint-config-digitalbazaar": "^2.8.0",
49
+ "eslint-plugin-jsdoc": "^37.9.7",
50
+ "jsdoc-to-markdown": "^7.1.1"
51
+ }
52
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "env": {
3
+ "mocha": true
4
+ },
5
+ "globals": {
6
+ "assertNoError": true,
7
+ "should": true
8
+ }
9
+ }
@@ -0,0 +1,275 @@
1
+ /*!
2
+ * Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved.
3
+ */
4
+ import {keystores} from '@bedrock/kms';
5
+
6
+ describe('keystores APIs', () => {
7
+ describe('insert API', () => {
8
+ it('throws error on missing config', async () => {
9
+ let err;
10
+ let result;
11
+ try {
12
+ result = await keystores.insert();
13
+ } catch(e) {
14
+ err = e;
15
+ }
16
+ should.not.exist(result);
17
+ should.exist(err);
18
+ err.message.should.contain('config (object) is required');
19
+ });
20
+ it('throws error on missing config.id', async () => {
21
+ let err;
22
+ let result;
23
+ const config = {};
24
+ try {
25
+ result = await keystores.insert({config});
26
+ } catch(e) {
27
+ err = e;
28
+ }
29
+ should.not.exist(result);
30
+ should.exist(err);
31
+ err.message.should.contain('config.id (string) is required');
32
+ });
33
+ it('throws error on missing config.controller', async () => {
34
+ let err;
35
+ let result;
36
+ const config = {
37
+ id: 'https://example.com/keystores/foo',
38
+ };
39
+ try {
40
+ result = await keystores.insert({config});
41
+ } catch(e) {
42
+ err = e;
43
+ }
44
+ should.not.exist(result);
45
+ should.exist(err);
46
+ err.message.should.contain('config.controller (string) is required');
47
+ });
48
+ it('throws error on missing config.kmsModule', async () => {
49
+ let err;
50
+ let result;
51
+ const config = {
52
+ id: 'https://example.com/keystores/foo',
53
+ controller: 'bar',
54
+ };
55
+ try {
56
+ result = await keystores.insert({config});
57
+ } catch(e) {
58
+ err = e;
59
+ }
60
+ should.not.exist(result);
61
+ should.exist(err);
62
+ err.message.should.contain('config.kmsModule (string) is required');
63
+ });
64
+ it('throws error on missing config.sequence', async () => {
65
+ let err;
66
+ let result;
67
+ const config = {
68
+ id: 'https://example.com/keystores/foo',
69
+ controller: 'bar',
70
+ kmsModule: 'ssm-v1'
71
+ };
72
+ try {
73
+ result = await keystores.insert({config});
74
+ } catch(e) {
75
+ err = e;
76
+ }
77
+ should.not.exist(result);
78
+ should.exist(err);
79
+ err.message.should.contain('Keystore config sequence must be "0".');
80
+ });
81
+ it('throws error on negative config.sequence', async () => {
82
+ let err;
83
+ let result;
84
+ const config = {
85
+ id: 'https://example.com/keystores/foo',
86
+ controller: 'bar',
87
+ kmsModule: 'ssm-v1',
88
+ sequence: -1,
89
+ };
90
+ try {
91
+ result = await keystores.insert({config});
92
+ } catch(e) {
93
+ err = e;
94
+ }
95
+ should.not.exist(result);
96
+ should.exist(err);
97
+ err.message.should.contain('Keystore config sequence must be "0".');
98
+ });
99
+ it('throws error on float config.sequence', async () => {
100
+ let err;
101
+ let result;
102
+ const config = {
103
+ id: 'https://example.com/keystores/foo',
104
+ controller: 'bar',
105
+ kmsModule: 'ssm-v1',
106
+ sequence: 1.1,
107
+ };
108
+ try {
109
+ result = await keystores.insert({config});
110
+ } catch(e) {
111
+ err = e;
112
+ }
113
+ should.not.exist(result);
114
+ should.exist(err);
115
+ err.message.should.contain('Keystore config sequence must be "0".');
116
+ });
117
+ it('throws error on non-zero config.sequence', async () => {
118
+ let err;
119
+ let result;
120
+ const config = {
121
+ id: 'https://example.com/keystores/foo',
122
+ controller: 'bar',
123
+ kmsModule: 'ssm-v1',
124
+ sequence: 1,
125
+ };
126
+ try {
127
+ result = await keystores.insert({config});
128
+ } catch(e) {
129
+ err = e;
130
+ }
131
+ should.not.exist(result);
132
+ should.exist(err);
133
+ err.message.should.contain('Keystore config sequence must be "0".');
134
+ });
135
+ it('throws error on string config.sequence', async () => {
136
+ let err;
137
+ let result;
138
+ const config = {
139
+ id: 'https://example.com/keystores/foo',
140
+ controller: 'bar',
141
+ kmsModule: 'ssm-v1',
142
+ sequence: '0',
143
+ };
144
+ try {
145
+ result = await keystores.insert({config});
146
+ } catch(e) {
147
+ err = e;
148
+ }
149
+ should.not.exist(result);
150
+ should.exist(err);
151
+ err.message.should.contain('Keystore config sequence must be "0".');
152
+ });
153
+ it('throws error on non-string config.id', async () => {
154
+ let err;
155
+ let result;
156
+ const config = {
157
+ id: 1,
158
+ controller: 'bar',
159
+ kmsModule: 'ssm-v1',
160
+ sequence: '0',
161
+ };
162
+ try {
163
+ result = await keystores.insert({config});
164
+ } catch(e) {
165
+ err = e;
166
+ }
167
+ should.not.exist(result);
168
+ should.exist(err);
169
+ err.message.should.contain('config.id (string) is required');
170
+ });
171
+ it('throws error on non-string config.controller', async () => {
172
+ let err;
173
+ let result;
174
+ const config = {
175
+ id: 'https://example.com/keystores/foo',
176
+ controller: 1,
177
+ kmsModule: 'ssm-v1',
178
+ sequence: '0',
179
+ };
180
+ try {
181
+ result = await keystores.insert({config});
182
+ } catch(e) {
183
+ err = e;
184
+ }
185
+ should.not.exist(result);
186
+ should.exist(err);
187
+ err.message.should.contain('config.controller (string) is required');
188
+ });
189
+ it('successfully creates a keystore', async () => {
190
+ let err;
191
+ let result;
192
+ const config = {
193
+ id: 'https://example.com/keystores/foo',
194
+ controller: 'bar',
195
+ kmsModule: 'ssm-v1',
196
+ sequence: 0,
197
+ };
198
+ try {
199
+ result = await keystores.insert({config});
200
+ } catch(e) {
201
+ err = e;
202
+ }
203
+ assertNoError(err);
204
+ should.exist(result);
205
+ result.should.be.an('object');
206
+ result.should.have.property('config');
207
+ result.config.should.eql(config);
208
+ });
209
+ it('throws DuplicateError on duplicate keystore config', async () => {
210
+ let err;
211
+ let result;
212
+ const config = {
213
+ id:
214
+ 'https://example.com/keystores/fbea027c-ecc4-4562-b3dc-392db7b7c7c6',
215
+ controller: 'bar',
216
+ kmsModule: 'ssm-v1',
217
+ sequence: 0,
218
+ };
219
+ try {
220
+ result = await keystores.insert({config});
221
+ } catch(e) {
222
+ err = e;
223
+ }
224
+ assertNoError(err);
225
+ should.exist(result);
226
+ result = undefined;
227
+ err = undefined;
228
+ try {
229
+ result = await keystores.insert({config});
230
+ } catch(e) {
231
+ err = e;
232
+ }
233
+ should.exist(err);
234
+ err.name.should.equal('DuplicateError');
235
+ });
236
+ it('throws DuplicateError on config with same controller and referenceId',
237
+ async () => {
238
+ // configs have unique IDs, but the same controller and referenceId
239
+ let err;
240
+ let result;
241
+ const keystoreConfig1 = {
242
+ id: 'https://example.com/keystores/fbea027c',
243
+ controller: 'bar',
244
+ kmsModule: 'ssm-v1',
245
+ referenceId: 'urn:uuid:72b89236-7bb7-4d00-8930-9c74c4a7a4a8',
246
+ sequence: 0,
247
+ };
248
+ try {
249
+ result = await keystores.insert({config: keystoreConfig1});
250
+ } catch(e) {
251
+ err = e;
252
+ }
253
+ assertNoError(err);
254
+ should.exist(result);
255
+
256
+ const keystoreConfig2 = {
257
+ id: 'https://example.com/keystores/4f398f8f',
258
+ controller: 'bar',
259
+ kmsModule: 'ssm-v1',
260
+ referenceId: 'urn:uuid:72b89236-7bb7-4d00-8930-9c74c4a7a4a8',
261
+ sequence: 0,
262
+ };
263
+
264
+ result = undefined;
265
+ err = undefined;
266
+ try {
267
+ result = await keystores.insert({config: keystoreConfig2});
268
+ } catch(e) {
269
+ err = e;
270
+ }
271
+ should.exist(err);
272
+ err.name.should.equal('DuplicateError');
273
+ });
274
+ }); // end insert API
275
+ }); // end keystore APIs