@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.
@@ -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
+ }