@did-btcr2/cli 0.12.1 → 0.12.3
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/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +165 -38
- package/dist/esm/src/keystore/file-key-store.js +81 -21
- package/dist/esm/src/keystore/file-key-store.js.map +1 -1
- package/dist/esm/src/keystore/lock.js +127 -0
- package/dist/esm/src/keystore/lock.js.map +1 -0
- package/dist/types/src/keystore/file-key-store.d.ts +12 -0
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
- package/dist/types/src/keystore/lock.d.ts +26 -0
- package/dist/types/src/keystore/lock.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/keystore/file-key-store.ts +84 -21
- package/src/keystore/lock.ts +143 -0
package/dist/cjs/index.js
CHANGED
|
@@ -63,7 +63,7 @@ var import_utils2 = require("@noble/hashes/utils.js");
|
|
|
63
63
|
|
|
64
64
|
// src/config.ts
|
|
65
65
|
var import_api = require("@did-btcr2/api");
|
|
66
|
-
var
|
|
66
|
+
var import_node_fs5 = require("fs");
|
|
67
67
|
var import_node_os2 = require("os");
|
|
68
68
|
var import_node_path4 = require("path");
|
|
69
69
|
|
|
@@ -142,7 +142,7 @@ function assertSecurePerms(path) {
|
|
|
142
142
|
var import_key_manager = require("@did-btcr2/key-manager");
|
|
143
143
|
|
|
144
144
|
// src/keystore/file-key-store.ts
|
|
145
|
-
var
|
|
145
|
+
var import_node_fs3 = require("fs");
|
|
146
146
|
var import_node_path3 = require("path");
|
|
147
147
|
var import_base2 = require("@scure/base");
|
|
148
148
|
|
|
@@ -230,6 +230,90 @@ function decryptSecret(env, passphrase) {
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// src/keystore/lock.ts
|
|
234
|
+
var import_node_fs2 = require("fs");
|
|
235
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
236
|
+
var DEFAULT_STALE_MS = 3e4;
|
|
237
|
+
var DEFAULT_RETRY_MS = 50;
|
|
238
|
+
var tokenCounter = 0;
|
|
239
|
+
function sleepSync(ms) {
|
|
240
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
241
|
+
}
|
|
242
|
+
function isProcessAlive(pid) {
|
|
243
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
244
|
+
try {
|
|
245
|
+
process.kill(pid, 0);
|
|
246
|
+
return true;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return error.code === "EPERM";
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function breakIfStale(lockPath, staleMs) {
|
|
252
|
+
let ageMs;
|
|
253
|
+
let pid;
|
|
254
|
+
try {
|
|
255
|
+
const stat = (0, import_node_fs2.statSync)(lockPath);
|
|
256
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
257
|
+
pid = Number.parseInt((0, import_node_fs2.readFileSync)(lockPath, "utf-8").split(".")[0] ?? "", 10);
|
|
258
|
+
} catch {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (ageMs > staleMs || !isProcessAlive(pid)) {
|
|
262
|
+
try {
|
|
263
|
+
(0, import_node_fs2.rmSync)(lockPath, { force: true });
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
function releaseIfOwner(lockPath, token) {
|
|
271
|
+
try {
|
|
272
|
+
if ((0, import_node_fs2.readFileSync)(lockPath, "utf-8") === token) (0, import_node_fs2.rmSync)(lockPath, { force: true });
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function withFileLock(lockPath, fn, options = {}) {
|
|
277
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
278
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
279
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
280
|
+
const token = `${process.pid}.${tokenCounter++}`;
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
for (; ; ) {
|
|
283
|
+
try {
|
|
284
|
+
const fd = (0, import_node_fs2.openSync)(lockPath, "wx", 384);
|
|
285
|
+
try {
|
|
286
|
+
(0, import_node_fs2.writeSync)(fd, token);
|
|
287
|
+
} finally {
|
|
288
|
+
(0, import_node_fs2.closeSync)(fd);
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code !== "EEXIST") {
|
|
293
|
+
throw new KeyStoreError(
|
|
294
|
+
`Failed to acquire keystore lock at ${lockPath}.`,
|
|
295
|
+
"KEYSTORE_LOCK_ERROR",
|
|
296
|
+
{ lockPath, cause: error instanceof Error ? error.message : String(error) }
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (breakIfStale(lockPath, staleMs)) continue;
|
|
300
|
+
if (Date.now() >= deadline) {
|
|
301
|
+
throw new KeyStoreError(
|
|
302
|
+
`Timed out after ${timeoutMs}ms waiting for the keystore lock at ${lockPath}. Another btcr2 process may be writing; retry, or remove the lock file if no other process is running.`,
|
|
303
|
+
"KEYSTORE_LOCKED_ERROR",
|
|
304
|
+
{ lockPath, timeoutMs }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
sleepSync(retryMs);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return fn();
|
|
312
|
+
} finally {
|
|
313
|
+
releaseIfOwner(lockPath, token);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
233
317
|
// src/keystore/paths.ts
|
|
234
318
|
var import_node_os = require("os");
|
|
235
319
|
var import_node_path2 = require("path");
|
|
@@ -242,23 +326,27 @@ function defaultKeystorePath() {
|
|
|
242
326
|
var KEYSTORE_VERSION = 1;
|
|
243
327
|
var FileKeyStore = class {
|
|
244
328
|
#path;
|
|
329
|
+
#lockPath;
|
|
330
|
+
#lockOptions;
|
|
245
331
|
#getPassphrase;
|
|
246
332
|
#argonParams;
|
|
247
333
|
#cache = /* @__PURE__ */ new Map();
|
|
248
334
|
#active;
|
|
249
335
|
constructor(options) {
|
|
250
336
|
this.#path = options.path ?? defaultKeystorePath();
|
|
337
|
+
this.#lockPath = `${this.#path}.lock`;
|
|
338
|
+
this.#lockOptions = options.lock ?? {};
|
|
251
339
|
this.#getPassphrase = options.getPassphrase;
|
|
252
340
|
this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
|
|
253
341
|
ensureDir((0, import_node_path3.dirname)(this.#path), 448);
|
|
254
|
-
this.#
|
|
342
|
+
this.#loadFromDisk();
|
|
255
343
|
}
|
|
256
|
-
#
|
|
257
|
-
if (!(0,
|
|
344
|
+
#loadFromDisk() {
|
|
345
|
+
if (!(0, import_node_fs3.existsSync)(this.#path)) return;
|
|
258
346
|
assertSecurePerms(this.#path);
|
|
259
347
|
let parsed;
|
|
260
348
|
try {
|
|
261
|
-
parsed = JSON.parse((0,
|
|
349
|
+
parsed = JSON.parse((0, import_node_fs3.readFileSync)(this.#path, "utf-8"));
|
|
262
350
|
} catch {
|
|
263
351
|
throw new KeyStoreError(
|
|
264
352
|
`Keystore at ${this.#path} is corrupt or unreadable.`,
|
|
@@ -317,6 +405,42 @@ var FileKeyStore = class {
|
|
|
317
405
|
writeFileAtomic(this.#path, `${JSON.stringify(file, null, 2)}
|
|
318
406
|
`, 384);
|
|
319
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Re-reads the file into the cache, discarding the prior in-memory view so a
|
|
410
|
+
* mutation applies on top of whatever other processes have written. Secrets
|
|
411
|
+
* already decrypted this session are carried over for entries whose sealed
|
|
412
|
+
* envelope is byte-identical on disk, so a mid-session write does not force a
|
|
413
|
+
* re-prompt for keys it did not touch.
|
|
414
|
+
*/
|
|
415
|
+
#reload() {
|
|
416
|
+
const carried = /* @__PURE__ */ new Map();
|
|
417
|
+
for (const [id, entry] of this.#cache) {
|
|
418
|
+
if (entry.secret && entry.decrypted) carried.set(id, { secret: entry.secret, decrypted: entry.decrypted });
|
|
419
|
+
}
|
|
420
|
+
this.#cache.clear();
|
|
421
|
+
this.#active = void 0;
|
|
422
|
+
this.#loadFromDisk();
|
|
423
|
+
for (const [id, prior] of carried) {
|
|
424
|
+
const entry = this.#cache.get(id);
|
|
425
|
+
if (entry?.secret && JSON.stringify(entry.secret) === JSON.stringify(prior.secret)) {
|
|
426
|
+
entry.decrypted = prior.decrypted;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Runs a cache mutation under the exclusive write lock, reloading the file
|
|
432
|
+
* first so the change merges with any concurrent writer's change rather than
|
|
433
|
+
* overwriting it, then flushing the result atomically. Callers must do any
|
|
434
|
+
* expensive work (such as sealing a secret with argon2id) before calling this,
|
|
435
|
+
* so the locked critical section stays short.
|
|
436
|
+
*/
|
|
437
|
+
#mutate(apply) {
|
|
438
|
+
withFileLock(this.#lockPath, () => {
|
|
439
|
+
this.#reload();
|
|
440
|
+
apply();
|
|
441
|
+
this.#flush();
|
|
442
|
+
}, this.#lockOptions);
|
|
443
|
+
}
|
|
320
444
|
get(id) {
|
|
321
445
|
const entry = this.#cache.get(id);
|
|
322
446
|
if (!entry) return void 0;
|
|
@@ -342,26 +466,28 @@ var FileKeyStore = class {
|
|
|
342
466
|
}
|
|
343
467
|
set(id, value) {
|
|
344
468
|
const secret = value.secretKey ? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams) : void 0;
|
|
345
|
-
this.#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
469
|
+
this.#mutate(() => {
|
|
470
|
+
this.#cache.set(id, {
|
|
471
|
+
publicKey: value.publicKey,
|
|
472
|
+
...value.tags && { tags: value.tags },
|
|
473
|
+
...secret && { secret },
|
|
474
|
+
...value.secretKey && { decrypted: value.secretKey }
|
|
475
|
+
});
|
|
350
476
|
});
|
|
351
|
-
this.#flush();
|
|
352
477
|
}
|
|
353
478
|
delete(id) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
this.#
|
|
358
|
-
}
|
|
479
|
+
let existed = false;
|
|
480
|
+
this.#mutate(() => {
|
|
481
|
+
existed = this.#cache.delete(id);
|
|
482
|
+
if (existed && this.#active === id) this.#active = void 0;
|
|
483
|
+
});
|
|
359
484
|
return existed;
|
|
360
485
|
}
|
|
361
486
|
clear() {
|
|
362
|
-
this.#
|
|
363
|
-
|
|
364
|
-
|
|
487
|
+
this.#mutate(() => {
|
|
488
|
+
this.#cache.clear();
|
|
489
|
+
this.#active = void 0;
|
|
490
|
+
});
|
|
365
491
|
}
|
|
366
492
|
/** All stored values with secret keys omitted. Never decrypts, never prompts. */
|
|
367
493
|
list() {
|
|
@@ -400,11 +526,12 @@ var FileKeyStore = class {
|
|
|
400
526
|
* clears it. Throws if the identifier is not a known key.
|
|
401
527
|
*/
|
|
402
528
|
setActive(id) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
529
|
+
this.#mutate(() => {
|
|
530
|
+
if (id !== void 0 && !this.#cache.has(id)) {
|
|
531
|
+
throw new KeyStoreError(`Cannot set unknown key as active: ${id}.`, "KEY_NOT_FOUND_ERROR", { keyId: id });
|
|
532
|
+
}
|
|
533
|
+
this.#active = id;
|
|
534
|
+
});
|
|
408
535
|
}
|
|
409
536
|
};
|
|
410
537
|
|
|
@@ -464,13 +591,13 @@ var FileBackedKeyManager = class {
|
|
|
464
591
|
};
|
|
465
592
|
|
|
466
593
|
// src/keystore/passphrase.ts
|
|
467
|
-
var
|
|
594
|
+
var import_node_fs4 = require("fs");
|
|
468
595
|
var ENV_KEYSTORE_PASSPHRASE = "BTCR2_KEYSTORE_PASSPHRASE";
|
|
469
596
|
function acquirePassphrase(options = {}) {
|
|
470
597
|
const fromEnv = process.env[ENV_KEYSTORE_PASSPHRASE];
|
|
471
598
|
if (fromEnv) return assertNonEmpty(fromEnv.replace(/\r?\n$/, ""));
|
|
472
599
|
if (options.passphraseFile) {
|
|
473
|
-
return assertNonEmpty((0,
|
|
600
|
+
return assertNonEmpty((0, import_node_fs4.readFileSync)(options.passphraseFile, "utf-8").replace(/\r?\n$/, ""));
|
|
474
601
|
}
|
|
475
602
|
if (!process.stdin.isTTY) {
|
|
476
603
|
throw new KeyStoreError(
|
|
@@ -504,7 +631,7 @@ function promptHidden(label) {
|
|
|
504
631
|
for (; ; ) {
|
|
505
632
|
let read = 0;
|
|
506
633
|
try {
|
|
507
|
-
read = (0,
|
|
634
|
+
read = (0, import_node_fs4.readSync)(stdin.fd, byte, 0, 1, null);
|
|
508
635
|
} catch (error) {
|
|
509
636
|
const code = error.code;
|
|
510
637
|
if (code === "EAGAIN") continue;
|
|
@@ -601,7 +728,7 @@ function defaultConfigPath() {
|
|
|
601
728
|
}
|
|
602
729
|
function readConfigFile(path) {
|
|
603
730
|
try {
|
|
604
|
-
const content = (0,
|
|
731
|
+
const content = (0, import_node_fs5.readFileSync)(path, "utf-8");
|
|
605
732
|
return JSON.parse(content);
|
|
606
733
|
} catch {
|
|
607
734
|
return void 0;
|
|
@@ -1034,7 +1161,7 @@ function parseJsonArg2(flagName) {
|
|
|
1034
1161
|
// src/commands/key.ts
|
|
1035
1162
|
var import_keypair = require("@did-btcr2/keypair");
|
|
1036
1163
|
var import_utils3 = require("@noble/hashes/utils.js");
|
|
1037
|
-
var
|
|
1164
|
+
var import_node_fs6 = require("fs");
|
|
1038
1165
|
function registerKeyCommand(program, factory, globals) {
|
|
1039
1166
|
const key = program.command("key").description("Manage keypairs in the encrypted keystore.");
|
|
1040
1167
|
const print = (result) => console.log(formatResult(result, globals()));
|
|
@@ -1132,7 +1259,7 @@ function parseHex(hex, expectedBytes, label) {
|
|
|
1132
1259
|
function readHexFile(path, expectedBytes, label) {
|
|
1133
1260
|
let content;
|
|
1134
1261
|
try {
|
|
1135
|
-
content = (0,
|
|
1262
|
+
content = (0, import_node_fs6.readFileSync)(path, "utf-8");
|
|
1136
1263
|
} catch {
|
|
1137
1264
|
throw new CLIError(`Cannot read ${label} at ${path}.`, "INVALID_ARGUMENT_ERROR", { label, path });
|
|
1138
1265
|
}
|
|
@@ -1141,7 +1268,7 @@ function readHexFile(path, expectedBytes, label) {
|
|
|
1141
1268
|
function writeSecretFile(path, contents) {
|
|
1142
1269
|
let fd;
|
|
1143
1270
|
try {
|
|
1144
|
-
fd = (0,
|
|
1271
|
+
fd = (0, import_node_fs6.openSync)(path, "wx", 384);
|
|
1145
1272
|
} catch (error) {
|
|
1146
1273
|
if (error.code === "EEXIST") {
|
|
1147
1274
|
throw new CLIError(`Refusing to overwrite existing file ${path}. Choose a new --out path.`, "INVALID_ARGUMENT_ERROR", { path });
|
|
@@ -1149,14 +1276,14 @@ function writeSecretFile(path, contents) {
|
|
|
1149
1276
|
throw error;
|
|
1150
1277
|
}
|
|
1151
1278
|
try {
|
|
1152
|
-
(0,
|
|
1279
|
+
(0, import_node_fs6.writeFileSync)(fd, contents);
|
|
1153
1280
|
} finally {
|
|
1154
|
-
(0,
|
|
1281
|
+
(0, import_node_fs6.closeSync)(fd);
|
|
1155
1282
|
}
|
|
1156
1283
|
}
|
|
1157
1284
|
|
|
1158
1285
|
// src/commands/config.ts
|
|
1159
|
-
var
|
|
1286
|
+
var import_node_fs7 = require("fs");
|
|
1160
1287
|
var import_node_path5 = require("path");
|
|
1161
1288
|
function registerConfigCommand(program, globals) {
|
|
1162
1289
|
const config = program.command("config").description("Read and write CLI configuration.");
|
|
@@ -1164,7 +1291,7 @@ function registerConfigCommand(program, globals) {
|
|
|
1164
1291
|
const print = (result) => console.log(formatResult(result, globals()));
|
|
1165
1292
|
config.command("init").description("Create a default config file with one profile per network.").option("--force", "Overwrite an existing config file.", false).action((options) => {
|
|
1166
1293
|
const p = path();
|
|
1167
|
-
if ((0,
|
|
1294
|
+
if ((0, import_node_fs7.existsSync)(p) && !options.force) {
|
|
1168
1295
|
throw new CLIError(`Config already exists at ${p}. Use --force to overwrite.`, "INVALID_ARGUMENT_ERROR", { path: p });
|
|
1169
1296
|
}
|
|
1170
1297
|
const scaffold = {
|
|
@@ -1276,14 +1403,14 @@ function completionScript(shell) {
|
|
|
1276
1403
|
}
|
|
1277
1404
|
|
|
1278
1405
|
// src/version.ts
|
|
1279
|
-
var
|
|
1406
|
+
var import_node_fs8 = require("fs");
|
|
1280
1407
|
var import_node_path6 = require("path");
|
|
1281
1408
|
var import_node_url = require("url");
|
|
1282
1409
|
function readVersion() {
|
|
1283
1410
|
let dir = (0, import_node_path6.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
1284
1411
|
for (let i = 0; i < 5; i++) {
|
|
1285
1412
|
try {
|
|
1286
|
-
const pkg = JSON.parse((0,
|
|
1413
|
+
const pkg = JSON.parse((0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, "package.json"), "utf-8"));
|
|
1287
1414
|
if (pkg.name === "@did-btcr2/cli") return pkg.version;
|
|
1288
1415
|
} catch {
|
|
1289
1416
|
}
|
|
@@ -4,6 +4,7 @@ import { base64urlnopad } from '@scure/base';
|
|
|
4
4
|
import { assertSecurePerms, ensureDir, writeFileAtomic } from './atomic.js';
|
|
5
5
|
import { DEFAULT_ARGON_PARAMS, decryptSecret, encryptSecret } from './envelope.js';
|
|
6
6
|
import { KeyStoreError } from './error.js';
|
|
7
|
+
import { withFileLock } from './lock.js';
|
|
7
8
|
import { defaultKeystorePath } from './paths.js';
|
|
8
9
|
/** Current on-disk keystore file format version. */
|
|
9
10
|
export const KEYSTORE_VERSION = 1;
|
|
@@ -13,6 +14,15 @@ export const KEYSTORE_VERSION = 1;
|
|
|
13
14
|
* in memory at construction and flushing the whole file atomically on every
|
|
14
15
|
* mutation.
|
|
15
16
|
*
|
|
17
|
+
* Every mutation runs under an exclusive cross-process lock and, inside that
|
|
18
|
+
* lock, reloads the file from disk before applying its change and flushing.
|
|
19
|
+
* Caching at construction and flushing the whole file is otherwise a lost-update
|
|
20
|
+
* race: two `btcr2` processes that each load, then each write, would have the
|
|
21
|
+
* second overwrite the first. The lock serializes the writers and the reload
|
|
22
|
+
* merges any change the other made, so concurrent invocations compose instead of
|
|
23
|
+
* clobbering. Reads stay lock-free: an atomic rename means a concurrent reader
|
|
24
|
+
* always sees a complete file, old or new.
|
|
25
|
+
*
|
|
16
26
|
* Secrets are materialized only through {@link FileKeyStore.get}. The
|
|
17
27
|
* {@link FileKeyStore.list} and {@link FileKeyStore.entries} projections omit
|
|
18
28
|
* secret keys and never decrypt, so enumerating the store never triggers a
|
|
@@ -20,18 +30,22 @@ export const KEYSTORE_VERSION = 1;
|
|
|
20
30
|
*/
|
|
21
31
|
export class FileKeyStore {
|
|
22
32
|
#path;
|
|
33
|
+
#lockPath;
|
|
34
|
+
#lockOptions;
|
|
23
35
|
#getPassphrase;
|
|
24
36
|
#argonParams;
|
|
25
37
|
#cache = new Map();
|
|
26
38
|
#active;
|
|
27
39
|
constructor(options) {
|
|
28
40
|
this.#path = options.path ?? defaultKeystorePath();
|
|
41
|
+
this.#lockPath = `${this.#path}.lock`;
|
|
42
|
+
this.#lockOptions = options.lock ?? {};
|
|
29
43
|
this.#getPassphrase = options.getPassphrase;
|
|
30
44
|
this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
|
|
31
45
|
ensureDir(dirname(this.#path), 0o700);
|
|
32
|
-
this.#
|
|
46
|
+
this.#loadFromDisk();
|
|
33
47
|
}
|
|
34
|
-
#
|
|
48
|
+
#loadFromDisk() {
|
|
35
49
|
if (!existsSync(this.#path))
|
|
36
50
|
return;
|
|
37
51
|
assertSecurePerms(this.#path);
|
|
@@ -82,6 +96,43 @@ export class FileKeyStore {
|
|
|
82
96
|
};
|
|
83
97
|
writeFileAtomic(this.#path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
|
|
84
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Re-reads the file into the cache, discarding the prior in-memory view so a
|
|
101
|
+
* mutation applies on top of whatever other processes have written. Secrets
|
|
102
|
+
* already decrypted this session are carried over for entries whose sealed
|
|
103
|
+
* envelope is byte-identical on disk, so a mid-session write does not force a
|
|
104
|
+
* re-prompt for keys it did not touch.
|
|
105
|
+
*/
|
|
106
|
+
#reload() {
|
|
107
|
+
const carried = new Map();
|
|
108
|
+
for (const [id, entry] of this.#cache) {
|
|
109
|
+
if (entry.secret && entry.decrypted)
|
|
110
|
+
carried.set(id, { secret: entry.secret, decrypted: entry.decrypted });
|
|
111
|
+
}
|
|
112
|
+
this.#cache.clear();
|
|
113
|
+
this.#active = undefined;
|
|
114
|
+
this.#loadFromDisk();
|
|
115
|
+
for (const [id, prior] of carried) {
|
|
116
|
+
const entry = this.#cache.get(id);
|
|
117
|
+
if (entry?.secret && JSON.stringify(entry.secret) === JSON.stringify(prior.secret)) {
|
|
118
|
+
entry.decrypted = prior.decrypted;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Runs a cache mutation under the exclusive write lock, reloading the file
|
|
124
|
+
* first so the change merges with any concurrent writer's change rather than
|
|
125
|
+
* overwriting it, then flushing the result atomically. Callers must do any
|
|
126
|
+
* expensive work (such as sealing a secret with argon2id) before calling this,
|
|
127
|
+
* so the locked critical section stays short.
|
|
128
|
+
*/
|
|
129
|
+
#mutate(apply) {
|
|
130
|
+
withFileLock(this.#lockPath, () => {
|
|
131
|
+
this.#reload();
|
|
132
|
+
apply();
|
|
133
|
+
this.#flush();
|
|
134
|
+
}, this.#lockOptions);
|
|
135
|
+
}
|
|
85
136
|
get(id) {
|
|
86
137
|
const entry = this.#cache.get(id);
|
|
87
138
|
if (!entry)
|
|
@@ -112,30 +163,36 @@ export class FileKeyStore {
|
|
|
112
163
|
return this.#cache.has(id);
|
|
113
164
|
}
|
|
114
165
|
set(id, value) {
|
|
166
|
+
// Seal the secret before taking the lock: argon2id is deliberately slow and
|
|
167
|
+
// must not extend the critical section that blocks other processes.
|
|
115
168
|
const secret = value.secretKey
|
|
116
169
|
? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams)
|
|
117
170
|
: undefined;
|
|
118
|
-
this.#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
this.#mutate(() => {
|
|
172
|
+
this.#cache.set(id, {
|
|
173
|
+
publicKey: value.publicKey,
|
|
174
|
+
...(value.tags && { tags: value.tags }),
|
|
175
|
+
...(secret && { secret }),
|
|
176
|
+
...(value.secretKey && { decrypted: value.secretKey }),
|
|
177
|
+
});
|
|
123
178
|
});
|
|
124
|
-
this.#flush();
|
|
125
179
|
}
|
|
126
180
|
delete(id) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
181
|
+
// `existed` reflects the freshly-reloaded state inside the lock, so a key a
|
|
182
|
+
// concurrent process already removed reads as absent rather than resurrected.
|
|
183
|
+
let existed = false;
|
|
184
|
+
this.#mutate(() => {
|
|
185
|
+
existed = this.#cache.delete(id);
|
|
186
|
+
if (existed && this.#active === id)
|
|
130
187
|
this.#active = undefined;
|
|
131
|
-
|
|
132
|
-
}
|
|
188
|
+
});
|
|
133
189
|
return existed;
|
|
134
190
|
}
|
|
135
191
|
clear() {
|
|
136
|
-
this.#
|
|
137
|
-
|
|
138
|
-
|
|
192
|
+
this.#mutate(() => {
|
|
193
|
+
this.#cache.clear();
|
|
194
|
+
this.#active = undefined;
|
|
195
|
+
});
|
|
139
196
|
}
|
|
140
197
|
/** All stored values with secret keys omitted. Never decrypts, never prompts. */
|
|
141
198
|
list() {
|
|
@@ -174,11 +231,14 @@ export class FileKeyStore {
|
|
|
174
231
|
* clears it. Throws if the identifier is not a known key.
|
|
175
232
|
*/
|
|
176
233
|
setActive(id) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
234
|
+
this.#mutate(() => {
|
|
235
|
+
// The existence check runs against the reloaded state, so a key another
|
|
236
|
+
// process added in the meantime is a valid active target.
|
|
237
|
+
if (id !== undefined && !this.#cache.has(id)) {
|
|
238
|
+
throw new KeyStoreError(`Cannot set unknown key as active: ${id}.`, 'KEY_NOT_FOUND_ERROR', { keyId: id });
|
|
239
|
+
}
|
|
240
|
+
this.#active = id;
|
|
241
|
+
});
|
|
182
242
|
}
|
|
183
243
|
}
|
|
184
244
|
//# sourceMappingURL=file-key-store.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-key-store.js","sourceRoot":"","sources":["../../../../src/keystore/file-key-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,iBAAiB,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnF,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEjD,oDAAoD;AACpD,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"file-key-store.js","sourceRoot":"","sources":["../../../../src/keystore/file-key-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from 'node:fs';
|
|
2
|
+
import { KeyStoreError } from './error.js';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
4
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
5
|
+
const DEFAULT_RETRY_MS = 50;
|
|
6
|
+
// A per-process counter so a lock token is unambiguous even when one process
|
|
7
|
+
// runs several stores over the same path (as the tests do): the pid alone would
|
|
8
|
+
// collide, the pid plus counter never does.
|
|
9
|
+
let tokenCounter = 0;
|
|
10
|
+
/**
|
|
11
|
+
* Sleeps synchronously for `ms` without spinning the CPU. The keystore store is
|
|
12
|
+
* a synchronous interface, so the wait must block the thread rather than yield a
|
|
13
|
+
* promise. `Atomics.wait` on a private buffer no other thread can notify always
|
|
14
|
+
* runs the full duration. Node-only, which the keystore already is.
|
|
15
|
+
*/
|
|
16
|
+
function sleepSync(ms) {
|
|
17
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reports whether a process is still running. `process.kill(pid, 0)` sends no
|
|
21
|
+
* signal but performs the existence/permission check: ESRCH means the process
|
|
22
|
+
* is gone, EPERM means it exists under another user (still alive).
|
|
23
|
+
*/
|
|
24
|
+
function isProcessAlive(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return error.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Removes the lock if its writer process is gone or it has aged past `staleMs`,
|
|
37
|
+
* so a process that crashed mid-mutation cannot wedge the keystore permanently.
|
|
38
|
+
* Returns true when the lock was broken or had already vanished (caller should
|
|
39
|
+
* retry the create immediately), false when a live, fresh holder still owns it.
|
|
40
|
+
*/
|
|
41
|
+
function breakIfStale(lockPath, staleMs) {
|
|
42
|
+
let ageMs;
|
|
43
|
+
let pid;
|
|
44
|
+
try {
|
|
45
|
+
const stat = statSync(lockPath);
|
|
46
|
+
ageMs = Date.now() - stat.mtimeMs;
|
|
47
|
+
pid = Number.parseInt(readFileSync(lockPath, 'utf-8').split('.')[0] ?? '', 10);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// The lock disappeared between our failed create and this inspection; the
|
|
51
|
+
// caller can race for it again straight away.
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (ageMs > staleMs || !isProcessAlive(pid)) {
|
|
55
|
+
try {
|
|
56
|
+
rmSync(lockPath, { force: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Another waiter broke it first; either way it is gone, so retry.
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
/** Releases the lock only while it still holds our token, never one broken from us as stale. */
|
|
66
|
+
function releaseIfOwner(lockPath, token) {
|
|
67
|
+
try {
|
|
68
|
+
if (readFileSync(lockPath, 'utf-8') === token)
|
|
69
|
+
rmSync(lockPath, { force: true });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Already removed (broken as stale, or never created); nothing to release.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Runs `fn` while holding an exclusive, cross-process advisory lock on
|
|
77
|
+
* `lockPath`, then releases it.
|
|
78
|
+
*
|
|
79
|
+
* The lock is an `O_EXCL` lockfile: creating it fails when another holder
|
|
80
|
+
* exists, which serializes mutators across separate `btcr2` processes. This is
|
|
81
|
+
* the missing half of a safe read-modify-write on the keystore file: an atomic
|
|
82
|
+
* rename keeps the file from tearing, but only mutual exclusion (paired with a
|
|
83
|
+
* reload inside the lock) keeps two concurrent writers from clobbering each
|
|
84
|
+
* other's changes. A lock whose writer has died, or that has aged past
|
|
85
|
+
* `staleMs`, is broken so a crash cannot deadlock future invocations.
|
|
86
|
+
*
|
|
87
|
+
* @throws {KeyStoreError} `KEYSTORE_LOCKED_ERROR` if the lock cannot be acquired
|
|
88
|
+
* within `timeoutMs`, or `KEYSTORE_LOCK_ERROR` on an unexpected filesystem error.
|
|
89
|
+
*/
|
|
90
|
+
export function withFileLock(lockPath, fn, options = {}) {
|
|
91
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
92
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
93
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
94
|
+
const token = `${process.pid}.${tokenCounter++}`;
|
|
95
|
+
const deadline = Date.now() + timeoutMs;
|
|
96
|
+
for (;;) {
|
|
97
|
+
try {
|
|
98
|
+
const fd = openSync(lockPath, 'wx', 0o600);
|
|
99
|
+
try {
|
|
100
|
+
writeSync(fd, token);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code !== 'EEXIST') {
|
|
109
|
+
throw new KeyStoreError(`Failed to acquire keystore lock at ${lockPath}.`, 'KEYSTORE_LOCK_ERROR', { lockPath, cause: error instanceof Error ? error.message : String(error) });
|
|
110
|
+
}
|
|
111
|
+
if (breakIfStale(lockPath, staleMs))
|
|
112
|
+
continue;
|
|
113
|
+
if (Date.now() >= deadline) {
|
|
114
|
+
throw new KeyStoreError(`Timed out after ${timeoutMs}ms waiting for the keystore lock at ${lockPath}. `
|
|
115
|
+
+ 'Another btcr2 process may be writing; retry, or remove the lock file if no other process is running.', 'KEYSTORE_LOCKED_ERROR', { lockPath, timeoutMs });
|
|
116
|
+
}
|
|
117
|
+
sleepSync(retryMs);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return fn();
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
releaseIfOwner(lockPath, token);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=lock.js.map
|