@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/src/config.ts CHANGED
@@ -3,14 +3,18 @@ import type { KeyManager } from '@did-btcr2/key-manager';
3
3
  import { StaticFeeEstimator } from '@did-btcr2/method';
4
4
  import type { BroadcastOptions } from '@did-btcr2/method';
5
5
  import { readFileSync } from 'node:fs';
6
- import { homedir } from 'node:os';
7
- import { dirname, join } from 'node:path';
6
+ import { dirname } from 'node:path';
8
7
  import { CLIError } from './error.js';
9
8
  import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
10
9
  import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
10
+ import { keystoreProtection, keystoreVerifierId } from './keystore/file-key-store.js';
11
11
  import { defaultKeystorePath } from './keystore/paths.js';
12
12
  import { acquirePassphrase } from './keystore/passphrase.js';
13
- import { blankToUndef, SUPPORTED_NETWORKS, type NetworkOption, type OutputFormat } from './types.js';
13
+ import { readLiveSessionPassphrase } from './keystore/session.js';
14
+ import { defaultConfigPath, defaultSessionPath } from './paths.js';
15
+ import { blankToUndef, SUPPORTED_NETWORKS, type KeystoreProtectionLabel, type NetworkOption, type OutputFormat } from './types.js';
16
+
17
+ export { defaultConfigPath };
14
18
 
15
19
  /**
16
20
  * Endpoint overrides provided via CLI flags, env vars, or config file.
@@ -39,9 +43,11 @@ export type ConnectionOverrides = {
39
43
  btcRpcWallet? : string;
40
44
  /** Extra Bitcoin Core RPC headers as raw `Key: Value` flag values (repeatable). */
41
45
  btcRpcHeader? : string[];
46
+ /** CLI home root from `--home`. Colocates config.json + keystore.json (ADR 079). */
47
+ home? : string;
42
48
  config? : string;
43
49
  profile? : string;
44
- /** Keystore file path. Overrides the default `$XDG_DATA_HOME/btcr2/keystore.json`. */
50
+ /** Keystore file path. Overrides the home default `<home>/keystore.json`. */
45
51
  keystore? : string;
46
52
  /** Path to a file holding the keystore passphrase (for unattended use). */
47
53
  passphraseFile? : string;
