@did-btcr2/cli 0.15.0 → 0.17.0

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.
Files changed (76) hide show
  1. package/README.md +47 -6
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +836 -122
  4. package/dist/esm/src/cli.js +7 -4
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +11 -15
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +4 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +3 -1
  11. package/dist/esm/src/commands/deactivate.js.map +1 -1
  12. package/dist/esm/src/commands/index.js +2 -0
  13. package/dist/esm/src/commands/index.js.map +1 -1
  14. package/dist/esm/src/commands/init.js +69 -0
  15. package/dist/esm/src/commands/init.js.map +1 -0
  16. package/dist/esm/src/commands/keystore.js +180 -0
  17. package/dist/esm/src/commands/keystore.js.map +1 -0
  18. package/dist/esm/src/commands/profile.js +1 -1
  19. package/dist/esm/src/commands/profile.js.map +1 -1
  20. package/dist/esm/src/commands/update.js +3 -1
  21. package/dist/esm/src/commands/update.js.map +1 -1
  22. package/dist/esm/src/config.js +109 -31
  23. package/dist/esm/src/config.js.map +1 -1
  24. package/dist/esm/src/keystore/file-key-store.js +388 -32
  25. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  26. package/dist/esm/src/keystore/passphrase.js +53 -10
  27. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  28. package/dist/esm/src/keystore/paths.js +6 -18
  29. package/dist/esm/src/keystore/paths.js.map +1 -1
  30. package/dist/esm/src/keystore/session.js +250 -0
  31. package/dist/esm/src/keystore/session.js.map +1 -0
  32. package/dist/esm/src/paths.js +71 -0
  33. package/dist/esm/src/paths.js.map +1 -0
  34. package/dist/esm/src/types.js.map +1 -1
  35. package/dist/types/src/cli.d.ts.map +1 -1
  36. package/dist/types/src/commands/config.d.ts.map +1 -1
  37. package/dist/types/src/commands/create.d.ts.map +1 -1
  38. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  39. package/dist/types/src/commands/index.d.ts +2 -0
  40. package/dist/types/src/commands/index.d.ts.map +1 -1
  41. package/dist/types/src/commands/init.d.ts +13 -0
  42. package/dist/types/src/commands/init.d.ts.map +1 -0
  43. package/dist/types/src/commands/keystore.d.ts +11 -0
  44. package/dist/types/src/commands/keystore.d.ts.map +1 -0
  45. package/dist/types/src/commands/update.d.ts.map +1 -1
  46. package/dist/types/src/config.d.ts +38 -14
  47. package/dist/types/src/config.d.ts.map +1 -1
  48. package/dist/types/src/keystore/file-key-store.d.ts +102 -10
  49. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  50. package/dist/types/src/keystore/passphrase.d.ts +26 -1
  51. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  52. package/dist/types/src/keystore/paths.d.ts +6 -10
  53. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  54. package/dist/types/src/keystore/session.d.ts +105 -0
  55. package/dist/types/src/keystore/session.d.ts.map +1 -0
  56. package/dist/types/src/paths.d.ts +64 -0
  57. package/dist/types/src/paths.d.ts.map +1 -0
  58. package/dist/types/src/types.d.ts +53 -0
  59. package/dist/types/src/types.d.ts.map +1 -1
  60. package/package.json +3 -3
  61. package/src/cli.ts +8 -3
  62. package/src/commands/config.ts +12 -14
  63. package/src/commands/create.ts +4 -1
  64. package/src/commands/deactivate.ts +3 -1
  65. package/src/commands/index.ts +2 -0
  66. package/src/commands/init.ts +80 -0
  67. package/src/commands/keystore.ts +232 -0
  68. package/src/commands/profile.ts +1 -1
  69. package/src/commands/update.ts +3 -1
  70. package/src/config.ts +118 -35
  71. package/src/keystore/file-key-store.ts +498 -43
  72. package/src/keystore/passphrase.ts +71 -8
  73. package/src/keystore/paths.ts +6 -19
  74. package/src/keystore/session.ts +303 -0
  75. package/src/paths.ts +92 -0
  76. package/src/types.ts +16 -1
package/dist/cjs/index.js CHANGED
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  REDACTED: () => REDACTED,
29
29
  SUPPORTED_NETWORKS: () => SUPPORTED_NETWORKS,
30
30
  VERSION: () => VERSION,
31
+ assertKeystoreAllowedForNetwork: () => assertKeystoreAllowedForNetwork,
31
32
  blankToUndef: () => blankToUndef,
32
33
  defaultApiFactory: () => defaultApiFactory,
33
34
  defaultConfigPath: () => defaultConfigPath,
