@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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-place upgrade of an installation key from the plaintext `file://` tier to
|
|
5
|
+
* the DPAPI-wrapped `dpapi://` tier.
|
|
6
|
+
*
|
|
7
|
+
* node tools/migrate-key-to-dpapi.js --config C:/ProgramData/MeteorLive/installation.json
|
|
8
|
+
*
|
|
9
|
+
* The key does not change: the same P-256 private key ends up wrapped by DPAPI
|
|
10
|
+
* instead of sitting in a PEM, so the public key the server already trusts stays
|
|
11
|
+
* valid, `installation_uid` stays valid, and nothing has to be re-bound. Only
|
|
12
|
+
* `key_reference` moves.
|
|
13
|
+
*
|
|
14
|
+
* Flags:
|
|
15
|
+
* --config <path> installation config to migrate (required)
|
|
16
|
+
* --backend <module-id> DPAPI binding to load; default '@meteorlive/dpapi'.
|
|
17
|
+
* A path (relative or absolute) is resolved against the
|
|
18
|
+
* cwd, which is how the tests inject a fake binding.
|
|
19
|
+
* --entropy-hex <hex> optional secondary entropy. It is stored nowhere: every
|
|
20
|
+
* later open of the key must supply the same bytes as
|
|
21
|
+
* `dpapi: { entropy }`, or the key is unreadable forever.
|
|
22
|
+
*
|
|
23
|
+
* The step order below is the safety design, not a preference. Nothing is written
|
|
24
|
+
* until the existing key has been read and parsed; the config is not touched
|
|
25
|
+
* until the freshly wrapped key has produced a signature that verifies against
|
|
26
|
+
* the *old* public key; and the plaintext PEM is not destroyed until the config
|
|
27
|
+
* has stopped pointing at it. Every failure before the config is saved rolls the
|
|
28
|
+
* new file back and leaves the installation exactly as it was.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const crypto = require('node:crypto');
|
|
32
|
+
const fsp = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const { MeteorCloudError, fail } = require('../lib/errors');
|
|
36
|
+
const { loadInstallationConfig, saveInstallationConfig } = require('../lib/config');
|
|
37
|
+
const {
|
|
38
|
+
DpapiKeyStore,
|
|
39
|
+
FileKeyStore,
|
|
40
|
+
parseKeyReference,
|
|
41
|
+
keyReferenceFor,
|
|
42
|
+
DPAPI_MODULE
|
|
43
|
+
} = require('../lib/keystore');
|
|
44
|
+
const { jwkThumbprint } = require('../lib/jose');
|
|
45
|
+
const { writeFileDurable } = require('../lib/durable');
|
|
46
|
+
|
|
47
|
+
/** The members `p256PublicJwk` emits, in RFC 7638 order. */
|
|
48
|
+
const JWK_FIELDS = Object.freeze(['crv', 'kty', 'x', 'y']);
|
|
49
|
+
|
|
50
|
+
const USAGE =
|
|
51
|
+
'usage: node tools/migrate-key-to-dpapi.js --config <installation.json> ' +
|
|
52
|
+
'[--backend <module-id>] [--entropy-hex <hex>]';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `installation.key.pem` -> `installation.key.dpapi`, the pair of names the
|
|
56
|
+
* README and the integration guide already use for the two tiers. The suffix is
|
|
57
|
+
* cosmetic — `keyReference` is what selects the store — but a `.pem` file
|
|
58
|
+
* holding a DPAPI blob is a support call waiting to happen.
|
|
59
|
+
*/
|
|
60
|
+
function dpapiTargetPathFor(sourcePath) {
|
|
61
|
+
const resolved = path.resolve(sourcePath);
|
|
62
|
+
const stem = resolved.toLowerCase().endsWith('.pem') ? resolved.slice(0, -4) : resolved;
|
|
63
|
+
return `${stem}.dpapi`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseEntropyHex(value) {
|
|
67
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
68
|
+
// The value itself is secret-shaped, so it is never echoed back in the error.
|
|
69
|
+
if (typeof value !== 'string' || !/^(?:[0-9a-fA-F]{2})+$/.test(value)) {
|
|
70
|
+
fail('--entropy-hex must be a non-empty hex string with an even number of digits');
|
|
71
|
+
}
|
|
72
|
+
return Buffer.from(value, 'hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolves the DPAPI binding. An injected `backend` object wins; otherwise the
|
|
77
|
+
* module id is required, which on a non-Windows host can only ever be a stand-in
|
|
78
|
+
* supplied by the caller — the real CryptProtectData does not exist there, and
|
|
79
|
+
* silently "migrating" with a fake would produce a key no Windows machine can
|
|
80
|
+
* open.
|
|
81
|
+
*/
|
|
82
|
+
function resolveBackend(options) {
|
|
83
|
+
let backend = options.backend;
|
|
84
|
+
if (!backend) {
|
|
85
|
+
const moduleId = options.backendModule || DPAPI_MODULE;
|
|
86
|
+
if (moduleId === DPAPI_MODULE && process.platform !== 'win32') {
|
|
87
|
+
fail(
|
|
88
|
+
`${DPAPI_MODULE} wraps a Windows-only API; run this tool on the Windows machine ` +
|
|
89
|
+
'that holds the key, or pass --backend <module-id> to supply your own binding',
|
|
90
|
+
{ kind: 'keystore' }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const target = /^[./\\]/.test(moduleId) || path.isAbsolute(moduleId) ? path.resolve(moduleId) : moduleId;
|
|
94
|
+
try {
|
|
95
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
96
|
+
backend = require(target);
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
throw new MeteorCloudError(
|
|
99
|
+
`cannot load the DPAPI backend ${moduleId}; install it next to the SDK ` +
|
|
100
|
+
'(npm install @meteorlive/dpapi) or point --backend at your own binding',
|
|
101
|
+
{ kind: 'keystore', cause }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Same gate DpapiKeyStore applies: a half-implemented binding must fail here,
|
|
106
|
+
// not as "protectData is not a function" with a key already half-migrated.
|
|
107
|
+
if (typeof backend.protectData !== 'function' || typeof backend.unprotectData !== 'function') {
|
|
108
|
+
fail(`${DPAPI_MODULE} does not implement protectData/unprotectData`, { kind: 'keystore' });
|
|
109
|
+
}
|
|
110
|
+
return backend;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Mirrors `writeNewKeyFile` in lib/keystore.js: mode 0600, and 'wx' so an
|
|
115
|
+
* existing file is never overwritten. The refusal matters more here than at
|
|
116
|
+
* bind time — the file already sitting there could be the only copy of another
|
|
117
|
+
* live installation's key.
|
|
118
|
+
*/
|
|
119
|
+
async function writeWrappedKey(filePath, wrapped) {
|
|
120
|
+
try {
|
|
121
|
+
// Durable before anything is allowed to touch the PEM. This used to be a
|
|
122
|
+
// plain writeFile while the *shred* was the only fsync in the SDK, so a
|
|
123
|
+
// power cut just after a successful migration could make the destruction
|
|
124
|
+
// stick and the replacement evaporate. See lib/durable.js.
|
|
125
|
+
await writeFileDurable(filePath, wrapped, { mode: 0o600, flag: 'wx' });
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
if (cause && cause.code === 'EEXIST') {
|
|
128
|
+
fail(
|
|
129
|
+
`a key already exists at ${filePath}; inspect it and move it aside before ` +
|
|
130
|
+
'migrating, so an installation that still depends on it is not destroyed',
|
|
131
|
+
{ kind: 'keystore' }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
throw new MeteorCloudError('cannot write the DPAPI-wrapped installation key', {
|
|
135
|
+
kind: 'keystore',
|
|
136
|
+
cause
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Overwrites the plaintext key with zeros, flushes, then unlinks. Best effort by
|
|
144
|
+
* construction: on a copy-on-write or flash-translated filesystem the old blocks
|
|
145
|
+
* may survive, and a backup taken before the migration certainly does. Treat the
|
|
146
|
+
* PEM as compromised-if-copied rather than as erased.
|
|
147
|
+
*/
|
|
148
|
+
async function shredPlaintextKey(filePath) {
|
|
149
|
+
let handle;
|
|
150
|
+
try {
|
|
151
|
+
handle = await fsp.open(filePath, 'r+');
|
|
152
|
+
const { size } = await handle.stat();
|
|
153
|
+
if (size > 0) await handle.write(Buffer.alloc(size), 0, size, 0);
|
|
154
|
+
await handle.sync();
|
|
155
|
+
} catch (_) {
|
|
156
|
+
/* best effort: the unlink below is what actually has to happen */
|
|
157
|
+
} finally {
|
|
158
|
+
if (handle) {
|
|
159
|
+
try {
|
|
160
|
+
await handle.close();
|
|
161
|
+
} catch (_) {
|
|
162
|
+
/* best effort */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await fsp.rm(filePath, { force: true });
|
|
168
|
+
return true;
|
|
169
|
+
} catch (_) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Proves the wrapped key is the same key, before anything irreversible happens.
|
|
176
|
+
*
|
|
177
|
+
* Two independent checks: the public JWK must come back field for field, and a
|
|
178
|
+
* signature produced through the DPAPI store must verify against the public key
|
|
179
|
+
* derived from the *original* PEM. The first catches a backend that returned
|
|
180
|
+
* some other key's bytes; the second catches a wrap/unwrap path that produces a
|
|
181
|
+
* structurally valid but different key.
|
|
182
|
+
*/
|
|
183
|
+
async function verifyMigratedKey(keyStore, keyReference, originalJwk) {
|
|
184
|
+
const migratedJwk = await keyStore.publicJwk(keyReference);
|
|
185
|
+
for (const field of JWK_FIELDS) {
|
|
186
|
+
if (migratedJwk[field] !== originalJwk[field]) {
|
|
187
|
+
fail(
|
|
188
|
+
'the DPAPI-wrapped key does not carry the same public key as the PEM; ' +
|
|
189
|
+
'the plaintext key and the config were left untouched',
|
|
190
|
+
{ kind: 'keystore' }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const challenge = crypto.randomBytes(32);
|
|
195
|
+
const signature = await keyStore.sign(keyReference, challenge);
|
|
196
|
+
const publicKey = crypto.createPublicKey({ key: originalJwk, format: 'jwk' });
|
|
197
|
+
const verified = crypto.verify(
|
|
198
|
+
'sha256',
|
|
199
|
+
challenge,
|
|
200
|
+
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
|
|
201
|
+
signature
|
|
202
|
+
);
|
|
203
|
+
if (!verified) {
|
|
204
|
+
fail(
|
|
205
|
+
'a signature from the DPAPI-wrapped key does not verify against the original ' +
|
|
206
|
+
'public key; the plaintext key and the config were left untouched',
|
|
207
|
+
{ kind: 'keystore' }
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {object} options
|
|
214
|
+
* @param {string} options.configPath installation config to migrate
|
|
215
|
+
* @param {object} [options.backend] DPAPI binding, injected directly
|
|
216
|
+
* @param {string} [options.backendModule] module id to require instead
|
|
217
|
+
* @param {Buffer} [options.entropy] secondary entropy for protect/unprotect
|
|
218
|
+
* @param {(line: string) => void} [options.log]
|
|
219
|
+
* @returns {Promise<object>} a summary carrying paths and public fingerprints only
|
|
220
|
+
*/
|
|
221
|
+
async function migrateKeyToDpapi(options = {}) {
|
|
222
|
+
const { configPath } = options;
|
|
223
|
+
if (typeof configPath !== 'string' || !configPath) fail('configPath is required');
|
|
224
|
+
const log = typeof options.log === 'function' ? options.log : () => {};
|
|
225
|
+
const entropy = options.entropy;
|
|
226
|
+
|
|
227
|
+
// 1. Load, and decide whether there is anything to do. An unsupported scheme
|
|
228
|
+
// (windows-cng://) is already refused by loadInstallationConfig, with the
|
|
229
|
+
// message that names the tier.
|
|
230
|
+
const config = await loadInstallationConfig(configPath);
|
|
231
|
+
const { scheme, path: sourcePath } = parseKeyReference(config.keyReference);
|
|
232
|
+
if (scheme === 'dpapi') {
|
|
233
|
+
log(`nothing to do: ${configPath} already uses the dpapi tier`);
|
|
234
|
+
log(`key_reference ${config.keyReference}`);
|
|
235
|
+
return {
|
|
236
|
+
status: 'noop',
|
|
237
|
+
configPath,
|
|
238
|
+
keyReference: config.keyReference,
|
|
239
|
+
keyPath: sourcePath,
|
|
240
|
+
installationUid: config.installationUid
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (scheme !== 'file') {
|
|
244
|
+
fail(`cannot migrate a ${scheme}:// key reference; this tool upgrades file:// keys only`, {
|
|
245
|
+
kind: 'keystore'
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const targetPath = dpapiTargetPathFor(sourcePath);
|
|
250
|
+
if (targetPath === sourcePath) {
|
|
251
|
+
fail(
|
|
252
|
+
`the plaintext key is already named ${sourcePath}, which is where the wrapped key ` +
|
|
253
|
+
'would go; rename it (for example to installation.key.pem) and retry',
|
|
254
|
+
{ kind: 'keystore' }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 2. Read the existing key. FileKeyStore.publicJwk runs the full file check
|
|
259
|
+
// (regular file, plausible size, mode 0600, parses as P-256), so a key that
|
|
260
|
+
// is already broken fails here rather than half way through a migration.
|
|
261
|
+
const originalJwk = await new FileKeyStore().publicJwk(config.keyReference);
|
|
262
|
+
const pem = await fsp.readFile(sourcePath);
|
|
263
|
+
let privateKey;
|
|
264
|
+
try {
|
|
265
|
+
privateKey = crypto.createPrivateKey(pem);
|
|
266
|
+
} catch (cause) {
|
|
267
|
+
throw new MeteorCloudError('installation key is not a usable PKCS#8 private key', {
|
|
268
|
+
kind: 'keystore',
|
|
269
|
+
cause
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
pem.fill(0);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const der = privateKey.export({ type: 'pkcs8', format: 'der' });
|
|
276
|
+
let backend;
|
|
277
|
+
let wrapped;
|
|
278
|
+
try {
|
|
279
|
+
// 3. Resolve the binding. Never reached on a non-Windows host without an
|
|
280
|
+
// explicit --backend.
|
|
281
|
+
backend = resolveBackend(options);
|
|
282
|
+
// 4a. Wrap.
|
|
283
|
+
try {
|
|
284
|
+
wrapped = backend.protectData(der, entropy);
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
throw new MeteorCloudError('the DPAPI backend refused to protect the installation key', {
|
|
287
|
+
kind: 'keystore',
|
|
288
|
+
cause
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (!Buffer.isBuffer(wrapped) || wrapped.length === 0) {
|
|
292
|
+
fail('the DPAPI backend returned an empty blob', { kind: 'keystore' });
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
der.fill(0);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const keyStore = new DpapiKeyStore({ backend, entropy });
|
|
299
|
+
const keyReference = keyReferenceFor('dpapi', targetPath);
|
|
300
|
+
let wroteTarget = false;
|
|
301
|
+
try {
|
|
302
|
+
// 4b. Write, refusing to clobber.
|
|
303
|
+
await writeWrappedKey(targetPath, wrapped);
|
|
304
|
+
wroteTarget = true;
|
|
305
|
+
// 5. Prove it is the same key before anything can be lost.
|
|
306
|
+
await verifyMigratedKey(keyStore, keyReference, originalJwk);
|
|
307
|
+
// 6a. Flip the config atomically. Up to this line every failure is a
|
|
308
|
+
// no-change failure; after it, the new key is the installation's key.
|
|
309
|
+
await saveInstallationConfig(configPath, { ...config, keyReference });
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (wroteTarget) {
|
|
312
|
+
try {
|
|
313
|
+
await fsp.rm(targetPath, { force: true });
|
|
314
|
+
} catch (_) {
|
|
315
|
+
/* best effort: the config still points at the untouched PEM */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 6b. Only now is the plaintext copy redundant.
|
|
322
|
+
const plaintextKeyRemoved = await shredPlaintextKey(sourcePath);
|
|
323
|
+
|
|
324
|
+
// 7. Summary. Paths and a public-key fingerprint only — no key bytes, ever.
|
|
325
|
+
const thumbprint = jwkThumbprint(originalJwk);
|
|
326
|
+
log(`migrated ${configPath}`);
|
|
327
|
+
log(`installation ${config.installationUid}`);
|
|
328
|
+
log(`key was ${sourcePath}`);
|
|
329
|
+
log(`key now ${targetPath}`);
|
|
330
|
+
log(`key_reference ${keyReference}`);
|
|
331
|
+
log(`public key unchanged, JWK thumbprint ${thumbprint} — no re-bind needed`);
|
|
332
|
+
log(
|
|
333
|
+
plaintextKeyRemoved
|
|
334
|
+
? 'plaintext PEM zeroed and deleted (best effort; SSDs and backups may retain blocks)'
|
|
335
|
+
: `plaintext PEM COULD NOT BE DELETED — remove ${sourcePath} yourself`
|
|
336
|
+
);
|
|
337
|
+
if (entropy) {
|
|
338
|
+
log('entropy required from now on: pass the same bytes as dpapi.entropy, or the key is unreadable');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
status: 'migrated',
|
|
343
|
+
configPath,
|
|
344
|
+
installationUid: config.installationUid,
|
|
345
|
+
previousKeyReference: config.keyReference,
|
|
346
|
+
keyReference,
|
|
347
|
+
previousKeyPath: sourcePath,
|
|
348
|
+
keyPath: targetPath,
|
|
349
|
+
publicJwkThumbprint: thumbprint,
|
|
350
|
+
plaintextKeyRemoved,
|
|
351
|
+
entropyRequired: Boolean(entropy)
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function parseArgs(argv) {
|
|
356
|
+
const args = {};
|
|
357
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
358
|
+
const match = argv[i].match(/^--([a-z-]+)(?:=(.*))?$/);
|
|
359
|
+
if (!match) continue;
|
|
360
|
+
args[match[1]] = match[2] !== undefined ? match[2] : argv[++i];
|
|
361
|
+
}
|
|
362
|
+
return args;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function main(argv) {
|
|
366
|
+
const args = parseArgs(argv);
|
|
367
|
+
if (!args.config) fail(USAGE);
|
|
368
|
+
await migrateKeyToDpapi({
|
|
369
|
+
configPath: path.resolve(args.config),
|
|
370
|
+
backendModule: args.backend,
|
|
371
|
+
entropy: parseEntropyHex(args['entropy-hex']),
|
|
372
|
+
// eslint-disable-next-line no-console
|
|
373
|
+
log: (line) => console.log(line)
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { migrateKeyToDpapi, dpapiTargetPathFor, parseEntropyHex, parseArgs };
|
|
378
|
+
|
|
379
|
+
if (require.main === module) {
|
|
380
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
381
|
+
const safe = error && typeof error.toSafeObject === 'function' ? error.toSafeObject() : error;
|
|
382
|
+
// eslint-disable-next-line no-console
|
|
383
|
+
console.error(`migration failed: ${(safe && safe.message) || error}`);
|
|
384
|
+
process.exitCode = 1;
|
|
385
|
+
});
|
|
386
|
+
}
|