@@ -144,6 +150,22 @@ export function writeConfigFile(path: string, mutate: (raw: Record<string, unkno
144
150
  writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
145
151
  }
146
152
 
153
+ /**
154
+ * Writes a default config scaffold to `path`: schema version, a `text` output
155
+ * default, and one empty profile per supported network. Shared by `config init`
156
+ * and `btcr2 init` so the seeded config is identical. Writes atomically (file
157
+ * 0600, dir 0700); the caller decides whether to overwrite an existing file.
158
+ */
159
+ export function writeDefaultConfigFile(path: string): void {
160
+ const scaffold = {
161
+ schemaVersion : CONFIG_SCHEMA_VERSION,
162
+ defaults : { output: 'text' },
163
+ profiles : Object.fromEntries(SUPPORTED_NETWORKS.map(n => [ n, {} ])),
164
+ };
165
+ ensureDir(dirname(path), 0o700);
166
+ writeFileAtomic(path, `${JSON.stringify(scaffold, null, 2)}\n`, 0o600);
167
+ }
168
+
147
169
  /** Reads the value at a dotted path (e.g. `profiles.regtest.btc.rest`). */
148
170
  export function getConfigPath(config: Record<string, unknown>, path: string): unknown {
149
171
  return path.split('.').reduce<unknown>(
@@ -244,21 +266,6 @@ export function readEnvOverrides(): ConnectionOverrides {
244
266
  };
245
267
  }
246
268
 
247
- /**
248
- * Default config file path following the XDG Base Directory Specification.
249
- *
250
- * Resolution order:
251
- * 1. `$XDG_CONFIG_HOME/btcr2/config.json`
252
- * 2. `%APPDATA%/btcr2/config.json` (Windows)
253
- * 3. `~/.config/btcr2/config.json` (fallback)
254
- */
255
- export function defaultConfigPath(): string {
256
- const base = blankToUndef(process.env.XDG_CONFIG_HOME)
257
- ?? blankToUndef(process.env.APPDATA)
258
- ?? join(homedir(), '.config');
259
- return join(base, 'btcr2', 'config.json');
260
- }
261
-
262
269
  /**
263
270
  * Reads and JSON-parses a config file without applying the schema-version
264
271
  * ceiling check. Returns `undefined` only for a genuinely absent file (ENOENT).
@@ -379,7 +386,7 @@ export function resolveActiveProfile(
379
386
  * identifier encodes.
380
387
  */
381
388
  export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption {
382
- const configPath = overrides?.config ?? defaultConfigPath();
389
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
383
390
  const file = readConfigFile(configPath);
384
391
 
385
392
  const explicit = file?.defaults?.network;
@@ -407,7 +414,7 @@ export function profileNetworkMismatch(
407
414
  // command that actually resolves a connection.
408
415
  let file: ConfigFile | undefined;
409
416
  try {
410
- file = readConfigFile(overrides?.config ?? defaultConfigPath());
417
+ file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
411
418
  } catch {
412
419
  return undefined;
413
420
  }
@@ -422,13 +429,13 @@ export function profileNetworkMismatch(
422
429
  * then `'text'`. A malformed config never blocks output resolution (the command's
423
430
  * own read path surfaces it); output format falls back to `'text'` instead.
424
431
  */
425
- export function resolveOutputFormat(options: { output?: string; config?: string }): OutputFormat {
432
+ export function resolveOutputFormat(options: { output?: string; config?: string; home?: string }): OutputFormat {
426
433
  const candidates: Array<string | undefined> = [
427
434
  blankToUndef(options.output),
428
435
  process.env.BTCR2_OUTPUT || undefined,
429
436
  ];
430
437
  try {
431
- candidates.push(readConfigFile(options.config ?? defaultConfigPath())?.defaults?.output);
438
+ candidates.push(readConfigFile(options.config ?? defaultConfigPath(options))?.defaults?.output);
432
439
  } catch {
433
440
  // Output format is cosmetic; a broken config is reported by the command
434
441
  // itself rather than aborting here (which would block a recovery command).
@@ -501,7 +508,7 @@ export function resolveConnectionConfig(
501
508
  // Layer 1: Config file profile (lowest precedence of the three override layers).
502
509
  // The active-profile name is resolved through the same shared helper as
503
510
  // resolveDefaultNetwork so the two cannot disagree about which profile is live.
504
- const configPath = overrides?.config ?? defaultConfigPath();
511
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
505
512
  const file = readConfigFile(configPath);
506
513
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
507
514
  const profileName = activeProfile ?? network;
@@ -708,7 +715,7 @@ export function resolveBroadcastOptions(
708
715
  overrides: ConnectionOverrides | undefined,
709
716
  flags : { feeRate?: string; changeAddress?: string },
710
717
  ): BroadcastOptions | undefined {
711
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
718
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
712
719
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
713
720
  const profileBtc = file?.profiles?.[activeProfile ?? network]?.btc;
714
721
 
@@ -778,7 +785,7 @@ export interface EffectiveConfig {
778
785
  * and each `source` is derived by the same precedence order the merge uses.
779
786
  */
780
787
  export function resolveEffectiveConfig(network: NetworkOption, overrides?: ConnectionOverrides): EffectiveConfig {
781
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
788
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
782
789
  const { name: activeProfile } = resolveActiveProfile(file, overrides);
783
790
  const profileName = activeProfile ?? network;
784
791
  const fileOv = file ? profileToOverrides(file, profileName) : {};
@@ -941,20 +948,89 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
941
948
  * or opened. The persisted active-key pointer is re-applied (a non-decrypting
942
949
  * existence check) so "the active key" survives across invocations.
943
950
  */
944
- function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
951
+ function buildKeystoreKms(overrides?: ConnectionOverrides, network?: NetworkOption): KeyManager {
952
+ const keystorePath = resolveKeystorePath(overrides);
953
+ const sessionPath = defaultSessionPath(overrides);
954
+ // The network the operation will sign under, known here because the factory
955
+ // receives it. A `bitcoin` operation must not consume a session that was not
956
+ // unlocked with `--allow-mainnet`, so mainnet keeps per-use authentication even
957
+ // while a session is live (ADR 081). Key commands pass no network, so the
958
+ // session serves them as before.
959
+ const isMainnetOperation = network === 'bitcoin';
945
960
  return new FileBackedKeyManager({
946
- path : resolveKeystorePath(overrides),
947
- getPassphrase : () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
961
+ path : keystorePath,
962
+ // The store decides when to confirm: it passes `confirm: true` only while
963
+ // establishing a fresh keystore's passphrase, so a first-key typo is caught
964
+ // by a second entry (ADR 080). confirm is a no-op for env/file sources.
965
+ //
966
+ // On the non-establishing path, a cached session (ADR 081) is consulted below
967
+ // the env var / --passphrase-file and above the interactive prompt. It is
968
+ // wired ONLY when not confirming, so establishment never consults the session
969
+ // and a first passphrase is always entered fresh and twice. The session is
970
+ // bound to this keystore's verifier, so a rotated passphrase invalidates it.
971
+ getPassphrase : (opts) => acquirePassphrase({
972
+ passphraseFile : overrides?.passphraseFile,
973
+ confirm : opts?.confirm,
974
+ ...(opts?.confirm ? {} : {
975
+ beforePrompt : (): string | undefined =>
976
+ readLiveSessionPassphrase(sessionPath, keystorePath, keystoreVerifierId(keystorePath), isMainnetOperation),
977
+ }),
978
+ }),
948
979
  });
949
980
  }
950
981
 
982
+ /**
983
+ * The protection mode of the resolved keystore, read without decrypting or
984
+ * prompting: `encrypted`, `dev` (plaintext), or `absent`. Used by `keystore
985
+ * status`, `config path`, and the mainnet guard.
986
+ */
987
+ export function resolveKeystoreProtection(overrides?: ConnectionOverrides): KeystoreProtectionLabel {
988
+ return keystoreProtection(resolveKeystorePath(overrides));
989
+ }
990
+
991
+ /**
992
+ * Hard-refuses using an unencrypted dev keystore for a mainnet operation (ADR
993
+ * 080). A plaintext key must never sign or seal a `bitcoin` did:btcr2; the check
994
+ * reads only the keystore's protection header, so it never decrypts or prompts.
995
+ * A no-op for every other network and for encrypted/absent keystores.
996
+ */
997
+ export function assertKeystoreAllowedForNetwork(network: NetworkOption, overrides?: ConnectionOverrides): void {
998
+ if (network !== 'bitcoin') return;
999
+ if (resolveKeystoreProtection(overrides) !== 'dev') return;
1000
+ const path = resolveKeystorePath(overrides);
1001
+ throw new CLIError(
1002
+ `Refusing a mainnet (bitcoin) operation with the unencrypted dev keystore at ${path}. `
1003
+ + 'Dev keystores hold plaintext keys and are for testnet/regtest throwaway material only. '
1004
+ + 'Establish an encrypted keystore (btcr2 keystore init) for mainnet keys.',
1005
+ 'DEV_KEYSTORE_MAINNET_ERROR',
1006
+ { path, network },
1007
+ );
1008
+ }
1009
+
951
1010
  /**
952
1011
  * Resolves the keystore file path: the `--keystore` flag, else the active
953
- * profile's `identity.keystore`, else the default XDG keystore path. The flag
954
- * always wins over the profile default.
1012
+ * profile's `identity.keystore`, else the default `<home>/keystore.json` (ADR
1013
+ * 079). The flag always wins over the profile default and never reads the config.
1014
+ *
1015
+ * A malformed config aborts loudly by default so a keystore-mutating command
1016
+ * never silently reads or writes the wrong store. Pass `lenient: true` only for
1017
+ * diagnostic/recovery commands (`config path`, `keystore status`) that must still
1018
+ * report a path instead of crashing on the very config you ran them to fix; those
1019
+ * fall back to the home default when the profile identity cannot be read.
955
1020
  */
956
- export function resolveKeystorePath(overrides?: ConnectionOverrides): string {
957
- return overrides?.keystore ?? activeProfileIdentity(overrides)?.keystore ?? defaultKeystorePath();
1021
+ export function resolveKeystorePath(overrides?: ConnectionOverrides, options?: { lenient?: boolean }): string {
1022
+ // The flag wins outright and short-circuits before any config read. A blank
1023
+ // flag defers to the profile, and a blank profile `identity.keystore` defers to
1024
+ // the default, so neither resolves the keystore to an empty path.
1025
+ const fromFlag = blankToUndef(overrides?.keystore);
1026
+ if (fromFlag) return fromFlag;
1027
+ let identity: { keystore?: string; default?: string } | undefined;
1028
+ try {
1029
+ identity = activeProfileIdentity(overrides);
1030
+ } catch (error) {
1031
+ if (!options?.lenient) throw error;
1032
+ }
1033
+ return blankToUndef(identity?.keystore) ?? defaultKeystorePath(overrides);
958
1034
  }
959
1035
 
960
1036
  /**
@@ -963,7 +1039,14 @@ export function resolveKeystorePath(overrides?: ConnectionOverrides): string {
963
1039
  * selected by `--profile` or the config's `defaults.profile`.
964
1040
  */
965
1041
  function activeProfileIdentity(overrides?: ConnectionOverrides): { keystore?: string; default?: string } | undefined {
966
- const file = readConfigFile(overrides?.config ?? defaultConfigPath());
1042
+ // Intentionally propagates a malformed-config error rather than swallowing it.
1043
+ // This feeds resolveKeystorePath for keystore-mutating commands (key generate/
1044
+ // import, keystore init/change-passphrase) and the mainnet dev-keystore guard,
1045
+ // so a broken config must abort loudly instead of silently resolving to the
1046
+ // default keystore and stranding key material there. Diagnostic-only commands
1047
+ // opt into a graceful fallback via resolveKeystorePath's `lenient` option; do
1048
+ // not add a try/catch here (it would re-hide the keystore misdirection).
1049
+ const file = readConfigFile(overrides?.config ?? defaultConfigPath(overrides));
967
1050
  const { name } = resolveActiveProfile(file, overrides);
968
1051
  return name ? file?.profiles?.[name]?.identity : undefined;
969
1052
  }
@@ -987,7 +1070,7 @@ export function resolveSigningKeyRef(overrides?: ConnectionOverrides): string |
987
1070
  export function keystoreApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
988
1071
  return createApi({
989
1072
  ...resolveConnectionConfig(network, overrides),
990
- kms : buildKeystoreKms(overrides),
1073
+ kms : buildKeystoreKms(overrides, network),
991
1074
  });
992
1075
  }
993
1076