@@ -47,7 +48,9 @@ __export(index_exports, {
47
48
  registerConfigCommand: () => registerConfigCommand,
48
49
  registerCreateCommand: () => registerCreateCommand,
49
50
  registerDeactivateCommand: () => registerDeactivateCommand,
51
+ registerInitCommand: () => registerInitCommand,
50
52
  registerKeyCommand: () => registerKeyCommand,
53
+ registerKeystoreCommand: () => registerKeystoreCommand,
51
54
  registerProfileCommand: () => registerProfileCommand,
52
55
  registerResolveCommand: () => registerResolveCommand,
53
56
  registerUpdateCommand: () => registerUpdateCommand,
@@ -57,6 +60,7 @@ __export(index_exports, {
57
60
  resolveDefaultNetwork: () => resolveDefaultNetwork,
58
61
  resolveEffectiveConfig: () => resolveEffectiveConfig,
59
62
  resolveKeystorePath: () => resolveKeystorePath,
63
+ resolveKeystoreProtection: () => resolveKeystoreProtection,
60
64
  resolveOutputFormat: () => resolveOutputFormat,
61
65
  resolveSecretRef: () => resolveSecretRef,
62
66
  resolveSigningKeyRef: () => resolveSigningKeyRef,
@@ -64,7 +68,8 @@ __export(index_exports, {
64
68
  scrubUrlUserinfo: () => scrubUrlUserinfo,
65
69
  setConfigPath: () => setConfigPath,
66
70
  unsetConfigPath: () => unsetConfigPath,
67
- writeConfigFile: () => writeConfigFile
71
+ writeConfigFile: () => writeConfigFile,
72
+ writeDefaultConfigFile: () => writeDefaultConfigFile
68
73
  });
69
74
  module.exports = __toCommonJS(index_exports);
70
75
 
@@ -76,15 +81,14 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
76
81
  var import_common3 = require("@did-btcr2/common");
77
82
  var import_commander = require("commander");
78
83
 
79
- // src/commands/create.ts
80
- var import_utils2 = require("@noble/hashes/utils.js");
84
+ // src/commands/init.ts
85
+ var import_node_fs7 = require("fs");
81
86
 
82
87
  // src/config.ts
83
88
  var import_api = require("@did-btcr2/api");
84
89
  var import_method = require("@did-btcr2/method");
85
- var import_node_fs5 = require("fs");
86
- var import_node_os2 = require("os");
87
- var import_node_path4 = require("path");
90
+ var import_node_fs6 = require("fs");
91
+ var import_node_path5 = require("path");
88
92
 
89
93
  // src/error.ts
90
94
  var import_common = require("@did-btcr2/common");
@@ -163,6 +167,8 @@ var import_key_manager = require("@did-btcr2/key-manager");
163
167
  // src/keystore/file-key-store.ts
164
168
  var import_node_fs3 = require("fs");
165
169
  var import_node_path3 = require("path");
170
+ var import_sha2 = require("@noble/hashes/sha2.js");
171
+ var import_utils2 = require("@noble/hashes/utils.js");
166
172
  var import_base2 = require("@scure/base");
167
173
 
168
174
  // src/keystore/envelope.ts
@@ -333,7 +339,7 @@ function withFileLock(lockPath, fn, options = {}) {
333
339
  }
334
340
  }
335
341
 
336
- // src/keystore/paths.ts
342
+ // src/paths.ts
337
343
  var import_node_os = require("os");
338
344
  var import_node_path2 = require("path");
339
345
 
@@ -351,14 +357,43 @@ function blankToUndef(value) {
351
357
  return value.trim() === "" ? void 0 : value;
352
358
  }
353
359
 
354
- // src/keystore/paths.ts
355
- function defaultKeystorePath() {
356
- const base = blankToUndef(process.env.XDG_DATA_HOME) ?? blankToUndef(process.env.LOCALAPPDATA) ?? (0, import_node_path2.join)((0, import_node_os.homedir)(), ".local", "share");
357
- return (0, import_node_path2.join)(base, "btcr2", "keystore.json");
360
+ // src/paths.ts
361
+ var ENV_HOME = "BTCR2_HOME";
362
+ var CONFIG_FILENAME = "config.json";
363
+ var KEYSTORE_FILENAME = "keystore.json";
364
+ var SESSION_FILENAME = "session.json";
365
+ function resolveHome(overrides) {
366
+ return blankToUndef(overrides?.home) ?? blankToUndef(process.env[ENV_HOME]) ?? platformDefaultHome();
367
+ }
368
+ function platformDefaultHome() {
369
+ if (process.platform === "win32") {
370
+ const base = blankToUndef(process.env.LOCALAPPDATA) ?? blankToUndef(process.env.APPDATA) ?? (0, import_node_os.homedir)();
371
+ return (0, import_node_path2.join)(base, "btcr2");
372
+ }
373
+ return (0, import_node_path2.join)((0, import_node_os.homedir)(), ".btcr2");
374
+ }
375
+ function defaultConfigPath(overrides) {
376
+ return (0, import_node_path2.join)(resolveHome(overrides), CONFIG_FILENAME);
377
+ }
378
+ function defaultKeystorePath(overrides) {
379
+ return (0, import_node_path2.join)(resolveHome(overrides), KEYSTORE_FILENAME);
380
+ }
381
+ function defaultSessionPath(overrides) {
382
+ return (0, import_node_path2.join)(resolveHome(overrides), SESSION_FILENAME);
358
383
  }
359
384
 
360
385
  // src/keystore/file-key-store.ts
361
386
  var KEYSTORE_VERSION = 1;
387
+ var VERIFIER_PLAINTEXT = (0, import_utils2.utf8ToBytes)("did-btcr2-keystore-verifier-v1");
388
+ function bytesEqual(a, b) {
389
+ if (a.length !== b.length) return false;
390
+ let diff = 0;
391
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
392
+ return diff === 0;
393
+ }
394
+ function sameEnvelope(a, b) {
395
+ return JSON.stringify(a) === JSON.stringify(b);
396
+ }
362
397
  var FileKeyStore = class {
363
398
  #path;
364
399
  #lockPath;
@@ -367,12 +402,15 @@ var FileKeyStore = class {
367
402
  #argonParams;
368
403
  #cache = /* @__PURE__ */ new Map();
369
404
  #active;
405
+ #protection;
406
+ #verifier;
370
407
  constructor(options) {
371
408
  this.#path = options.path ?? defaultKeystorePath();
372
409
  this.#lockPath = `${this.#path}.lock`;
373
410
  this.#lockOptions = options.lock ?? {};
374
411
  this.#getPassphrase = options.getPassphrase;
375
412
  this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
413
+ this.#protection = options.protection ?? "passphrase";
376
414
  ensureDir((0, import_node_path3.dirname)(this.#path), 448);
377
415
  this.#loadFromDisk();
378
416
  }
@@ -396,7 +434,26 @@ var FileKeyStore = class {
396
434
  { version: parsed.v }
397
435
  );
398
436
  }
437
+ if (parsed.protection !== "none" && parsed.protection !== "passphrase") {
438
+ throw new KeyStoreError(
439
+ `Keystore at ${this.#path} has no recognized protection header; it was not written by this CLI.`,
440
+ "KEYSTORE_CORRUPT_ERROR",
441
+ { path: this.#path }
442
+ );
443
+ }
444
+ this.#protection = parsed.protection;
445
+ this.#verifier = parsed.verifier;
399
446
  this.#active = parsed.active;
447
+ const sealedPresent = Object.values(parsed.keys ?? {}).some(
448
+ (k) => k && typeof k === "object" && k.secret !== void 0
449
+ );
450
+ if (this.#protection === "passphrase" && sealedPresent && this.#verifier === void 0) {
451
+ throw new KeyStoreError(
452
+ `Keystore at ${this.#path} holds sealed keys but no passphrase verifier; it is corrupt or was tampered with.`,
453
+ "KEYSTORE_CORRUPT_ERROR",
454
+ { path: this.#path }
455
+ );
456
+ }
400
457
  for (const [id, stored] of Object.entries(parsed.keys ?? {})) {
401
458
  let publicKey;
402
459
  try {
@@ -416,24 +473,67 @@ var FileKeyStore = class {
416
473
  { path: this.#path, keyId: id }
417
474
  );
418
475
  }
419
- this.#cache.set(id, {
420
- publicKey,
421
- ...stored.tags && { tags: stored.tags },
422
- ...stored.secret && { secret: stored.secret }
423
- });
476
+ const entry = { publicKey, ...stored.tags && { tags: stored.tags } };
477
+ if (stored.plainSecret !== void 0) {
478
+ if (this.#protection !== "none") {
479
+ throw new KeyStoreError(
480
+ `Keystore entry ${id} holds a plaintext secret in an encrypted keystore at ${this.#path}.`,
481
+ "KEYSTORE_CORRUPT_ERROR",
482
+ { path: this.#path, keyId: id }
483
+ );
484
+ }
485
+ entry.plaintext = true;
486
+ entry.decrypted = this.#decodePlainSecret(stored.plainSecret, id);
487
+ } else if (stored.secret) {
488
+ if (this.#protection !== "passphrase") {
489
+ throw new KeyStoreError(
490
+ `Keystore entry ${id} holds a sealed secret in a dev keystore at ${this.#path}.`,
491
+ "KEYSTORE_CORRUPT_ERROR",
492
+ { path: this.#path, keyId: id }
493
+ );
494
+ }
495
+ entry.secret = stored.secret;
496
+ }
497
+ this.#cache.set(id, entry);
424
498
  }
425
499
  }
500
+ /** Decodes and length-checks a dev-keystore plaintext secret. */
501
+ #decodePlainSecret(encoded, id) {
502
+ let secret;
503
+ try {
504
+ secret = import_base2.base64urlnopad.decode(encoded);
505
+ } catch {
506
+ throw new KeyStoreError(
507
+ `Keystore entry ${id} has a malformed plaintext secret.`,
508
+ "KEYSTORE_CORRUPT_ERROR",
509
+ { path: this.#path, keyId: id }
510
+ );
511
+ }
512
+ if (secret.length !== 32) {
513
+ throw new KeyStoreError(
514
+ `Keystore entry ${id} has a ${secret.length}-byte secret; expected 32.`,
515
+ "KEYSTORE_CORRUPT_ERROR",
516
+ { path: this.#path, keyId: id }
517
+ );
518
+ }
519
+ return secret;
520
+ }
426
521
  #flush() {
427
522
  const keys = {};
428
523
  for (const [id, entry] of this.#cache) {
429
- keys[id] = {
430
- publicKey: import_base2.base64urlnopad.encode(entry.publicKey),
431
- ...entry.tags && { tags: entry.tags },
432
- ...entry.secret && { secret: entry.secret }
433
- };
524
+ const stored = { publicKey: import_base2.base64urlnopad.encode(entry.publicKey) };
525
+ if (entry.tags) stored.tags = entry.tags;
526
+ if (entry.plaintext && entry.decrypted) {
527
+ stored.plainSecret = import_base2.base64urlnopad.encode(entry.decrypted);
528
+ } else if (entry.secret) {
529
+ stored.secret = entry.secret;
530
+ }
531
+ keys[id] = stored;
434
532
  }
435
533
  const file = {
436
534
  v: KEYSTORE_VERSION,
535
+ protection: this.#protection,
536
+ ...this.#verifier && { verifier: this.#verifier },
437
537
  ...this.#active && { active: this.#active },
438
538
  keys
439
539
  };
@@ -445,7 +545,8 @@ var FileKeyStore = class {
445
545
  * mutation applies on top of whatever other processes have written. Secrets
446
546
  * already decrypted this session are carried over for entries whose sealed
447
547
  * envelope is byte-identical on disk, so a mid-session write does not force a
448
- * re-prompt for keys it did not touch.
548
+ * re-prompt for keys it did not touch. (Dev-keystore plaintext secrets are
549
+ * reloaded from disk, so they need no carry.)
449
550
  */
450
551
  #reload() {
451
552
  const carried = /* @__PURE__ */ new Map();
@@ -476,6 +577,53 @@ var FileKeyStore = class {
476
577
  this.#flush();
477
578
  }, this.#lockOptions);
478
579
  }
580
+ /**
581
+ * Verifies a candidate passphrase against the keystore verifier, throwing when
582
+ * it does not open the sentinel. A no-op only on a fresh keystore whose
583
+ * passphrase has not been established yet (no verifier to check against).
584
+ */
585
+ #assertPassphrase(passphrase) {
586
+ if (!this.#verifier) return;
587
+ let plain;
588
+ try {
589
+ plain = decryptSecret(this.#verifier, passphrase);
590
+ } catch {
591
+ throw new KeyStoreError(
592
+ `Incorrect passphrase for the keystore at ${this.#path}.`,
593
+ "DECRYPT_ERROR",
594
+ { path: this.#path }
595
+ );
596
+ }
597
+ if (!bytesEqual(plain, VERIFIER_PLAINTEXT)) {
598
+ throw new KeyStoreError(
599
+ `Keystore verifier at ${this.#path} did not match; the file may be corrupt.`,
600
+ "KEYSTORE_CORRUPT_ERROR",
601
+ { path: this.#path }
602
+ );
603
+ }
604
+ }
605
+ /** Passphrase for opening an existing secret: verified when a verifier exists. */
606
+ #openPassphrase() {
607
+ const passphrase = this.#getPassphrase();
608
+ this.#assertPassphrase(passphrase);
609
+ return passphrase;
610
+ }
611
+ /**
612
+ * Passphrase for sealing a secret. When the keystore's passphrase is already
613
+ * established (a verifier exists), prompt once and verify it. Otherwise this is
614
+ * establishment on a fresh keystore: prompt with confirm and mint a verifier for
615
+ * the caller to persist alongside the first sealed key.
616
+ */
617
+ #sealPassphrase() {
618
+ if (this.#verifier) {
619
+ const passphrase2 = this.#getPassphrase();
620
+ this.#assertPassphrase(passphrase2);
621
+ return { passphrase: passphrase2 };
622
+ }
623
+ const passphrase = this.#getPassphrase({ confirm: true });
624
+ const newVerifier = encryptSecret(VERIFIER_PLAINTEXT, passphrase, this.#argonParams);
625
+ return { passphrase, newVerifier };
626
+ }
479
627
  get(id) {
480
628
  const entry = this.#cache.get(id);
481
629
  if (!entry) return void 0;
@@ -483,13 +631,20 @@ var FileKeyStore = class {
483
631
  publicKey: entry.publicKey,
484
632
  ...entry.tags && { tags: entry.tags }
485
633
  };
486
- if (entry.secret) {
634
+ if (entry.plaintext && entry.decrypted) {
635
+ const secret = entry.decrypted;
636
+ Object.defineProperty(result, "secretKey", {
637
+ configurable: true,
638
+ enumerable: false,
639
+ get: () => secret
640
+ });
641
+ } else if (entry.secret) {
487
642
  const sealed = entry.secret;
488
643
  Object.defineProperty(result, "secretKey", {
489
644
  configurable: true,
490
645
  enumerable: false,
491
646
  get: () => {
492
- entry.decrypted ??= decryptSecret(sealed, this.#getPassphrase());
647
+ entry.decrypted ??= decryptSecret(sealed, this.#openPassphrase());
493
648
  return entry.decrypted;
494
649
  }
495
650
  });
@@ -500,12 +655,45 @@ var FileKeyStore = class {
500
655
  return this.#cache.has(id);
501
656
  }
502
657
  set(id, value) {
503
- const secret = value.secretKey ? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams) : void 0;
658
+ if (this.#protection === "none") {
659
+ this.#mutate(() => {
660
+ this.#cache.set(id, {
661
+ publicKey: value.publicKey,
662
+ ...value.tags && { tags: value.tags },
663
+ ...value.secretKey && { plaintext: true, decrypted: value.secretKey }
664
+ });
665
+ });
666
+ return;
667
+ }
668
+ const verifierAtSeal = this.#verifier;
669
+ let sealed;
670
+ let newVerifier;
671
+ let passphrase;
672
+ if (value.secretKey) {
673
+ const resolved = this.#sealPassphrase();
674
+ passphrase = resolved.passphrase;
675
+ newVerifier = resolved.newVerifier;
676
+ sealed = encryptSecret(value.secretKey, passphrase, this.#argonParams);
677
+ }
504
678
  this.#mutate(() => {
679
+ if (value.secretKey) {
680
+ if (this.#verifier === void 0 && newVerifier !== void 0) {
681
+ this.#verifier = newVerifier;
682
+ } else if (this.#verifier !== void 0 && passphrase !== void 0) {
683
+ if (verifierAtSeal !== void 0 && !sameEnvelope(this.#verifier, verifierAtSeal)) {
684
+ throw new KeyStoreError(
685
+ `The keystore passphrase at ${this.#path} changed concurrently; re-run the command.`,
686
+ "KEYSTORE_CONCURRENT_CHANGE_ERROR",
687
+ { path: this.#path }
688
+ );
689
+ }
690
+ this.#assertPassphrase(passphrase);
691
+ }
692
+ }
505
693
  this.#cache.set(id, {
506
694
  publicKey: value.publicKey,
507
695
  ...value.tags && { tags: value.tags },
508
- ...secret && { secret },
696
+ ...sealed && { secret: sealed },
509
697
  ...value.secretKey && { decrypted: value.secretKey }
510
698
  });
511
699
  });
@@ -568,7 +756,138 @@ var FileKeyStore = class {
568
756
  this.#active = id;
569
757
  });
570
758
  }
759
+ /** The keystore's protection mode. */
760
+ get protection() {
761
+ return this.#protection;
762
+ }
763
+ /**
764
+ * Re-seals every sealed secret and the verifier under a new passphrase (ADR
765
+ * 080), returning the number of secrets re-sealed. Verifies the current
766
+ * passphrase against the verifier first. Refused on a dev keystore, which has
767
+ * no passphrase.
768
+ */
769
+ changePassphrase(oldPassphrase, newPassphrase) {
770
+ if (this.#protection === "none") {
771
+ throw new KeyStoreError(
772
+ `The keystore at ${this.#path} is an unencrypted dev keystore; it has no passphrase to change.`,
773
+ "KEYSTORE_PROTECTION_ERROR",
774
+ { path: this.#path }
775
+ );
776
+ }
777
+ if (this.#verifier) this.#assertPassphrase(oldPassphrase);
778
+ const resealed = /* @__PURE__ */ new Map();
779
+ for (const [id, entry] of this.#cache) {
780
+ if (!entry.secret) continue;
781
+ let plain;
782
+ try {
783
+ plain = decryptSecret(entry.secret, oldPassphrase);
784
+ } catch {
785
+ throw new KeyStoreError(
786
+ `Incorrect current passphrase for the keystore at ${this.#path}.`,
787
+ "DECRYPT_ERROR",
788
+ { path: this.#path }
789
+ );
790
+ }
791
+ resealed.set(id, { from: entry.secret, to: encryptSecret(plain, newPassphrase, this.#argonParams), plain });
792
+ }
793
+ const newVerifier = encryptSecret(VERIFIER_PLAINTEXT, newPassphrase, this.#argonParams);
794
+ let rekeyed = 0;
795
+ this.#mutate(() => {
796
+ for (const [id, entry] of this.#cache) {
797
+ if (!entry.secret) continue;
798
+ const pre = resealed.get(id);
799
+ if (!pre || !sameEnvelope(pre.from, entry.secret)) {
800
+ throw new KeyStoreError(
801
+ `The keystore at ${this.#path} changed while its passphrase was being changed; re-run the command.`,
802
+ "KEYSTORE_CONCURRENT_CHANGE_ERROR",
803
+ { path: this.#path }
804
+ );
805
+ }
806
+ entry.secret = pre.to;
807
+ entry.decrypted = pre.plain;
808
+ rekeyed++;
809
+ }
810
+ this.#verifier = newVerifier;
811
+ });
812
+ return rekeyed;
813
+ }
571
814
  };
815
+ function keystoreSummary(path) {
816
+ const absent = { protection: "absent", established: false, keyCount: 0, active: void 0 };
817
+ if (!(0, import_node_fs3.existsSync)(path)) return absent;
818
+ let parsed;
819
+ try {
820
+ parsed = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf-8"));
821
+ } catch {
822
+ return absent;
823
+ }
824
+ const keys = parsed.keys && typeof parsed.keys === "object" ? parsed.keys : {};
825
+ const keyCount = Object.keys(keys).length;
826
+ const active = typeof parsed.active === "string" ? parsed.active : void 0;
827
+ if (parsed.protection === "none") {
828
+ return { protection: "dev", established: true, keyCount, active };
829
+ }
830
+ if (parsed.protection === "passphrase") {
831
+ return { protection: "encrypted", established: parsed.verifier !== void 0, keyCount, active };
832
+ }
833
+ return absent;
834
+ }
835
+ function keystoreProtection(path) {
836
+ return keystoreSummary(path).protection;
837
+ }
838
+ function keystoreVerifierId(path) {
839
+ if (!(0, import_node_fs3.existsSync)(path)) return void 0;
840
+ let parsed;
841
+ try {
842
+ parsed = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf-8"));
843
+ } catch {
844
+ return void 0;
845
+ }
846
+ if (!parsed.verifier) return void 0;
847
+ return import_base2.base64urlnopad.encode((0, import_sha2.sha256)((0, import_utils2.utf8ToBytes)(JSON.stringify(parsed.verifier))));
848
+ }
849
+ function verifyKeystorePassphrase(path, passphrase) {
850
+ if (!(0, import_node_fs3.existsSync)(path)) return false;
851
+ let parsed;
852
+ try {
853
+ parsed = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf-8"));
854
+ } catch {
855
+ return false;
856
+ }
857
+ if (parsed.protection !== "passphrase" || !parsed.verifier) return false;
858
+ try {
859
+ return bytesEqual(decryptSecret(parsed.verifier, passphrase), VERIFIER_PLAINTEXT);
860
+ } catch {
861
+ return false;
862
+ }
863
+ }
864
+ function initKeystore(path, options) {
865
+ ensureDir((0, import_node_path3.dirname)(path), 448);
866
+ let file;
867
+ if (options.protection === "none") {
868
+ file = { v: KEYSTORE_VERSION, protection: "none", keys: {} };
869
+ } else {
870
+ const passphrase = options.getPassphrase({ confirm: true });
871
+ const verifier = encryptSecret(VERIFIER_PLAINTEXT, passphrase, options.argonParams ?? DEFAULT_ARGON_PARAMS);
872
+ file = { v: KEYSTORE_VERSION, protection: "passphrase", verifier, keys: {} };
873
+ }
874
+ writeFileAtomic(path, `${JSON.stringify(file, null, 2)}
875
+ `, 384);
876
+ }
877
+ function changeKeystorePassphrase(path, oldPassphrase, newPassphrase, argonParams) {
878
+ const store = new FileKeyStore({
879
+ path,
880
+ ...argonParams && { argonParams },
881
+ getPassphrase: () => {
882
+ throw new KeyStoreError(
883
+ `Unexpected passphrase prompt while changing the passphrase for ${path}.`,
884
+ "KEYSTORE_INTERNAL_ERROR",
885
+ { path }
886
+ );
887
+ }
888
+ });
889
+ return store.changePassphrase(oldPassphrase, newPassphrase);
890
+ }
572
891
 
573
892
  // src/keystore/file-backed-key-manager.ts
574
893
  var FileBackedKeyManager = class {
@@ -629,10 +948,14 @@ var FileBackedKeyManager = class {
629
948
  var import_node_fs4 = require("fs");
630
949
  var ENV_KEYSTORE_PASSPHRASE = "BTCR2_KEYSTORE_PASSPHRASE";
631
950
  function acquirePassphrase(options = {}) {
632
- const fromEnv = process.env[ENV_KEYSTORE_PASSPHRASE];
633
- if (fromEnv) return assertNonEmpty(fromEnv.replace(/\r?\n$/, ""));
634
- if (options.passphraseFile) {
635
- return assertNonEmpty((0, import_node_fs4.readFileSync)(options.passphraseFile, "utf-8").replace(/\r?\n$/, ""));
951
+ if (!options.forcePrompt) {
952
+ const fromEnv = process.env[ENV_KEYSTORE_PASSPHRASE];
953
+ if (fromEnv) return assertNonEmpty(fromEnv.replace(/\r?\n$/, ""));
954
+ if (options.passphraseFile) {
955
+ return assertNonEmpty((0, import_node_fs4.readFileSync)(options.passphraseFile, "utf-8").replace(/\r?\n$/, ""));
956
+ }
957
+ const fromSession = options.beforePrompt?.();
958
+ if (fromSession) return assertNonEmpty(fromSession);
636
959
  }
637
960
  if (!process.stdin.isTTY) {
638
961
  throw new KeyStoreError(
@@ -655,6 +978,12 @@ function assertNonEmpty(passphrase) {
655
978
  }
656
979
  return passphrase;
657
980
  }
981
+ var IDLE_WAIT = new Int32Array(new SharedArrayBuffer(4));
982
+ var IDLE_POLL_MS = 20;
983
+ function dropLastUtf8Char(bytes) {
984
+ while (bytes.length > 0 && (bytes[bytes.length - 1] & 192) === 128) bytes.pop();
985
+ bytes.pop();
986
+ }
658
987
  function promptHidden(label) {
659
988
  process.stderr.write(label);
660
989
  const stdin = process.stdin;
@@ -669,7 +998,10 @@ function promptHidden(label) {
669
998
  read = (0, import_node_fs4.readSync)(stdin.fd, byte, 0, 1, null);
670
999
  } catch (error) {
671
1000
  const code = error.code;
672
- if (code === "EAGAIN") continue;
1001
+ if (code === "EAGAIN") {
1002
+ Atomics.wait(IDLE_WAIT, 0, 0, IDLE_POLL_MS);
1003
+ continue;
1004
+ }
673
1005
  if (code === "EOF") break;
674
1006
  throw error;
675
1007
  }
@@ -680,7 +1012,7 @@ function promptHidden(label) {
680
1012
  throw new KeyStoreError("Passphrase entry aborted.", "PASSPHRASE_REQUIRED_ERROR");
681
1013
  }
682
1014
  if (ch === 127 || ch === 8) {
683
- bytes.pop();
1015
+ dropLastUtf8Char(bytes);
684
1016
  continue;
685
1017
  }
686
1018
  bytes.push(ch);
@@ -692,16 +1024,168 @@ function promptHidden(label) {
692
1024
  return Buffer.from(bytes).toString("utf-8");
693
1025
  }
694
1026
 
1027
+ // src/keystore/session.ts
1028
+ var import_node_fs5 = require("fs");
1029
+ var import_node_path4 = require("path");
1030
+ var import_utils3 = require("@noble/hashes/utils.js");
1031
+ var import_base3 = require("@scure/base");
1032
+ var SESSION_VERSION = 1;
1033
+ var DEFAULT_SESSION_TTL_MS = 60 * 60 * 1e3;
1034
+ var MAX_SESSION_TTL_MS = 24 * 60 * 60 * 1e3;
1035
+ var ENV_KEYSTORE_TTL = "BTCR2_KEYSTORE_TTL";
1036
+ function writeSession(sessionPath, input) {
1037
+ const createdAt = Date.now();
1038
+ const session = {
1039
+ v: SESSION_VERSION,
1040
+ keystore: (0, import_node_path4.resolve)(input.keystorePath),
1041
+ verifierId: input.verifierId,
1042
+ passphrase: import_base3.base64urlnopad.encode((0, import_utils3.utf8ToBytes)(input.passphrase)),
1043
+ allowMainnet: input.allowMainnet ?? false,
1044
+ createdAt,
1045
+ expiresAt: createdAt + input.ttlMs,
1046
+ ttlSeconds: Math.round(input.ttlMs / 1e3)
1047
+ };
1048
+ ensureDir((0, import_node_path4.dirname)(sessionPath), 448);
1049
+ writeFileAtomic(sessionPath, `${JSON.stringify(session, null, 2)}
1050
+ `, 384);
1051
+ return session;
1052
+ }
1053
+ function clearSession(sessionPath) {
1054
+ let existed = false;
1055
+ try {
1056
+ existed = (0, import_node_fs5.existsSync)(sessionPath);
1057
+ if (existed) (0, import_node_fs5.rmSync)(sessionPath, { force: true });
1058
+ } catch {
1059
+ existed = false;
1060
+ }
1061
+ sweepSessionTemps(sessionPath);
1062
+ return existed;
1063
+ }
1064
+ function sweepSessionTemps(sessionPath) {
1065
+ const dir = (0, import_node_path4.dirname)(sessionPath);
1066
+ const prefix = `.${(0, import_node_path4.basename)(sessionPath)}.`;
1067
+ try {
1068
+ for (const name of (0, import_node_fs5.readdirSync)(dir)) {
1069
+ if (name.startsWith(prefix) && name.endsWith(".tmp")) {
1070
+ try {
1071
+ (0, import_node_fs5.rmSync)((0, import_node_path4.join)(dir, name), { force: true });
1072
+ } catch {
1073
+ }
1074
+ }
1075
+ }
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ function readLiveSessionPassphrase(sessionPath, keystorePath, currentVerifierId, isMainnetOperation = false) {
1080
+ const verdict = evaluateSession(sessionPath, keystorePath, currentVerifierId, Date.now());
1081
+ if (verdict.status === "live") {
1082
+ if (isMainnetOperation && !verdict.session.allowMainnet) return void 0;
1083
+ try {
1084
+ return Buffer.from(import_base3.base64urlnopad.decode(verdict.session.passphrase)).toString("utf-8");
1085
+ } catch {
1086
+ clearSession(sessionPath);
1087
+ return void 0;
1088
+ }
1089
+ }
1090
+ if (verdict.status === "expired" || verdict.status === "stale" || verdict.status === "future" || verdict.status === "malformed") {
1091
+ clearSession(sessionPath);
1092
+ }
1093
+ return void 0;
1094
+ }
1095
+ function readSessionStatus(sessionPath, keystorePath, currentVerifierId) {
1096
+ const now = Date.now();
1097
+ const verdict = evaluateSession(sessionPath, keystorePath, currentVerifierId, now);
1098
+ if (verdict.status !== "live") return { active: false };
1099
+ return {
1100
+ active: true,
1101
+ expiresAt: verdict.session.expiresAt,
1102
+ secondsRemaining: Math.max(0, Math.round((verdict.session.expiresAt - now) / 1e3)),
1103
+ allowMainnet: verdict.session.allowMainnet
1104
+ };
1105
+ }
1106
+ function parseTtlToMs(raw) {
1107
+ const match = /^(\d+)([smh]?)$/.exec(raw.trim());
1108
+ if (!match) return void 0;
1109
+ const n = Number(match[1]);
1110
+ if (!Number.isFinite(n)) return void 0;
1111
+ const unitMs = match[2] === "h" ? 36e5 : match[2] === "m" ? 6e4 : 1e3;
1112
+ return n * unitMs;
1113
+ }
1114
+ function evaluateSession(sessionPath, keystorePath, currentVerifierId, now) {
1115
+ const session = secureReadSession(sessionPath);
1116
+ if (!session) return { status: "none" };
1117
+ if (session.v !== SESSION_VERSION) return { status: "malformed" };
1118
+ if (typeof session.passphrase !== "string" || typeof session.keystore !== "string" || typeof session.verifierId !== "string" || typeof session.allowMainnet !== "boolean" || typeof session.createdAt !== "number" || typeof session.expiresAt !== "number") {
1119
+ return { status: "malformed" };
1120
+ }
1121
+ if (session.createdAt > now) return { status: "future" };
1122
+ if (now >= session.expiresAt) return { status: "expired" };
1123
+ if ((0, import_node_path4.resolve)(session.keystore) !== (0, import_node_path4.resolve)(keystorePath)) return { status: "foreign" };
1124
+ if (currentVerifierId === void 0 || session.verifierId !== currentVerifierId) return { status: "stale" };
1125
+ return { status: "live", session };
1126
+ }
1127
+ function secureReadSession(sessionPath) {
1128
+ let raw;
1129
+ if (process.platform === "win32") {
1130
+ try {
1131
+ raw = (0, import_node_fs5.readFileSync)(sessionPath, "utf-8");
1132
+ } catch {
1133
+ return void 0;
1134
+ }
1135
+ } else {
1136
+ let fd;
1137
+ try {
1138
+ fd = (0, import_node_fs5.openSync)(sessionPath, import_node_fs5.constants.O_RDONLY | import_node_fs5.constants.O_NOFOLLOW);
1139
+ } catch {
1140
+ return void 0;
1141
+ }
1142
+ try {
1143
+ const st = (0, import_node_fs5.fstatSync)(fd);
1144
+ const myUid = typeof process.getuid === "function" ? process.getuid() : void 0;
1145
+ if (!st.isFile() || myUid !== void 0 && st.uid !== myUid || (st.mode & 63) !== 0) {
1146
+ try {
1147
+ (0, import_node_fs5.rmSync)(sessionPath, { force: true });
1148
+ } catch {
1149
+ }
1150
+ return void 0;
1151
+ }
1152
+ raw = (0, import_node_fs5.readFileSync)(fd, "utf-8");
1153
+ } catch {
1154
+ return void 0;
1155
+ } finally {
1156
+ try {
1157
+ (0, import_node_fs5.closeSync)(fd);
1158
+ } catch {
1159
+ }
1160
+ }
1161
+ }
1162
+ try {
1163
+ return JSON.parse(raw);
1164
+ } catch {
1165
+ return void 0;
1166
+ }
1167
+ }
1168
+
695
1169
  // src/config.ts
696
1170
  var CONFIG_SCHEMA_VERSION = 1;
697
1171
  function writeConfigFile(path, mutate) {
698
1172
  const raw = readConfigFile(path) ?? {};
699
1173
  mutate(raw);
700
1174
  raw.schemaVersion = CONFIG_SCHEMA_VERSION;
701
- ensureDir((0, import_node_path4.dirname)(path), 448);
1175
+ ensureDir((0, import_node_path5.dirname)(path), 448);
702
1176
  writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}
703
1177
  `, 384);
704
1178
  }
1179
+ function writeDefaultConfigFile(path) {
1180
+ const scaffold = {
1181
+ schemaVersion: CONFIG_SCHEMA_VERSION,
1182
+ defaults: { output: "text" },
1183
+ profiles: Object.fromEntries(SUPPORTED_NETWORKS.map((n) => [n, {}]))
1184
+ };
1185
+ ensureDir((0, import_node_path5.dirname)(path), 448);
1186
+ writeFileAtomic(path, `${JSON.stringify(scaffold, null, 2)}
1187
+ `, 384);
1188
+ }
705
1189
  function getConfigPath(config, path) {
706
1190
  return path.split(".").reduce(
707
1191
  (node, key) => node?.[key],
@@ -760,14 +1244,10 @@ function readEnvOverrides() {
760
1244
  casRpcUrl: env(ENV_VARS.CAS_RPC_URL)
761
1245
  };
762
1246
  }
763
- function defaultConfigPath() {
764
- const base = blankToUndef(process.env.XDG_CONFIG_HOME) ?? blankToUndef(process.env.APPDATA) ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".config");
765
- return (0, import_node_path4.join)(base, "btcr2", "config.json");
766
- }
767
1247
  function parseConfigFileRaw(path) {
768
1248
  let content;
769
1249
  try {
770
- content = (0, import_node_fs5.readFileSync)(path, "utf-8");
1250
+ content = (0, import_node_fs6.readFileSync)(path, "utf-8");
771
1251
  } catch (error) {
772
1252
  if (error.code === "ENOENT") return void 0;
773
1253
  throw new CLIError(
@@ -822,7 +1302,7 @@ function resolveActiveProfile(file, overrides) {
822
1302
  return { name, network };
823
1303
  }
824
1304
  function resolveDefaultNetwork(overrides) {
825
- const configPath = overrides?.config ?? defaultConfigPath();
1305
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
826
1306
  const file = readConfigFile(configPath);
827
1307
  const explicit = file?.defaults?.network;
828
1308
  if (explicit && SUPPORTED_NETWORKS.includes(explicit)) return explicit;
@@ -833,7 +1313,7 @@ function resolveDefaultNetwork(overrides) {
833
1313
  function profileNetworkMismatch(network, overrides) {
834
1314
  let file;
835
1315
  try {
836
- file = readConfigFile(overrides?.config ?? defaultConfigPath());
1316
+ file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
837
1317
  } catch {
838
1318
  return void 0;
839
1319
  }
@@ -847,7 +1327,7 @@ function resolveOutputFormat(options) {
847
1327
  process.env.BTCR2_OUTPUT || void 0
848
1328
  ];
849
1329
  try {
850
- candidates.push(readConfigFile(options.config ?? defaultConfigPath())?.defaults?.output);
1330
+ candidates.push(readConfigFile(options.config ?? defaultConfigPath(options))?.defaults?.output);
851
1331
  } catch {
852
1332
  }
853
1333
  for (const candidate of candidates) {
@@ -873,7 +1353,7 @@ function resolveRpcUnit(overrides, env, fileOverrides) {
873
1353
  }
874
1354
  function resolveConnectionConfig(network, overrides) {
875
1355
  if (!network) return {};
876
- const configPath = overrides?.config ?? defaultConfigPath();
1356
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
877
1357
  const file = readConfigFile(configPath);
878
1358
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
879
1359
  const profileName = activeProfile ?? network;
@@ -959,7 +1439,7 @@ function trimTrailingNewline(value) {
959
1439
  }
960
1440
  function readSecretFile(path, source) {
961
1441
  try {
962
- return trimTrailingNewline((0, import_node_fs5.readFileSync)(path, "utf-8"));
1442
+ return trimTrailingNewline((0, import_node_fs6.readFileSync)(path, "utf-8"));
963
1443
  } catch (error) {
964
1444
  throw new CLIError(
965
1445
  `Could not read the RPC password ${source} at ${path}: ${error.message}`,
@@ -985,7 +1465,7 @@ function readRpcPassFile() {
985
1465
  return readSecretFile(path, `file named by ${ENV_RPC_PASS_FILE}`);
986
1466
  }
987
1467
  function resolveBroadcastOptions(network, overrides, flags) {
988
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
1468
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
989
1469
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
990
1470
  const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
991
1471
  const options = {};
@@ -1007,7 +1487,7 @@ function parseFeeRate(raw) {
1007
1487
  return rate;
1008
1488
  }
1009
1489
  function resolveEffectiveConfig(network, overrides) {
1010
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
1490
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
1011
1491
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
1012
1492
  const profileName = activeProfile ?? network;
1013
1493
  const fileOv = file ? profileToOverrides(file, profileName) : {};
@@ -1093,17 +1573,56 @@ async function runDoctor(network, overrides) {
1093
1573
  function defaultApiFactory(network, overrides) {
1094
1574
  return (0, import_api.createApi)(resolveConnectionConfig(network, overrides));
1095
1575
  }
1096
- function buildKeystoreKms(overrides) {
1576
+ function buildKeystoreKms(overrides, network) {
1577
+ const keystorePath = resolveKeystorePath(overrides);
1578
+ const sessionPath = defaultSessionPath(overrides);
1579
+ const isMainnetOperation = network === "bitcoin";
1097
1580
  return new FileBackedKeyManager({
1098
- path: resolveKeystorePath(overrides),
1099
- getPassphrase: () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile })
1581
+ path: keystorePath,
1582
+ // The store decides when to confirm: it passes `confirm: true` only while
1583
+ // establishing a fresh keystore's passphrase, so a first-key typo is caught
1584
+ // by a second entry (ADR 080). confirm is a no-op for env/file sources.
1585
+ //
1586
+ // On the non-establishing path, a cached session (ADR 081) is consulted below
1587
+ // the env var / --passphrase-file and above the interactive prompt. It is
1588
+ // wired ONLY when not confirming, so establishment never consults the session
1589
+ // and a first passphrase is always entered fresh and twice. The session is
1590
+ // bound to this keystore's verifier, so a rotated passphrase invalidates it.
1591
+ getPassphrase: (opts) => acquirePassphrase({
1592
+ passphraseFile: overrides?.passphraseFile,
1593
+ confirm: opts?.confirm,
1594
+ ...opts?.confirm ? {} : {
1595
+ beforePrompt: () => readLiveSessionPassphrase(sessionPath, keystorePath, keystoreVerifierId(keystorePath), isMainnetOperation)
1596
+ }
1597
+ })
1100
1598
  });
1101
1599
  }
1102
- function resolveKeystorePath(overrides) {
1103
- return overrides?.keystore ?? activeProfileIdentity(overrides)?.keystore ?? defaultKeystorePath();
1600
+ function resolveKeystoreProtection(overrides) {
1601
+ return keystoreProtection(resolveKeystorePath(overrides));
1602
+ }
1603
+ function assertKeystoreAllowedForNetwork(network, overrides) {
1604
+ if (network !== "bitcoin") return;
1605
+ if (resolveKeystoreProtection(overrides) !== "dev") return;
1606
+ const path = resolveKeystorePath(overrides);
1607
+ throw new CLIError(
1608
+ `Refusing a mainnet (bitcoin) operation with the unencrypted dev keystore at ${path}. Dev keystores hold plaintext keys and are for testnet/regtest throwaway material only. Establish an encrypted keystore (btcr2 keystore init) for mainnet keys.`,
1609
+ "DEV_KEYSTORE_MAINNET_ERROR",
1610
+ { path, network }
1611
+ );
1612
+ }
1613
+ function resolveKeystorePath(overrides, options) {
1614
+ const fromFlag = blankToUndef(overrides?.keystore);
1615
+ if (fromFlag) return fromFlag;
1616
+ let identity;
1617
+ try {
1618
+ identity = activeProfileIdentity(overrides);
1619
+ } catch (error) {
1620
+ if (!options?.lenient) throw error;
1621
+ }
1622
+ return blankToUndef(identity?.keystore) ?? defaultKeystorePath(overrides);
1104
1623
  }
1105
1624
  function activeProfileIdentity(overrides) {
1106
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
1625
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
1107
1626
  const { name } = resolveActiveProfile(file, overrides);
1108
1627
  return name ? file?.profiles?.[name]?.identity : void 0;
1109
1628
  }
@@ -1113,7 +1632,7 @@ function resolveSigningKeyRef(overrides) {
1113
1632
  function keystoreApiFactory(network, overrides) {
1114
1633
  return (0, import_api.createApi)({
1115
1634
  ...resolveConnectionConfig(network, overrides),
1116
- kms: buildKeystoreKms(overrides)
1635
+ kms: buildKeystoreKms(overrides, network)
1117
1636
  });
1118
1637
  }
1119
1638
  function deriveNetwork(did) {
@@ -1128,6 +1647,86 @@ function deriveNetwork(did) {
1128
1647
  return network;
1129
1648
  }
1130
1649
 
1650
+ // src/output.ts
1651
+ var REDACTED = "********";
1652
+ var SECRET_KEY = /(pass|secret|token|auth|api[-_]?key|credential|bearer)/i;
1653
+ function isSecretKey(key) {
1654
+ return SECRET_KEY.test(key);
1655
+ }
1656
+ function scrubUrlUserinfo(value) {
1657
+ if (value === void 0) return void 0;
1658
+ return value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@:]+):[^/@]*@/i, `$1:${REDACTED}@`);
1659
+ }
1660
+ function redactSecrets(value, keyName) {
1661
+ if (Array.isArray(value)) {
1662
+ return value.map((item) => redactSecrets(item));
1663
+ }
1664
+ if (value !== null && typeof value === "object") {
1665
+ const out = {};
1666
+ for (const [key, val] of Object.entries(value)) {
1667
+ out[key] = redactSecrets(val, key);
1668
+ }
1669
+ return out;
1670
+ }
1671
+ if (keyName !== void 0 && isSecretKey(keyName)) {
1672
+ return value === void 0 ? void 0 : REDACTED;
1673
+ }
1674
+ return typeof value === "string" ? scrubUrlUserinfo(value) : value;
1675
+ }
1676
+ function formatResult(result, options) {
1677
+ if (options.output === "json") {
1678
+ return JSON.stringify(result, null, 2);
1679
+ }
1680
+ const { data } = result;
1681
+ return typeof data === "string" ? data : JSON.stringify(data, null, 2);
1682
+ }
1683
+
1684
+ // src/commands/init.ts
1685
+ function registerInitCommand(program, globals) {
1686
+ const print = (result) => console.log(formatResult(result, globals()));
1687
+ program.command("init").description("Set up the btcr2 home: create the directory, a default config, and establish the keystore.").option("--dev", "Establish an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only.", false).option("--force", "Re-create the config and keystore even if they already exist.", false).action((options) => {
1688
+ const g = globals();
1689
+ const home = resolveHome(g);
1690
+ const configPath = g.config ?? defaultConfigPath(g);
1691
+ const keystorePath = resolveKeystorePath(g);
1692
+ ensureDir(home, 448);
1693
+ const created = [];
1694
+ if (!(0, import_node_fs7.existsSync)(configPath) || options.force) {
1695
+ writeDefaultConfigFile(configPath);
1696
+ created.push("config");
1697
+ }
1698
+ const keystoreExists = (0, import_node_fs7.existsSync)(keystorePath);
1699
+ if (keystoreExists && options.force && !g.quiet) {
1700
+ process.stderr.write(
1701
+ `note: a keystore already exists at ${keystorePath} and was left intact. To re-establish it (discarding its keys), run "btcr2 keystore init --force".
1702
+ `
1703
+ );
1704
+ }
1705
+ if (!keystoreExists) {
1706
+ if (options.dev && !g.quiet) {
1707
+ process.stderr.write(
1708
+ "warning: establishing an UNENCRYPTED dev keystore. Keys are stored in plaintext. Use it only for disposable testnet material; mainnet operations will be refused.\n"
1709
+ );
1710
+ }
1711
+ initKeystore(keystorePath, {
1712
+ protection: options.dev ? "none" : "passphrase",
1713
+ getPassphrase: (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: "New keystore passphrase: " })
1714
+ });
1715
+ clearSession(defaultSessionPath(g));
1716
+ created.push("keystore");
1717
+ }
1718
+ const protection = keystoreSummary(keystorePath).protection;
1719
+ print({ action: "init", data: { home, config: configPath, keystore: keystorePath, created, protection } });
1720
+ if (!g.quiet && g.output !== "json") {
1721
+ process.stderr.write(`btcr2 home ready at ${home}. Next: btcr2 key generate --set-active
1722
+ `);
1723
+ }
1724
+ });
1725
+ }
1726
+
1727
+ // src/commands/create.ts
1728
+ var import_utils4 = require("@noble/hashes/utils.js");
1729
+
1131
1730
  // src/keystore/resolve-key-ref.ts
1132
1731
  function fingerprintOf(id) {
1133
1732
  return /^urn:kms:secp256k1:([0-9a-f]{32})$/.exec(id)?.[1];
@@ -1166,40 +1765,6 @@ function resolveKeyRef(kms, ref) {
1166
1765
  throw new CLIError(`No key matches reference "${ref}".`, "KEY_NOT_FOUND_ERROR", { ref });
1167
1766
  }
1168
1767
 
1169
- // src/output.ts
1170
- var REDACTED = "********";
1171
- var SECRET_KEY = /(pass|secret|token|auth|api[-_]?key|credential|bearer)/i;
1172
- function isSecretKey(key) {
1173
- return SECRET_KEY.test(key);
1174
- }
1175
- function scrubUrlUserinfo(value) {
1176
- if (value === void 0) return void 0;
1177
- return value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@:]+):[^/@]*@/i, `$1:${REDACTED}@`);
1178
- }
1179
- function redactSecrets(value, keyName) {
1180
- if (Array.isArray(value)) {
1181
- return value.map((item) => redactSecrets(item));
1182
- }
1183
- if (value !== null && typeof value === "object") {
1184
- const out = {};
1185
- for (const [key, val] of Object.entries(value)) {
1186
- out[key] = redactSecrets(val, key);
1187
- }
1188
- return out;
1189
- }
1190
- if (keyName !== void 0 && isSecretKey(keyName)) {
1191
- return value === void 0 ? void 0 : REDACTED;
1192
- }
1193
- return typeof value === "string" ? scrubUrlUserinfo(value) : value;
1194
- }
1195
- function formatResult(result, options) {
1196
- if (options.output === "json") {
1197
- return JSON.stringify(result, null, 2);
1198
- }
1199
- const { data } = result;
1200
- return typeof data === "string" ? data : JSON.stringify(data, null, 2);
1201
- }
1202
-
1203
1768
  // src/commands/create.ts
1204
1769
  var EXPECTED_BYTES = {
1205
1770
  k: { length: 33, label: "secp256k1 compressed public key (33 bytes)" },
@@ -1268,14 +1833,15 @@ function registerCreateCommand(program, factory, keystoreFactory, globals) {
1268
1833
  const publicKey2 = api2.kms.getPublicKey(keyId2);
1269
1834
  const did2 = api2.createDid("deterministic", publicKey2, { network });
1270
1835
  print(
1271
- { action: "create", data: did2, keyId: keyId2, publicKey: (0, import_utils2.bytesToHex)(publicKey2) },
1836
+ { action: "create", data: did2, keyId: keyId2, publicKey: (0, import_utils4.bytesToHex)(publicKey2) },
1272
1837
  `Using stored key ${keyId2}.`
1273
1838
  );
1274
1839
  return;
1275
1840
  }
1841
+ assertKeystoreAllowedForNetwork(network, overrides);
1276
1842
  const api = keystoreFactory(void 0, overrides);
1277
1843
  const { did, keyId } = api.generateDid({ network, setActive: true });
1278
- const publicKey = (0, import_utils2.bytesToHex)(api.kms.getPublicKey(keyId));
1844
+ const publicKey = (0, import_utils4.bytesToHex)(api.kms.getPublicKey(keyId));
1279
1845
  print(
1280
1846
  { action: "create", data: did, keyId, publicKey },
1281
1847
  `Generated and stored key ${keyId} (now the active key).`
@@ -1284,6 +1850,7 @@ function registerCreateCommand(program, factory, keystoreFactory, globals) {
1284
1850
  }
1285
1851
  function overridesFromGlobals(g) {
1286
1852
  return {
1853
+ home: g.home,
1287
1854
  config: g.config,
1288
1855
  profile: g.profile,
1289
1856
  keystore: g.keystore,
@@ -1305,7 +1872,7 @@ function parseGenesisBytes(hex, type) {
1305
1872
  const expected = EXPECTED_BYTES[type];
1306
1873
  let bytes;
1307
1874
  try {
1308
- bytes = (0, import_utils2.hexToBytes)(hex.trim());
1875
+ bytes = (0, import_utils4.hexToBytes)(hex.trim());
1309
1876
  } catch {
1310
1877
  throw new CLIError(
1311
1878
  `Invalid bytes: not valid hex. Expected ${expected.label}.`,
@@ -1420,6 +1987,7 @@ function registerUpdateCommand(program, factory, globals) {
1420
1987
  );
1421
1988
  }
1422
1989
  const network = deriveNetwork(did);
1990
+ assertKeystoreAllowedForNetwork(network, globals());
1423
1991
  const api = factory(network, globals());
1424
1992
  const keyId = resolveKeyRef(api.kms.kms, resolveSigningKeyRef(globals()));
1425
1993
  const signer = new import_key_manager2.KeyManagerSigner(api.kms.kms, keyId);
@@ -1517,6 +2085,7 @@ function registerDeactivateCommand(program, factory, globals) {
1517
2085
  );
1518
2086
  }
1519
2087
  const network = deriveNetwork(did);
2088
+ assertKeystoreAllowedForNetwork(network, globals());
1520
2089
  const api = factory(network, globals());
1521
2090
  const keyId = resolveKeyRef(api.kms.kms, resolveSigningKeyRef(globals()));
1522
2091
  const signer = new import_key_manager3.KeyManagerSigner(api.kms.kms, keyId);
@@ -1563,8 +2132,8 @@ function parseJsonArg2(flagName) {
1563
2132
 
1564
2133
  // src/commands/key.ts
1565
2134
  var import_keypair = require("@did-btcr2/keypair");
1566
- var import_utils3 = require("@noble/hashes/utils.js");
1567
- var import_node_fs6 = require("fs");
2135
+ var import_utils5 = require("@noble/hashes/utils.js");
2136
+ var import_node_fs8 = require("fs");
1568
2137
  function registerKeyCommand(program, factory, globals) {
1569
2138
  const key = program.command("key").description("Manage keypairs in the encrypted keystore.");
1570
2139
  const print = (result) => console.log(formatResult(result, globals()));
@@ -1573,7 +2142,7 @@ function registerKeyCommand(program, factory, globals) {
1573
2142
  assertNameAvailable(api.kms.kms, options.name);
1574
2143
  const setActive = options.setActive ?? false;
1575
2144
  const id = api.kms.generateKey({ ...options.name && { tags: { name: options.name } }, setActive });
1576
- print({ action: "key-generate", data: { keyId: id, publicKey: (0, import_utils3.bytesToHex)(api.kms.getPublicKey(id)), active: setActive } });
2145
+ print({ action: "key-generate", data: { keyId: id, publicKey: (0, import_utils5.bytesToHex)(api.kms.getPublicKey(id)), active: setActive } });
1577
2146
  });
1578
2147
  key.command("list").alias("ls").description("List stored keys.").action(() => {
1579
2148
  const kms = factory(void 0, globals()).kms.kms;
@@ -1593,7 +2162,7 @@ function registerKeyCommand(program, factory, globals) {
1593
2162
  const kms = factory(void 0, globals()).kms.kms;
1594
2163
  const id = resolveKeyRef(kms, ref);
1595
2164
  const entry = kms.getEntry(id);
1596
- print({ action: "key-show", data: { keyId: id, publicKey: (0, import_utils3.bytesToHex)(entry.publicKey), ...entry.tags && { tags: entry.tags } } });
2165
+ print({ action: "key-show", data: { keyId: id, publicKey: (0, import_utils5.bytesToHex)(entry.publicKey), ...entry.tags && { tags: entry.tags } } });
1597
2166
  });
1598
2167
  key.command("import").description("Import a key: a secret from a hex file, or a public key as watch-only.").option("--secret-file <path>", "Path to a file containing a 32-byte secret key as hex.").option("--public <hex>", "A 33-byte compressed public key as hex (imported watch-only).").option("--name <name>", "A human-friendly name, stored as a tag.").option("--set-active", "Make this the active key.", false).action((options) => {
1599
2168
  if (Boolean(options.secretFile) === Boolean(options.public)) {
@@ -1604,13 +2173,13 @@ function registerKeyCommand(program, factory, globals) {
1604
2173
  const keyPair = options.secretFile ? new import_keypair.SchnorrKeyPair({ secretKey: readHexFile(options.secretFile, 32, "--secret-file") }) : new import_keypair.SchnorrKeyPair({ publicKey: parseHex(options.public ?? "", 33, "--public") });
1605
2174
  const setActive = options.setActive ?? false;
1606
2175
  const id = api.kms.import(keyPair, { ...options.name && { tags: { name: options.name } }, setActive });
1607
- print({ action: "key-import", data: { keyId: id, publicKey: (0, import_utils3.bytesToHex)(api.kms.getPublicKey(id)), watchOnly: !options.secretFile, active: setActive } });
2176
+ print({ action: "key-import", data: { keyId: id, publicKey: (0, import_utils5.bytesToHex)(api.kms.getPublicKey(id)), watchOnly: !options.secretFile, active: setActive } });
1608
2177
  });
1609
2178
  key.command("export <ref>").description("Export a key. Public material by default; --secret writes the secret to a file.").option("--secret", "Export the secret key. Requires --out.", false).option("--out <path>", "Write the exported secret to this file (created 0600).").action((ref, options) => {
1610
2179
  const api = factory(void 0, globals());
1611
2180
  const id = resolveKeyRef(api.kms.kms, ref);
1612
2181
  if (!options.secret) {
1613
- print({ action: "key-export", data: { keyId: id, publicKey: (0, import_utils3.bytesToHex)(api.kms.getPublicKey(id)) } });
2182
+ print({ action: "key-export", data: { keyId: id, publicKey: (0, import_utils5.bytesToHex)(api.kms.getPublicKey(id)) } });
1614
2183
  return;
1615
2184
  }
1616
2185
  if (!options.out) {
@@ -1621,7 +2190,7 @@ function registerKeyCommand(program, factory, globals) {
1621
2190
  throw new CLIError(`Key ${id} is watch-only and has no secret to export.`, "INVALID_ARGUMENT_ERROR", { keyId: id });
1622
2191
  }
1623
2192
  process.stderr.write("warning: writing an unencrypted secret key to disk. Protect this file and delete it when done.\n");
1624
- writeSecretFile(options.out, (0, import_utils3.bytesToHex)(keyPair.secretKey.bytes));
2193
+ writeSecretFile(options.out, (0, import_utils5.bytesToHex)(keyPair.secretKey.bytes));
1625
2194
  print({ action: "key-export", data: { keyId: id, secretWrittenTo: options.out } });
1626
2195
  });
1627
2196
  key.command("delete <ref>").alias("rm").description("Delete a key from the keystore.").option("--force", "Delete even if it is the active key.", false).action((ref, options) => {
@@ -1646,7 +2215,7 @@ function assertNameAvailable(kms, name) {
1646
2215
  function parseHex(hex, expectedBytes, label) {
1647
2216
  let bytes;
1648
2217
  try {
1649
- bytes = (0, import_utils3.hexToBytes)(hex.trim());
2218
+ bytes = (0, import_utils5.hexToBytes)(hex.trim());
1650
2219
  } catch {
1651
2220
  throw new CLIError(`Invalid hex for ${label}.`, "INVALID_ARGUMENT_ERROR", { label });
1652
2221
  }
@@ -1662,7 +2231,7 @@ function parseHex(hex, expectedBytes, label) {
1662
2231
  function readHexFile(path, expectedBytes, label) {
1663
2232
  let content;
1664
2233
  try {
1665
- content = (0, import_node_fs6.readFileSync)(path, "utf-8");
2234
+ content = (0, import_node_fs8.readFileSync)(path, "utf-8");
1666
2235
  } catch {
1667
2236
  throw new CLIError(`Cannot read ${label} at ${path}.`, "INVALID_ARGUMENT_ERROR", { label, path });
1668
2237
  }
@@ -1671,7 +2240,7 @@ function readHexFile(path, expectedBytes, label) {
1671
2240
  function writeSecretFile(path, contents) {
1672
2241
  let fd;
1673
2242
  try {
1674
- fd = (0, import_node_fs6.openSync)(path, "wx", 384);
2243
+ fd = (0, import_node_fs8.openSync)(path, "wx", 384);
1675
2244
  } catch (error) {
1676
2245
  if (error.code === "EEXIST") {
1677
2246
  throw new CLIError(`Refusing to overwrite existing file ${path}. Choose a new --out path.`, "INVALID_ARGUMENT_ERROR", { path });
@@ -1679,15 +2248,156 @@ function writeSecretFile(path, contents) {
1679
2248
  throw error;
1680
2249
  }
1681
2250
  try {
1682
- (0, import_node_fs6.writeFileSync)(fd, contents);
2251
+ (0, import_node_fs8.writeFileSync)(fd, contents);
1683
2252
  } finally {
1684
- (0, import_node_fs6.closeSync)(fd);
2253
+ (0, import_node_fs8.closeSync)(fd);
1685
2254
  }
1686
2255
  }
1687
2256
 
2257
+ // src/commands/keystore.ts
2258
+ var import_node_fs9 = require("fs");
2259
+ function registerKeystoreCommand(program, globals) {
2260
+ const keystore = program.command("keystore").description("Establish, inspect, re-key, and unlock the keystore.");
2261
+ const print = (result) => console.log(formatResult(result, globals()));
2262
+ keystore.command("init").description("Establish the keystore (encrypted by default). Prompts for a passphrase and confirms it.").option("--dev", "Create an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only; mainnet operations are refused.", false).option("--force", "Re-establish even if a keystore already exists (discards its keys).", false).action((options) => {
2263
+ const g = globals();
2264
+ const path = resolveKeystorePath(g);
2265
+ if ((0, import_node_fs9.existsSync)(path) && !options.force) {
2266
+ throw new CLIError(
2267
+ `A keystore already exists at ${path}. Use --force to re-establish it (this discards its keys).`,
2268
+ "INVALID_ARGUMENT_ERROR",
2269
+ { path }
2270
+ );
2271
+ }
2272
+ if ((0, import_node_fs9.existsSync)(path) && options.force && !g.quiet) {
2273
+ const { keyCount } = keystoreSummary(path);
2274
+ if (keyCount > 0) {
2275
+ process.stderr.write(
2276
+ `warning: re-establishing the keystore at ${path} permanently discards its ${keyCount} existing key(s).
2277
+ `
2278
+ );
2279
+ }
2280
+ }
2281
+ if (options.dev && !g.quiet) {
2282
+ process.stderr.write(
2283
+ "warning: creating an UNENCRYPTED dev keystore. Keys are stored in plaintext. Use it only for disposable testnet material; mainnet operations will be refused.\n"
2284
+ );
2285
+ }
2286
+ initKeystore(path, {
2287
+ protection: options.dev ? "none" : "passphrase",
2288
+ getPassphrase: (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: "New keystore passphrase: " })
2289
+ });
2290
+ clearSession(defaultSessionPath(g));
2291
+ print({ action: "keystore-init", data: { path, protection: options.dev ? "dev" : "encrypted" } });
2292
+ });
2293
+ keystore.command("status").description("Show the keystore path, protection mode, key count, and session state. Never decrypts or prompts.").action(() => {
2294
+ const g = globals();
2295
+ const path = resolveKeystorePath(g, { lenient: true });
2296
+ const summary = keystoreSummary(path);
2297
+ const session = readSessionStatus(defaultSessionPath(g), path, keystoreVerifierId(path));
2298
+ if (summary.protection === "dev" && !g.quiet && g.output !== "json") {
2299
+ process.stderr.write("warning: this is an UNENCRYPTED dev keystore; keys are stored in plaintext.\n");
2300
+ }
2301
+ print({ action: "keystore-status", data: { path, ...summary, session } });
2302
+ });
2303
+ keystore.command("change-passphrase").alias("passwd").description("Change the keystore passphrase, re-sealing every key under the new one. Encrypted keystores only.").action(() => {
2304
+ const g = globals();
2305
+ const path = resolveKeystorePath(g);
2306
+ const summary = keystoreSummary(path);
2307
+ if (summary.protection === "absent") {
2308
+ throw new CLIError(`No keystore at ${path}. Run "btcr2 keystore init" first.`, "INVALID_ARGUMENT_ERROR", { path });
2309
+ }
2310
+ if (summary.protection === "dev") {
2311
+ throw new CLIError(
2312
+ `The keystore at ${path} is an unencrypted dev keystore; there is no passphrase to change.`,
2313
+ "INVALID_ARGUMENT_ERROR",
2314
+ { path }
2315
+ );
2316
+ }
2317
+ const oldPassphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: "Current keystore passphrase: " });
2318
+ const newPassphrase = acquirePassphrase({ forcePrompt: true, confirm: true, prompt: "New keystore passphrase: " });
2319
+ const rekeyed = changeKeystorePassphrase(path, oldPassphrase, newPassphrase);
2320
+ clearSession(defaultSessionPath(g));
2321
+ print({ action: "keystore-change-passphrase", data: { path, rekeyed } });
2322
+ });
2323
+ keystore.command("unlock").description("Cache the keystore passphrase for a session so later commands do not re-prompt (ADR 081).").option("--ttl <duration>", `Session lifetime: bare seconds or an s/m/h suffix (default 1h, max 24h). Also $${ENV_KEYSTORE_TTL}.`).option("--allow-mainnet", "Permit unlocking when the active network is mainnet (bitcoin); this suspends per-use passphrase auth for the session.", false).action((options) => {
2324
+ const g = globals();
2325
+ const path = resolveKeystorePath(g);
2326
+ const summary = keystoreSummary(path);
2327
+ if (summary.protection === "absent") {
2328
+ throw new CLIError(`No keystore at ${path}. Run "btcr2 init" or "btcr2 keystore init" first.`, "INVALID_ARGUMENT_ERROR", { path });
2329
+ }
2330
+ if (summary.protection === "dev") {
2331
+ throw new CLIError(
2332
+ `The keystore at ${path} is an unencrypted dev keystore; it has no passphrase to cache, so no unlock is needed.`,
2333
+ "INVALID_ARGUMENT_ERROR",
2334
+ { path }
2335
+ );
2336
+ }
2337
+ if (!summary.established) {
2338
+ throw new CLIError(
2339
+ `The keystore at ${path} has no passphrase established yet. Establish one with "btcr2 keystore init" or the first "btcr2 key generate".`,
2340
+ "INVALID_ARGUMENT_ERROR",
2341
+ { path }
2342
+ );
2343
+ }
2344
+ if (!options.allowMainnet && resolveDefaultNetwork(g) === "bitcoin") {
2345
+ throw new CLIError(
2346
+ `Refusing to unlock for a mainnet (bitcoin) context: caching the passphrase suspends per-use authentication for the session. Pass --allow-mainnet to override, or keep signing mainnet updates with a per-use passphrase prompt.`,
2347
+ "MAINNET_UNLOCK_REFUSED_ERROR",
2348
+ { path }
2349
+ );
2350
+ }
2351
+ const ttlMs = resolveSessionTtl(options.ttl);
2352
+ const passphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: "Keystore passphrase: " });
2353
+ if (!verifyKeystorePassphrase(path, passphrase)) {
2354
+ throw new CLIError(`Incorrect passphrase for the keystore at ${path}; no session was created.`, "DECRYPT_ERROR", { path });
2355
+ }
2356
+ const verifierId = keystoreVerifierId(path);
2357
+ if (!verifierId) {
2358
+ throw new CLIError(`The keystore at ${path} has no verifier to bind a session to.`, "INVALID_ARGUMENT_ERROR", { path });
2359
+ }
2360
+ const session = writeSession(defaultSessionPath(g), {
2361
+ keystorePath: path,
2362
+ verifierId,
2363
+ passphrase,
2364
+ ttlMs,
2365
+ allowMainnet: !!options.allowMainnet
2366
+ });
2367
+ print({ action: "keystore-unlock", data: { keystore: path, expiresAt: session.expiresAt, ttlSeconds: session.ttlSeconds } });
2368
+ });
2369
+ keystore.command("lock").description("Revoke the cached session so later commands prompt for the passphrase again (ADR 081).").action(() => {
2370
+ const g = globals();
2371
+ const sessionPath = defaultSessionPath(g);
2372
+ const cleared = clearSession(sessionPath);
2373
+ print({ action: "keystore-lock", data: { path: sessionPath, cleared } });
2374
+ });
2375
+ }
2376
+ function resolveSessionTtl(flag) {
2377
+ const fromFlag = blankToUndef(flag);
2378
+ const raw = fromFlag ?? blankToUndef(process.env[ENV_KEYSTORE_TTL]);
2379
+ if (raw === void 0) return DEFAULT_SESSION_TTL_MS;
2380
+ const source = fromFlag !== void 0 ? "--ttl" : `$${ENV_KEYSTORE_TTL}`;
2381
+ const ms = parseTtlToMs(raw);
2382
+ if (ms === void 0 || ms <= 0) {
2383
+ throw new CLIError(
2384
+ `Invalid ${source} "${raw}": expected seconds or a value with an s/m/h suffix, e.g. 3600, 45m, or 2h.`,
2385
+ "INVALID_ARGUMENT_ERROR",
2386
+ { value: raw, source }
2387
+ );
2388
+ }
2389
+ if (ms > MAX_SESSION_TTL_MS) {
2390
+ throw new CLIError(
2391
+ `${source} "${raw}" exceeds the 24h maximum for a cached passphrase.`,
2392
+ "INVALID_ARGUMENT_ERROR",
2393
+ { value: raw, source }
2394
+ );
2395
+ }
2396
+ return ms;
2397
+ }
2398
+
1688
2399
  // src/commands/config.ts
1689
- var import_node_fs7 = require("fs");
1690
- var import_node_path5 = require("path");
2400
+ var import_node_fs10 = require("fs");
1691
2401
 
1692
2402
  // src/config-schema.ts
1693
2403
  var CONFIG_SCHEMA = {
@@ -1812,21 +2522,14 @@ function walk(obj, prefix, issues) {
1812
2522
  // src/commands/config.ts
1813
2523
  function registerConfigCommand(program, globals) {
1814
2524
  const config = program.command("config").description("Read and write CLI configuration.");
1815
- const path = () => globals().config ?? defaultConfigPath();
2525
+ const path = () => globals().config ?? defaultConfigPath(globals());
1816
2526
  const print = (result) => console.log(formatResult(result, globals()));
1817
2527
  config.command("init").description("Create a default config file with one profile per network.").option("--force", "Overwrite an existing config file.", false).action((options) => {
1818
2528
  const p = path();
1819
- if ((0, import_node_fs7.existsSync)(p) && !options.force) {
2529
+ if ((0, import_node_fs10.existsSync)(p) && !options.force) {
1820
2530
  throw new CLIError(`Config already exists at ${p}. Use --force to overwrite.`, "INVALID_ARGUMENT_ERROR", { path: p });
1821
2531
  }
1822
- const scaffold = {
1823
- schemaVersion: CONFIG_SCHEMA_VERSION,
1824
- defaults: { output: "text" },
1825
- profiles: Object.fromEntries(SUPPORTED_NETWORKS.map((n) => [n, {}]))
1826
- };
1827
- ensureDir((0, import_node_path5.dirname)(p), 448);
1828
- writeFileAtomic(p, `${JSON.stringify(scaffold, null, 2)}
1829
- `, 384);
2532
+ writeDefaultConfigFile(p);
1830
2533
  print({ action: "config-init", data: { path: p } });
1831
2534
  });
1832
2535
  config.command("get [path]").description("Print a value at a dotted path, or the whole config.").option("--show-secrets", "Reveal secret values (RPC password, etc.) instead of redacting them.", false).action((dotted, opts) => {
@@ -1871,10 +2574,14 @@ function registerConfigCommand(program, globals) {
1871
2574
  }
1872
2575
  print({ action: "config-effective", data });
1873
2576
  });
1874
- config.command("path").description("Print the resolved config-file and keystore paths.").action(() => {
2577
+ config.command("path").description("Print the resolved home directory, config-file, and keystore paths.").action(() => {
2578
+ const g = globals();
1875
2579
  const data = {
1876
- config: globals().config ?? defaultConfigPath(),
1877
- keystore: globals().keystore ?? defaultKeystorePath()
2580
+ home: resolveHome(g),
2581
+ config: g.config ?? defaultConfigPath(g),
2582
+ // Diagnostic command: report the path even when the config is malformed
2583
+ // (this is a command you run to find and fix a broken config).
2584
+ keystore: resolveKeystorePath(g, { lenient: true })
1878
2585
  };
1879
2586
  print({ action: "config-path", data });
1880
2587
  });
@@ -1927,7 +2634,7 @@ function isStringScalarPath(dotted) {
1927
2634
  // src/commands/profile.ts
1928
2635
  function registerProfileCommand(program, globals) {
1929
2636
  const profile = program.command("profile").description("Manage configuration profiles.");
1930
- const path = () => globals().config ?? defaultConfigPath();
2637
+ const path = () => globals().config ?? defaultConfigPath(globals());
1931
2638
  const print = (result) => console.log(formatResult(result, globals()));
1932
2639
  profile.command("add <name>").description("Add an empty profile.").action((name) => {
1933
2640
  writeConfigFile(path(), (raw) => {
@@ -2000,14 +2707,14 @@ function completionScript(shell) {
2000
2707
  }
2001
2708
 
2002
2709
  // src/version.ts
2003
- var import_node_fs8 = require("fs");
2710
+ var import_node_fs11 = require("fs");
2004
2711
  var import_node_path6 = require("path");
2005
2712
  var import_node_url = require("url");
2006
2713
  function readVersion() {
2007
2714
  let dir = (0, import_node_path6.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
2008
2715
  for (let i = 0; i < 5; i++) {
2009
2716
  try {
2010
- const pkg = JSON.parse((0, import_node_fs8.readFileSync)((0, import_node_path6.join)(dir, "package.json"), "utf-8"));
2717
+ const pkg = JSON.parse((0, import_node_fs11.readFileSync)((0, import_node_path6.join)(dir, "package.json"), "utf-8"));
2011
2718
  if (pkg.name === "@did-btcr2/cli") return pkg.version;
2012
2719
  } catch {
2013
2720
  }
@@ -2035,17 +2742,19 @@ var DidBtcr2Cli = class {
2035
2742
  * {@link keystoreApiFactory}.
2036
2743
  */
2037
2744
  constructor(factory = defaultApiFactory, keystoreFactory = keystoreApiFactory) {
2038
- this.program = new import_commander.Command("btcr2").version(`btcr2 ${VERSION}`, "-v, --version", "Output the current version").description("CLI tool for the did:btcr2 method").option("-o, --output <format>", "Output format <json|text> (default: config defaults.output, else text)").option("--verbose", "Verbose output", false).option("--quiet", "Suppress non-essential output", false).option("-c, --config <path>", "Path to config file (default: $XDG_CONFIG_HOME/btcr2/config.json)").option("--profile <name>", "Config profile name (default: auto-detected from network)").option("--btc-rest <url>", "Override Bitcoin REST endpoint (Esplora API)").option("--btc-rpc-url <url>", "Override Bitcoin Core RPC endpoint").option("--btc-rpc-user <user>", "Bitcoin Core RPC username").option("--btc-rpc-pass <pass>", "Bitcoin Core RPC password").option("--cas-gateway <url>", "IPFS HTTP gateway for CAS reads (read-only)").option("--cas-rpc-url <url>", "IPFS HTTP RPC endpoint for a writable CAS (reads + writes; enables --publish-to-cas)").option("--btc-timeout <ms>", "Bitcoin REST/RPC request timeout in milliseconds (default: unbounded)").option("--cas-timeout <ms>", "CAS request timeout in milliseconds (default: 30000; 0 disables)").option("--btc-rest-header <header>", 'Extra Bitcoin REST header "Key: Value" (repeatable)', collectHeader, []).option("--btc-rpc-wallet <name>", "Bitcoin Core wallet name for wallet-scoped RPCs").option("--btc-rpc-header <header>", 'Extra Bitcoin Core RPC header "Key: Value" (repeatable)', collectHeader, []).option("--keystore <path>", "Path to the keystore file (default: $XDG_DATA_HOME/btcr2/keystore.json)").option("--passphrase-file <path>", "Read the keystore passphrase from a file (unattended use)").option("--signing-key <ref>", "Key for create/update/deactivate signing: a URN, fingerprint prefix, or name");
2745
+ this.program = new import_commander.Command("btcr2").version(`btcr2 ${VERSION}`, "-v, --version", "Output the current version").description("CLI tool for the did:btcr2 method").option("-o, --output <format>", "Output format <json|text> (default: config defaults.output, else text)").option("--verbose", "Verbose output", false).option("--quiet", "Suppress non-essential output", false).option("--home <dir>", "btcr2 home directory holding config.json + keystore.json (default: ~/.btcr2, %LOCALAPPDATA%\\btcr2 on Windows; overrides $BTCR2_HOME)").option("-c, --config <path>", "Path to config file (default: <home>/config.json)").option("--profile <name>", "Config profile name (default: auto-detected from network)").option("--btc-rest <url>", "Override Bitcoin REST endpoint (Esplora API)").option("--btc-rpc-url <url>", "Override Bitcoin Core RPC endpoint").option("--btc-rpc-user <user>", "Bitcoin Core RPC username").option("--btc-rpc-pass <pass>", "Bitcoin Core RPC password").option("--cas-gateway <url>", "IPFS HTTP gateway for CAS reads (read-only)").option("--cas-rpc-url <url>", "IPFS HTTP RPC endpoint for a writable CAS (reads + writes; enables --publish-to-cas)").option("--btc-timeout <ms>", "Bitcoin REST/RPC request timeout in milliseconds (default: unbounded)").option("--cas-timeout <ms>", "CAS request timeout in milliseconds (default: 30000; 0 disables)").option("--btc-rest-header <header>", 'Extra Bitcoin REST header "Key: Value" (repeatable)', collectHeader, []).option("--btc-rpc-wallet <name>", "Bitcoin Core wallet name for wallet-scoped RPCs").option("--btc-rpc-header <header>", 'Extra Bitcoin Core RPC header "Key: Value" (repeatable)', collectHeader, []).option("--keystore <path>", "Path to the keystore file (default: <home>/keystore.json)").option("--passphrase-file <path>", "Read the keystore passphrase from a file (unattended use)").option("--signing-key <ref>", "Key for create/update/deactivate signing: a URN, fingerprint prefix, or name");
2039
2746
  const globals = () => this.program.opts();
2040
2747
  this.program.hook("preAction", () => {
2041
2748
  const opts = this.program.opts();
2042
- opts.output = resolveOutputFormat({ output: opts.output, config: opts.config });
2749
+ opts.output = resolveOutputFormat({ output: opts.output, config: opts.config, home: opts.home });
2043
2750
  });
2751
+ registerInitCommand(this.program, globals);
2044
2752
  registerCreateCommand(this.program, factory, keystoreFactory, globals);
2045
2753
  registerResolveCommand(this.program, factory, globals);
2046
2754
  registerUpdateCommand(this.program, keystoreFactory, globals);
2047
2755
  registerDeactivateCommand(this.program, keystoreFactory, globals);
2048
2756
  registerKeyCommand(this.program, keystoreFactory, globals);
2757
+ registerKeystoreCommand(this.program, globals);
2049
2758
  registerConfigCommand(this.program, globals);
2050
2759
  registerProfileCommand(this.program, globals);
2051
2760
  registerCompletionCommand(this.program, globals);
@@ -2095,6 +2804,7 @@ function handleError(error, verbose) {
2095
2804
  REDACTED,
2096
2805
  SUPPORTED_NETWORKS,
2097
2806
  VERSION,
2807
+ assertKeystoreAllowedForNetwork,
2098
2808
  blankToUndef,
2099
2809
  defaultApiFactory,
2100
2810
  defaultConfigPath,
@@ -2114,7 +2824,9 @@ function handleError(error, verbose) {
2114
2824
  registerConfigCommand,
2115
2825
  registerCreateCommand,
2116
2826
  registerDeactivateCommand,
2827
+ registerInitCommand,
2117
2828
  registerKeyCommand,
2829
+ registerKeystoreCommand,
2118
2830
  registerProfileCommand,
2119
2831
  registerResolveCommand,
2120
2832
  registerUpdateCommand,
@@ -2124,6 +2836,7 @@ function handleError(error, verbose) {
2124
2836
  resolveDefaultNetwork,
2125
2837
  resolveEffectiveConfig,
2126
2838
  resolveKeystorePath,
2839
+ resolveKeystoreProtection,
2127
2840
  resolveOutputFormat,
2128
2841
  resolveSecretRef,
2129
2842
  resolveSigningKeyRef,
@@ -2131,5 +2844,6 @@ function handleError(error, verbose) {
2131
2844
  scrubUrlUserinfo,
2132
2845
  setConfigPath,
2133
2846
  unsetConfigPath,
2134
- writeConfigFile
2847
+ writeConfigFile,
2848
+ writeDefaultConfigFile
2135
2849
  });