@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,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
+ };