@thuzjq/meteorcloud-device-sdk-node 0.5.1
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.
- package/README.md +45 -0
- package/docs/NODE_INTEGRATION_GUIDE.zh-CN.md +414 -0
- package/index.d.ts +930 -0
- package/index.js +945 -0
- package/lib/artifacts.js +279 -0
- package/lib/camera.js +316 -0
- package/lib/config.js +257 -0
- package/lib/connect.js +718 -0
- package/lib/durable.js +85 -0
- package/lib/errors.js +70 -0
- package/lib/http.js +188 -0
- package/lib/jobs.js +54 -0
- package/lib/jose.js +146 -0
- package/lib/journal.js +116 -0
- package/lib/keystore.js +585 -0
- package/lib/resources.js +408 -0
- package/lib/tokens.js +311 -0
- package/lib/upload.js +188 -0
- package/package.json +44 -0
- package/tools/migrate-key-to-dpapi.js +386 -0
package/lib/keystore.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fsp = require('node:fs/promises');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { pathToFileURL, fileURLToPath } = require('node:url');
|
|
7
|
+
|
|
8
|
+
const { MeteorCloudError, fail } = require('./errors');
|
|
9
|
+
const { p256PublicJwk } = require('./jose');
|
|
10
|
+
const { writeFileDurable } = require('./durable');
|
|
11
|
+
|
|
12
|
+
const DPAPI_MODULE = '@meteorlive/dpapi';
|
|
13
|
+
const MAX_KEY_FILE_SIZE = 16 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where an installation's private key lives, and the only three things the SDK
|
|
17
|
+
* is ever allowed to ask of it.
|
|
18
|
+
*
|
|
19
|
+
* The interface deliberately has no `exportPrivateKey`. `sign()` takes bytes and
|
|
20
|
+
* returns a signature, which is the exact shape a Windows CNG persisted key
|
|
21
|
+
* (`NCryptSignHash` against a non-exportable handle) can satisfy. Every backend
|
|
22
|
+
* below is a stepping stone to that one; none of them may widen the interface.
|
|
23
|
+
*
|
|
24
|
+
* `keyReference` is a URI so that a config file written by one tier can be read
|
|
25
|
+
* by a client that supports a different set of tiers and fail loudly rather than
|
|
26
|
+
* silently reaching for the wrong key:
|
|
27
|
+
*
|
|
28
|
+
* file://<abs path> PKCS#8 PEM on disk, mode 0600 (dev / non-Windows)
|
|
29
|
+
* dpapi://<abs path> DPAPI CurrentUser-wrapped PKCS#8 DER (Windows default)
|
|
30
|
+
* cng://<container> non-exportable CNG key handle (Windows, strongest)
|
|
31
|
+
*
|
|
32
|
+
* `cng://` is spelled exactly as the C++ SDK spells it, and its containers live
|
|
33
|
+
* in the same `meteorlive/` namespace, because the installation config is a
|
|
34
|
+
* cross-SDK wire contract (see test/storage.test.js). A config written by one
|
|
35
|
+
* SDK has to be readable by the other; a Node-only `windows-cng://` spelling,
|
|
36
|
+
* which is what this comment used to reserve, would have split them.
|
|
37
|
+
*/
|
|
38
|
+
class KeyStore {
|
|
39
|
+
/** @returns {string} the URI scheme this store owns, without the colon. */
|
|
40
|
+
get scheme() {
|
|
41
|
+
throw new MeteorCloudError('KeyStore.scheme is not implemented', { kind: 'keystore' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Creates a fresh P-256 key and returns its `keyReference` URI.
|
|
46
|
+
* Must refuse to overwrite an existing key: silently replacing one would
|
|
47
|
+
* orphan a live installation whose public key the server still trusts.
|
|
48
|
+
*/
|
|
49
|
+
// eslint-disable-next-line no-unused-vars
|
|
50
|
+
async create(name) {
|
|
51
|
+
throw new MeteorCloudError('KeyStore.create is not implemented', { kind: 'keystore' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// eslint-disable-next-line no-unused-vars
|
|
55
|
+
async publicJwk(keyReference) {
|
|
56
|
+
throw new MeteorCloudError('KeyStore.publicJwk is not implemented', { kind: 'keystore' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns {Promise<Buffer>} 64-byte IEEE P1363 ECDSA signature over `data`. */
|
|
60
|
+
// eslint-disable-next-line no-unused-vars
|
|
61
|
+
async sign(keyReference, data) {
|
|
62
|
+
throw new MeteorCloudError('KeyStore.sign is not implemented', { kind: 'keystore' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line no-unused-vars
|
|
66
|
+
async exists(keyReference) {
|
|
67
|
+
throw new MeteorCloudError('KeyStore.exists is not implemented', { kind: 'keystore' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Destroys the local key. This does NOT revoke the installation — the server
|
|
72
|
+
* still holds the public key until the user revokes it on the web console.
|
|
73
|
+
*/
|
|
74
|
+
// eslint-disable-next-line no-unused-vars
|
|
75
|
+
async destroy(keyReference) {
|
|
76
|
+
throw new MeteorCloudError('KeyStore.destroy is not implemented', { kind: 'keystore' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseKeyReference(keyReference) {
|
|
81
|
+
if (typeof keyReference !== 'string' || !keyReference) fail('keyReference is required');
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(keyReference);
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
throw new MeteorCloudError('keyReference is not a valid URI', { kind: 'validation', cause });
|
|
87
|
+
}
|
|
88
|
+
const scheme = parsed.protocol.replace(/:$/, '');
|
|
89
|
+
if (scheme === 'file' || scheme === 'dpapi') {
|
|
90
|
+
// Both forms use an empty authority: `<scheme>:///<abs path>`. A non-empty
|
|
91
|
+
// host would be a UNC-style reference to another machine, which is never
|
|
92
|
+
// what a local key store means.
|
|
93
|
+
if (parsed.host) fail('keyReference must use an empty authority (three slashes)');
|
|
94
|
+
let filePath;
|
|
95
|
+
try {
|
|
96
|
+
// The WHATWG protocol setter refuses to convert between a special scheme
|
|
97
|
+
// (`file:`) and a non-special one (`dpapi:`) — assigning it is a silent
|
|
98
|
+
// no-op. Rebuilding the href as a string is the only conversion that works,
|
|
99
|
+
// and getting this wrong would resolve a DPAPI-wrapped key as plain PEM.
|
|
100
|
+
filePath = fileURLToPath(new URL(`file://${parsed.pathname}`));
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new MeteorCloudError('keyReference does not resolve to an absolute path', {
|
|
103
|
+
kind: 'validation',
|
|
104
|
+
cause
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (!path.isAbsolute(filePath)) fail('keyReference path must be absolute');
|
|
108
|
+
return { scheme, path: filePath };
|
|
109
|
+
}
|
|
110
|
+
if (scheme === 'cng') {
|
|
111
|
+
// A container name, not a path: nothing here ever reaches the filesystem,
|
|
112
|
+
// which is the entire point of the tier. `new URL('cng://meteorlive/x')`
|
|
113
|
+
// puts "meteorlive" in host and "/x" in pathname, so the container is
|
|
114
|
+
// reassembled rather than read off one field.
|
|
115
|
+
const container = `${parsed.host}${parsed.pathname}`;
|
|
116
|
+
if (!/^meteorlive\/[A-Za-z0-9._-]{1,128}$/.test(container)) {
|
|
117
|
+
fail(
|
|
118
|
+
'a cng:// keyReference must name a container under meteorlive/, ' +
|
|
119
|
+
'e.g. cng://meteorlive/<id>',
|
|
120
|
+
{ kind: 'keystore' }
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return { scheme, container };
|
|
124
|
+
}
|
|
125
|
+
if (scheme === 'windows-cng') {
|
|
126
|
+
// Only ever appeared in 0.3-era documentation as a reserved name; no
|
|
127
|
+
// release ever wrote one. Named explicitly so anyone who typed it from
|
|
128
|
+
// those docs is sent to the spelling both SDKs actually use.
|
|
129
|
+
fail(
|
|
130
|
+
'windows-cng:// was never implemented; the non-exportable tier is spelled ' +
|
|
131
|
+
'cng://meteorlive/<id>, the same as in the C++ SDK',
|
|
132
|
+
{ kind: 'keystore' }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
fail(`unsupported keyReference scheme: ${scheme}`, { kind: 'keystore' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function keyReferenceFor(scheme, filePath) {
|
|
139
|
+
const fileUrl = pathToFileURL(path.resolve(filePath)).toString();
|
|
140
|
+
if (scheme === 'file') return fileUrl;
|
|
141
|
+
// See parseKeyReference: `url.protocol = 'dpapi:'` on a file: URL does nothing
|
|
142
|
+
// at all, so the scheme is swapped textually.
|
|
143
|
+
return `${scheme}://${fileUrl.slice('file://'.length)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readKeyFile(filePath) {
|
|
147
|
+
let stat;
|
|
148
|
+
try {
|
|
149
|
+
stat = await fsp.lstat(filePath);
|
|
150
|
+
} catch (cause) {
|
|
151
|
+
throw new MeteorCloudError('installation key is missing; re-run the bind flow', {
|
|
152
|
+
kind: 'keystore',
|
|
153
|
+
cause
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
157
|
+
fail('installation key must be a regular file', { kind: 'keystore' });
|
|
158
|
+
}
|
|
159
|
+
if (stat.size < 1 || stat.size > MAX_KEY_FILE_SIZE) {
|
|
160
|
+
fail('installation key file size is implausible', { kind: 'keystore' });
|
|
161
|
+
}
|
|
162
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
|
163
|
+
// POSIX permission bits are meaningless on Windows (ACLs are the real
|
|
164
|
+
// control), so the check is skipped there rather than reported as a pass.
|
|
165
|
+
fail('installation key is readable by group or other; expected mode 0600', {
|
|
166
|
+
kind: 'keystore'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return await fsp.readFile(filePath);
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
throw new MeteorCloudError('cannot read installation key', { kind: 'keystore', cause });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function writeNewKeyFile(filePath, contents) {
|
|
177
|
+
try {
|
|
178
|
+
// 'wx' rather than 'w': overwriting would destroy the private half of an
|
|
179
|
+
// installation the server still considers active. Durable because a key
|
|
180
|
+
// that is only in the page cache is a key a power cut can take away, and
|
|
181
|
+
// this one cannot be regenerated — see lib/durable.js.
|
|
182
|
+
await writeFileDurable(filePath, contents, { mode: 0o600, flag: 'wx' });
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
if (cause && cause.code === 'EEXIST') {
|
|
185
|
+
fail('a key already exists at this reference; destroy it explicitly before rebinding', {
|
|
186
|
+
kind: 'keystore'
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
throw new MeteorCloudError('cannot write installation key', { kind: 'keystore', cause });
|
|
190
|
+
}
|
|
191
|
+
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function signWithPrivateKey(privateKey, data) {
|
|
195
|
+
// 'ieee-p1363' is what makes this a JOSE ES256 signature. The Node default is
|
|
196
|
+
// DER, which verifies fine with OpenSSL and fails every JWT library on earth.
|
|
197
|
+
return crypto.sign('sha256', data, { key: privateKey, dsaEncoding: 'ieee-p1363' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Tier 2 (dev / non-Windows): PKCS#8 PEM at `file://<path>`, mode 0600.
|
|
202
|
+
*
|
|
203
|
+
* The honest threat model: this protects against nothing but other local users.
|
|
204
|
+
* Copying the file to another machine yields a working installation identity.
|
|
205
|
+
* It exists so the SDK is testable and usable on macOS/Linux CI, and so the
|
|
206
|
+
* bind flow can be exercised without a native module. Windows production
|
|
207
|
+
* deployments must use `dpapi://` (or CNG once it lands).
|
|
208
|
+
*/
|
|
209
|
+
class FileKeyStore extends KeyStore {
|
|
210
|
+
get scheme() {
|
|
211
|
+
return 'file';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async create(name) {
|
|
215
|
+
const filePath = path.resolve(name);
|
|
216
|
+
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
217
|
+
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
218
|
+
await writeNewKeyFile(filePath, pem);
|
|
219
|
+
return keyReferenceFor('file', filePath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async _privateKey(keyReference) {
|
|
223
|
+
const { scheme, path: filePath } = parseKeyReference(keyReference);
|
|
224
|
+
if (scheme !== 'file') fail(`FileKeyStore cannot handle ${scheme}:// references`, { kind: 'keystore' });
|
|
225
|
+
const pem = await readKeyFile(filePath);
|
|
226
|
+
try {
|
|
227
|
+
return crypto.createPrivateKey(pem);
|
|
228
|
+
} catch (cause) {
|
|
229
|
+
throw new MeteorCloudError('installation key is not a usable PKCS#8 private key', {
|
|
230
|
+
kind: 'keystore',
|
|
231
|
+
cause
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async publicJwk(keyReference) {
|
|
237
|
+
return p256PublicJwk(await this._privateKey(keyReference));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async sign(keyReference, data) {
|
|
241
|
+
return signWithPrivateKey(await this._privateKey(keyReference), data);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async exists(keyReference) {
|
|
245
|
+
const { path: filePath } = parseKeyReference(keyReference);
|
|
246
|
+
try {
|
|
247
|
+
const stat = await fsp.lstat(filePath);
|
|
248
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
249
|
+
} catch (_) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async destroy(keyReference) {
|
|
255
|
+
const { path: filePath } = parseKeyReference(keyReference);
|
|
256
|
+
try {
|
|
257
|
+
await fsp.rm(filePath, { force: true });
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
throw new MeteorCloudError('cannot destroy installation key', { kind: 'keystore', cause });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Tier 1 (Windows default): PKCS#8 DER wrapped with DPAPI
|
|
266
|
+
* `CryptProtectData(CRYPTPROTECT_UI_FORBIDDEN, CurrentUser)` at `dpapi://<path>`.
|
|
267
|
+
*
|
|
268
|
+
* The optional peer module `@meteorlive/dpapi` ships in this repository and
|
|
269
|
+
* exports exactly:
|
|
270
|
+
*
|
|
271
|
+
* protectData(plaintext: Buffer, entropy?: Buffer): Buffer
|
|
272
|
+
* unprotectData(ciphertext: Buffer, entropy?: Buffer): Buffer
|
|
273
|
+
*
|
|
274
|
+
* i.e. a thin binding over CryptProtectData / CryptUnprotectData with the
|
|
275
|
+
* CurrentUser scope. Nothing here shells out: a `powershell -Command` bridge
|
|
276
|
+
* would put PKCS#8 bytes on a command line and into the process table, which is
|
|
277
|
+
* a worse exposure than the plaintext file it is trying to replace.
|
|
278
|
+
*
|
|
279
|
+
* A future `windows-cng://` store may call NCryptSignHash against a
|
|
280
|
+
* non-exportable handle. That does not require a contract change because
|
|
281
|
+
* `key_reference` already carries the tier.
|
|
282
|
+
*/
|
|
283
|
+
class DpapiKeyStore extends KeyStore {
|
|
284
|
+
constructor(options = {}) {
|
|
285
|
+
super();
|
|
286
|
+
// Injectable so the interface, error path and wrapping round-trip are all
|
|
287
|
+
// testable on a machine that has no DPAPI at all.
|
|
288
|
+
this._backend = options.backend;
|
|
289
|
+
this._entropy = options.entropy;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
get scheme() {
|
|
293
|
+
return 'dpapi';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_dpapi() {
|
|
297
|
+
let backend = this._backend;
|
|
298
|
+
if (!backend) {
|
|
299
|
+
if (process.platform !== 'win32') {
|
|
300
|
+
fail('dpapi:// key references are only usable on Windows', { kind: 'keystore' });
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
// eslint-disable-next-line global-require, import/no-unresolved
|
|
304
|
+
backend = require(DPAPI_MODULE);
|
|
305
|
+
} catch (cause) {
|
|
306
|
+
throw new MeteorCloudError(
|
|
307
|
+
`optional peer dependency ${DPAPI_MODULE} is not installed; ` +
|
|
308
|
+
`install it to use dpapi:// key storage, or bind with a file:// keyReference`,
|
|
309
|
+
{ kind: 'keystore', cause }
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Checked on every resolution, including an injected backend: a partial
|
|
314
|
+
// implementation must fail here rather than at `protectData is not a
|
|
315
|
+
// function` somewhere deep inside a bind.
|
|
316
|
+
if (typeof backend.protectData !== 'function' || typeof backend.unprotectData !== 'function') {
|
|
317
|
+
fail(`${DPAPI_MODULE} does not implement protectData/unprotectData`, { kind: 'keystore' });
|
|
318
|
+
}
|
|
319
|
+
this._backend = backend;
|
|
320
|
+
return backend;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async create(name) {
|
|
324
|
+
const filePath = path.resolve(name);
|
|
325
|
+
const backend = this._dpapi();
|
|
326
|
+
const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
327
|
+
const der = privateKey.export({ type: 'pkcs8', format: 'der' });
|
|
328
|
+
let wrapped;
|
|
329
|
+
try {
|
|
330
|
+
wrapped = backend.protectData(der, this._entropy);
|
|
331
|
+
} catch (cause) {
|
|
332
|
+
throw new MeteorCloudError('DPAPI refused to protect the installation key', {
|
|
333
|
+
kind: 'keystore',
|
|
334
|
+
cause
|
|
335
|
+
});
|
|
336
|
+
} finally {
|
|
337
|
+
der.fill(0);
|
|
338
|
+
}
|
|
339
|
+
if (!Buffer.isBuffer(wrapped) || wrapped.length === 0) {
|
|
340
|
+
fail('DPAPI backend returned an empty blob', { kind: 'keystore' });
|
|
341
|
+
}
|
|
342
|
+
await writeNewKeyFile(filePath, wrapped);
|
|
343
|
+
return keyReferenceFor('dpapi', filePath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async _privateKey(keyReference) {
|
|
347
|
+
const { scheme, path: filePath } = parseKeyReference(keyReference);
|
|
348
|
+
if (scheme !== 'dpapi') fail(`DpapiKeyStore cannot handle ${scheme}:// references`, { kind: 'keystore' });
|
|
349
|
+
const backend = this._dpapi();
|
|
350
|
+
const wrapped = await readKeyFile(filePath);
|
|
351
|
+
let der;
|
|
352
|
+
try {
|
|
353
|
+
der = backend.unprotectData(wrapped, this._entropy);
|
|
354
|
+
} catch (cause) {
|
|
355
|
+
throw new MeteorCloudError(
|
|
356
|
+
'DPAPI could not unprotect the installation key; the key was created by a ' +
|
|
357
|
+
'different Windows user or on a different machine — re-run the bind flow',
|
|
358
|
+
{ kind: 'keystore', cause }
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
363
|
+
} catch (cause) {
|
|
364
|
+
throw new MeteorCloudError('unprotected installation key is not valid PKCS#8', {
|
|
365
|
+
kind: 'keystore',
|
|
366
|
+
cause
|
|
367
|
+
});
|
|
368
|
+
} finally {
|
|
369
|
+
if (Buffer.isBuffer(der)) der.fill(0);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async publicJwk(keyReference) {
|
|
374
|
+
return p256PublicJwk(await this._privateKey(keyReference));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async sign(keyReference, data) {
|
|
378
|
+
return signWithPrivateKey(await this._privateKey(keyReference), data);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async exists(keyReference) {
|
|
382
|
+
const { path: filePath } = parseKeyReference(keyReference);
|
|
383
|
+
try {
|
|
384
|
+
const stat = await fsp.lstat(filePath);
|
|
385
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
386
|
+
} catch (_) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async destroy(keyReference) {
|
|
392
|
+
const { path: filePath } = parseKeyReference(keyReference);
|
|
393
|
+
try {
|
|
394
|
+
await fsp.rm(filePath, { force: true });
|
|
395
|
+
} catch (cause) {
|
|
396
|
+
throw new MeteorCloudError('cannot destroy installation key', { kind: 'keystore', cause });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
const CNG_MODULE = '@meteorlive/cng';
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Tier 0 — a P-256 key that lives inside the Windows CNG key storage provider
|
|
406
|
+
* and is created non-exportable.
|
|
407
|
+
*
|
|
408
|
+
* The difference from `dpapi://` is worth stating plainly, because both are
|
|
409
|
+
* "the OS protects the key" and only one of them survives an attacker who owns
|
|
410
|
+
* this process. DPAPI stops the key being readable *on disk*: to sign, the SDK
|
|
411
|
+
* asks Windows to unwrap it, and for that moment the private bytes are in
|
|
412
|
+
* Node's heap. Here they are never anywhere — the key is generated inside the
|
|
413
|
+
* KSP with `NCRYPT_EXPORT_POLICY = 0` set before finalize, and every signature
|
|
414
|
+
* is produced by `NCryptSignHash` inside the provider.
|
|
415
|
+
*
|
|
416
|
+
* What that does and does not buy: an attacker holding the user's token can
|
|
417
|
+
* still ask the KSP to sign for as long as they hold it. What they cannot do is
|
|
418
|
+
* take the key away and sign later, elsewhere, forever. Revoking the
|
|
419
|
+
* installation ends the first; nothing ends the second.
|
|
420
|
+
*
|
|
421
|
+
* The backend is injectable for the same reason DpapiKeyStore's is — so the
|
|
422
|
+
* whole path is testable on a machine with no CNG at all.
|
|
423
|
+
*/
|
|
424
|
+
class CngKeyStore extends KeyStore {
|
|
425
|
+
constructor(options = {}) {
|
|
426
|
+
super();
|
|
427
|
+
this._backend = options.backend;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
get scheme() {
|
|
431
|
+
return 'cng';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
_cng() {
|
|
435
|
+
let backend = this._backend;
|
|
436
|
+
if (!backend) {
|
|
437
|
+
if (process.platform !== 'win32') {
|
|
438
|
+
fail('cng:// key references are only usable on Windows', { kind: 'keystore' });
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
// eslint-disable-next-line global-require, import/no-unresolved
|
|
442
|
+
backend = require(CNG_MODULE);
|
|
443
|
+
} catch (cause) {
|
|
444
|
+
throw new MeteorCloudError(
|
|
445
|
+
`optional peer dependency ${CNG_MODULE} is not installed; ` +
|
|
446
|
+
`install it to use cng:// key storage, or bind with a dpapi:// keyReference`,
|
|
447
|
+
{ kind: 'keystore', cause }
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const name of ['createKey', 'publicKeyBlob', 'signDigest', 'keyExists', 'deleteKey']) {
|
|
452
|
+
if (typeof backend[name] !== 'function') {
|
|
453
|
+
fail(`${CNG_MODULE} does not implement ${name}`, { kind: 'keystore' });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
this._backend = backend;
|
|
457
|
+
return backend;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* `name` is the container's leaf, not a path — this tier never touches the
|
|
462
|
+
* filesystem. connect() passes the same locally generated id it would have
|
|
463
|
+
* used as a filename, so a caller that hands over a path gets its basename.
|
|
464
|
+
*/
|
|
465
|
+
async create(name) {
|
|
466
|
+
const leaf = String(name || '').replace(/^.*[\\/]/, '');
|
|
467
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(leaf)) {
|
|
468
|
+
fail('a cng:// key name must be 1-128 characters of [A-Za-z0-9._-]', { kind: 'keystore' });
|
|
469
|
+
}
|
|
470
|
+
const container = `meteorlive/${leaf}`;
|
|
471
|
+
const backend = this._cng();
|
|
472
|
+
// Refuses rather than overwrites, in the addon: replacing a container would
|
|
473
|
+
// destroy the private half of an installation the server still trusts.
|
|
474
|
+
backend.createKey(container);
|
|
475
|
+
return `cng://${container}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
_container(keyReference) {
|
|
479
|
+
const { scheme, container } = parseKeyReference(keyReference);
|
|
480
|
+
if (scheme !== 'cng') fail(`CngKeyStore cannot handle ${scheme}:// references`, { kind: 'keystore' });
|
|
481
|
+
return container;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async publicJwk(keyReference) {
|
|
485
|
+
const backend = this._cng();
|
|
486
|
+
const blob = backend.publicKeyBlob(this._container(keyReference));
|
|
487
|
+
if (!Buffer.isBuffer(blob)) fail('CNG backend returned no public key blob', { kind: 'keystore' });
|
|
488
|
+
return publicJwkFromEccBlob(blob);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The digest is computed here and the raw signature comes back from the
|
|
493
|
+
* provider. `NCryptSignHash` on an ECDSA key emits fixed-width r||s, which is
|
|
494
|
+
* already what JOSE calls P1363 — so unlike the software tiers there is no
|
|
495
|
+
* DER to convert, and no place for a conversion bug to live.
|
|
496
|
+
*/
|
|
497
|
+
async sign(keyReference, data) {
|
|
498
|
+
const backend = this._cng();
|
|
499
|
+
const digest = crypto.createHash('sha256').update(data).digest();
|
|
500
|
+
const signature = backend.signDigest(this._container(keyReference), digest);
|
|
501
|
+
if (!Buffer.isBuffer(signature) || signature.length !== 64) {
|
|
502
|
+
fail('CNG backend returned a signature that is not 64-byte P1363', { kind: 'keystore' });
|
|
503
|
+
}
|
|
504
|
+
return signature;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async exists(keyReference) {
|
|
508
|
+
try {
|
|
509
|
+
return this._cng().keyExists(this._container(keyReference)) === true;
|
|
510
|
+
} catch (_) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async destroy(keyReference) {
|
|
516
|
+
const backend = this._cng();
|
|
517
|
+
try {
|
|
518
|
+
backend.deleteKey(this._container(keyReference));
|
|
519
|
+
} catch (cause) {
|
|
520
|
+
throw new MeteorCloudError('cannot destroy installation key', { kind: 'keystore', cause });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Reads X and Y out of a BCRYPT_ECCKEY_BLOB.
|
|
527
|
+
*
|
|
528
|
+
* Layout: ULONG dwMagic, ULONG cbKey, then cbKey bytes of X and cbKey of Y,
|
|
529
|
+
* both big-endian and already the fixed width a JWK wants — so this is a split,
|
|
530
|
+
* not a conversion. The magic is checked because a provider handing back a
|
|
531
|
+
* different curve would otherwise produce a JWK that claims P-256 and is not,
|
|
532
|
+
* and every assertion signed against it would fail at the server with nothing
|
|
533
|
+
* pointing back here.
|
|
534
|
+
*/
|
|
535
|
+
function publicJwkFromEccBlob(blob) {
|
|
536
|
+
const ECDSA_PUBLIC_P256_MAGIC = 0x31534345;
|
|
537
|
+
if (blob.length < 8) fail('CNG public key blob is truncated', { kind: 'keystore' });
|
|
538
|
+
const magic = blob.readUInt32LE(0);
|
|
539
|
+
const keyBytes = blob.readUInt32LE(4);
|
|
540
|
+
if (magic !== ECDSA_PUBLIC_P256_MAGIC || keyBytes !== 32) {
|
|
541
|
+
fail('CNG public key blob is not an ECDSA P-256 public key', { kind: 'keystore' });
|
|
542
|
+
}
|
|
543
|
+
if (blob.length < 8 + keyBytes * 2) fail('CNG public key blob is truncated', { kind: 'keystore' });
|
|
544
|
+
return {
|
|
545
|
+
kty: 'EC',
|
|
546
|
+
crv: 'P-256',
|
|
547
|
+
x: blob.subarray(8, 8 + keyBytes).toString('base64url'),
|
|
548
|
+
y: blob.subarray(8 + keyBytes, 8 + keyBytes * 2).toString('base64url')
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Picks the backend a `keyReference` demands. Callers that already hold a
|
|
554
|
+
* KeyStore instance should pass it explicitly instead — this exists so a client
|
|
555
|
+
* constructed from nothing but a config file can still sign.
|
|
556
|
+
*/
|
|
557
|
+
function createKeyStore(keyReference, options = {}) {
|
|
558
|
+
const { scheme } = parseKeyReference(keyReference);
|
|
559
|
+
if (scheme === 'file') return new FileKeyStore();
|
|
560
|
+
if (scheme === 'dpapi') return new DpapiKeyStore(options.dpapi || {});
|
|
561
|
+
if (scheme === 'cng') return new CngKeyStore(options.cng || {});
|
|
562
|
+
return fail(`unsupported keyReference scheme: ${scheme}`, { kind: 'keystore' });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The tier a fresh bind should use on this platform: DPAPI on Windows, plain
|
|
567
|
+
* file everywhere else. Callers may override; `connect()` calls this when they
|
|
568
|
+
* do not.
|
|
569
|
+
*/
|
|
570
|
+
function defaultKeyStoreScheme() {
|
|
571
|
+
return process.platform === 'win32' ? 'dpapi' : 'file';
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
module.exports = {
|
|
575
|
+
KeyStore,
|
|
576
|
+
FileKeyStore,
|
|
577
|
+
DpapiKeyStore,
|
|
578
|
+
CngKeyStore,
|
|
579
|
+
createKeyStore,
|
|
580
|
+
parseKeyReference,
|
|
581
|
+
keyReferenceFor,
|
|
582
|
+
defaultKeyStoreScheme,
|
|
583
|
+
DPAPI_MODULE,
|
|
584
|
+
CNG_MODULE
|
|
585
|
+
};
|