@did-btcr2/cli 0.16.0 → 0.18.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 (74) hide show
  1. package/README.md +33 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +640 -128
  4. package/dist/esm/src/cli.js +2 -1
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/create.js +4 -0
  7. package/dist/esm/src/commands/create.js.map +1 -1
  8. package/dist/esm/src/commands/deactivate.js +2 -0
  9. package/dist/esm/src/commands/deactivate.js.map +1 -1
  10. package/dist/esm/src/commands/index.js +1 -0
  11. package/dist/esm/src/commands/index.js.map +1 -1
  12. package/dist/esm/src/commands/init.js +89 -40
  13. package/dist/esm/src/commands/init.js.map +1 -1
  14. package/dist/esm/src/commands/keystore.js +126 -8
  15. package/dist/esm/src/commands/keystore.js.map +1 -1
  16. package/dist/esm/src/commands/quickstart.js +158 -0
  17. package/dist/esm/src/commands/quickstart.js.map +1 -0
  18. package/dist/esm/src/commands/update.js +2 -0
  19. package/dist/esm/src/commands/update.js.map +1 -1
  20. package/dist/esm/src/config.js +93 -6
  21. package/dist/esm/src/config.js.map +1 -1
  22. package/dist/esm/src/hints.js +54 -0
  23. package/dist/esm/src/hints.js.map +1 -0
  24. package/dist/esm/src/keystore/file-key-store.js +48 -0
  25. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  26. package/dist/esm/src/keystore/passphrase.js +13 -0
  27. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  28. package/dist/esm/src/keystore/session.js +250 -0
  29. package/dist/esm/src/keystore/session.js.map +1 -0
  30. package/dist/esm/src/paths.js +12 -0
  31. package/dist/esm/src/paths.js.map +1 -1
  32. package/dist/esm/src/types.js.map +1 -1
  33. package/dist/types/src/cli.d.ts.map +1 -1
  34. package/dist/types/src/commands/create.d.ts.map +1 -1
  35. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  36. package/dist/types/src/commands/index.d.ts +1 -0
  37. package/dist/types/src/commands/index.d.ts.map +1 -1
  38. package/dist/types/src/commands/init.d.ts +52 -5
  39. package/dist/types/src/commands/init.d.ts.map +1 -1
  40. package/dist/types/src/commands/keystore.d.ts +41 -4
  41. package/dist/types/src/commands/keystore.d.ts.map +1 -1
  42. package/dist/types/src/commands/quickstart.d.ts +12 -0
  43. package/dist/types/src/commands/quickstart.d.ts.map +1 -0
  44. package/dist/types/src/commands/update.d.ts.map +1 -1
  45. package/dist/types/src/config.d.ts +35 -0
  46. package/dist/types/src/config.d.ts.map +1 -1
  47. package/dist/types/src/hints.d.ts +24 -0
  48. package/dist/types/src/hints.d.ts.map +1 -0
  49. package/dist/types/src/keystore/file-key-store.d.ts +16 -0
  50. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  51. package/dist/types/src/keystore/passphrase.d.ts +10 -0
  52. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  53. package/dist/types/src/keystore/session.d.ts +105 -0
  54. package/dist/types/src/keystore/session.d.ts.map +1 -0
  55. package/dist/types/src/paths.d.ts +10 -0
  56. package/dist/types/src/paths.d.ts.map +1 -1
  57. package/dist/types/src/types.d.ts +32 -0
  58. package/dist/types/src/types.d.ts.map +1 -1
  59. package/package.json +5 -5
  60. package/src/cli.ts +2 -0
  61. package/src/commands/create.ts +4 -0
  62. package/src/commands/deactivate.ts +2 -0
  63. package/src/commands/index.ts +1 -0
  64. package/src/commands/init.ts +148 -50
  65. package/src/commands/keystore.ts +183 -9
  66. package/src/commands/quickstart.ts +209 -0
  67. package/src/commands/update.ts +2 -0
  68. package/src/config.ts +103 -6
  69. package/src/hints.ts +51 -0
  70. package/src/keystore/file-key-store.ts +43 -0
  71. package/src/keystore/passphrase.ts +23 -0
  72. package/src/keystore/session.ts +303 -0
  73. package/src/paths.ts +13 -0
  74. package/src/types.ts +16 -2
@@ -1,21 +1,142 @@
1
1
  import type { Command } from 'commander';
2
2
  import { existsSync } from 'node:fs';
3
- import { defaultConfigPath, resolveKeystorePath, writeDefaultConfigFile } from '../config.js';
3
+ import {
4
+ assertSupportedNetwork,
5
+ defaultConfigPath,
6
+ persistDefaultNetwork,
7
+ resolveKeystorePath,
8
+ writeDefaultConfigFile,
9
+ } from '../config.js';
4
10
  import { ensureDir } from '../keystore/atomic.js';
5
11
  import { initKeystore, keystoreSummary } from '../keystore/file-key-store.js';
6
12
  import { acquirePassphrase } from '../keystore/passphrase.js';
13
+ import { clearSession } from '../keystore/session.js';
7
14
  import { formatResult } from '../output.js';
8
- import { resolveHome } from '../paths.js';
9
- import type { CommandResult, GlobalOptions } from '../types.js';
15
+ import { defaultSessionPath, resolveHome } from '../paths.js';
16
+ import type { CommandResult, GlobalOptions, KeystoreProtectionLabel, NetworkOption } from '../types.js';
17
+
18
+ /** Options for the shared {@link runInit} scaffolding step. */
19
+ export interface RunInitOptions {
20
+ /** Establish an UNENCRYPTED dev keystore (plaintext keys, testnet only). */
21
+ dev? : boolean;
22
+ /** Re-scaffold the regenerable config even if it exists (never the keystore). */
23
+ force? : boolean;
24
+ /** Explicit network from `-n/--network`, already validated. Persisted to `defaults.network`. */
25
+ network? : NetworkOption;
26
+ /**
27
+ * Network to persist when `-n` is absent and `defaults.network` is unset: a
28
+ * command's opinionated default (mutinynet for `quickstart`). Omitted by plain
29
+ * `init`, which never persists a merely-defaulted network.
30
+ */
31
+ fallbackNetwork? : NetworkOption;
32
+ /**
33
+ * Capture the establish-time confirmed passphrase so the caller can seed a
34
+ * session with no second prompt (`quickstart --unlock`). Only populated when a
35
+ * fresh ENCRYPTED keystore is established in this call. Never printed.
36
+ */
37
+ captureEstablishedPassphrase? : boolean;
38
+ }
39
+
40
+ /** Result of the shared {@link runInit} scaffolding step. */
41
+ export interface RunInitResult {
42
+ home : string;
43
+ config : string;
44
+ keystore : string;
45
+ network : NetworkOption;
46
+ created : string[];
47
+ protection : KeystoreProtectionLabel;
48
+ /**
49
+ * The confirmed passphrase from a fresh ENCRYPTED establishment in this call,
50
+ * present only when {@link RunInitOptions.captureEstablishedPassphrase} was set
51
+ * and a keystore was established. Consumed to seed a session; never printed.
52
+ */
53
+ establishedPassphrase? : string;
54
+ }
55
+
56
+ /**
57
+ * The shared scaffolding step behind `btcr2 init` and `btcr2 quickstart` (ADR
58
+ * 079/080/083): create the home, a default config if none exists, and establish
59
+ * the keystore if none exists (encrypted by default, `--dev` for unencrypted).
60
+ * Idempotent: existing files are left untouched, and `--force` re-scaffolds only
61
+ * the regenerable config, never the keystore. Records `defaults.network` via
62
+ * {@link persistDefaultNetwork}. Returns the resolved paths, network, protection,
63
+ * and (when asked) the establish-time passphrase for session seeding.
64
+ */
65
+ export function runInit(g: GlobalOptions, options: RunInitOptions = {}): RunInitResult {
66
+ const home = resolveHome(g);
67
+ const configPath = g.config ?? defaultConfigPath(g);
68
+ const keystorePath = resolveKeystorePath(g);
69
+ ensureDir(home, 0o700);
70
+
71
+ const created: string[] = [];
72
+
73
+ // The config is regenerable, so --force may re-scaffold it.
74
+ if (!existsSync(configPath) || options.force) {
75
+ writeDefaultConfigFile(configPath);
76
+ created.push('config');
77
+ }
78
+
79
+ // The keystore holds unrecoverable secret keys, so init never overwrites an
80
+ // existing one, even with --force: re-establishing a keystore is the explicit,
81
+ // deliberate `keystore init --force`. init only establishes when none exists.
82
+ const keystoreExists = existsSync(keystorePath);
83
+ if (keystoreExists && options.force && !g.quiet) {
84
+ process.stderr.write(
85
+ `note: a keystore already exists at ${keystorePath} and was left intact. `
86
+ + 'To re-establish it (discarding its keys), run "btcr2 keystore init --force".\n',
87
+ );
88
+ }
89
+
90
+ let establishedPassphrase: string | undefined;
91
+ if (!keystoreExists) {
92
+ if (options.dev && !g.quiet) {
93
+ process.stderr.write(
94
+ 'warning: establishing an UNENCRYPTED dev keystore. Keys are stored in plaintext. '
95
+ + 'Use it only for disposable testnet material; mainnet operations will be refused.\n',
96
+ );
97
+ }
98
+ initKeystore(keystorePath, {
99
+ protection : options.dev ? 'none' : 'passphrase',
100
+ getPassphrase : (opts) => {
101
+ const passphrase = acquirePassphrase({
102
+ passphraseFile : g.passphraseFile,
103
+ confirm : opts?.confirm,
104
+ prompt : 'New keystore passphrase: ',
105
+ });
106
+ // Capture only a fresh ENCRYPTED establishment, for session seeding.
107
+ if (options.captureEstablishedPassphrase && !options.dev) establishedPassphrase = passphrase;
108
+ return passphrase;
109
+ },
110
+ });
111
+ // A freshly established keystore mints a new verifier (or none, for --dev), so
112
+ // any cached session holds a passphrase for a keystore that no longer exists.
113
+ // Drop it, matching `keystore init` / `change-passphrase` (ADR 081).
114
+ clearSession(defaultSessionPath(g));
115
+ created.push('keystore');
116
+ }
117
+
118
+ // Record the network as defaults.network idempotently (ADR 083): an explicit
119
+ // -n always writes; a merely-defaulted network writes only when the raw config
120
+ // has none yet, so a re-run never clobbers an operator's earlier choice.
121
+ const { network } = persistDefaultNetwork(configPath, {
122
+ explicit : options.network,
123
+ fallback : options.fallbackNetwork,
124
+ overrides : g,
125
+ });
126
+
127
+ const protection = keystoreSummary(keystorePath).protection;
128
+ return { home, config: configPath, keystore: keystorePath, network, created, protection, establishedPassphrase };
129
+ }
10
130
 
11
131
  /**
12
132
  * Registers the top-level `btcr2 init`: the one-command entry point that creates
13
133
  * the btcr2 home (ADR 079), writes a default config if none exists, and
14
134
  * establishes the keystore if none exists (encrypted with a confirmed passphrase
15
- * by default, or `--dev` for an unencrypted testnet keystore). Idempotent:
16
- * existing files are left untouched unless `--force` is given. Establishing the
17
- * passphrase here, up front and confirmed, is what keeps the first `key generate`
18
- * off the accidental-first-seal path (ADR 080).
135
+ * by default, or `--dev` for an unencrypted testnet keystore). `-n/--network`
136
+ * records `defaults.network` so later commands can drop `-n` (ADR 083).
137
+ * Idempotent: existing files are left untouched unless `--force` is given.
138
+ * Establishing the passphrase here, up front and confirmed, is what keeps the
139
+ * first `key generate` off the accidental-first-seal path (ADR 080).
19
140
  */
20
141
  export function registerInitCommand(program: Command, globals: () => GlobalOptions): void {
21
142
  const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
@@ -23,52 +144,29 @@ export function registerInitCommand(program: Command, globals: () => GlobalOptio
23
144
  program
24
145
  .command('init')
25
146
  .description('Set up the btcr2 home: create the directory, a default config, and establish the keystore.')
147
+ .option(
148
+ '-n, --network <network>',
149
+ 'Bitcoin network to record as defaults.network <bitcoin|testnet3|testnet4|signet|mutinynet|regtest>',
150
+ )
26
151
  .option('--dev', 'Establish an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only.', false)
27
- .option('--force', 'Re-create the config and keystore even if they already exist.', false)
28
- .action((options: { dev?: boolean; force?: boolean }) => {
152
+ .option('--force', 'Re-create the config even if it already exists (never the keystore).', false)
153
+ .action((options: { network?: string; dev?: boolean; force?: boolean }) => {
29
154
  const g = globals();
30
- const home = resolveHome(g);
31
- const configPath = g.config ?? defaultConfigPath(g);
32
- const keystorePath = resolveKeystorePath(g);
33
- ensureDir(home, 0o700);
34
-
35
- const created: string[] = [];
36
-
37
- // The config is regenerable, so --force may re-scaffold it.
38
- if (!existsSync(configPath) || options.force) {
39
- writeDefaultConfigFile(configPath);
40
- created.push('config');
41
- }
42
-
43
- // The keystore holds unrecoverable secret keys, so `init` never overwrites
44
- // an existing one, even with --force: re-establishing a keystore is the
45
- // explicit, deliberate `keystore init --force`. `init` only establishes a
46
- // keystore when none exists.
47
- const keystoreExists = existsSync(keystorePath);
48
- if (keystoreExists && options.force && !g.quiet) {
49
- process.stderr.write(
50
- `note: a keystore already exists at ${keystorePath} and was left intact. `
51
- + 'To re-establish it (discarding its keys), run "btcr2 keystore init --force".\n',
52
- );
53
- }
54
- if (!keystoreExists) {
55
- if (options.dev && !g.quiet) {
56
- process.stderr.write(
57
- 'warning: establishing an UNENCRYPTED dev keystore. Keys are stored in plaintext. '
58
- + 'Use it only for disposable testnet material; mainnet operations will be refused.\n',
59
- );
60
- }
61
- initKeystore(keystorePath, {
62
- protection : options.dev ? 'none' : 'passphrase',
63
- getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
64
- });
65
- created.push('keystore');
66
- }
67
-
68
- const protection = keystoreSummary(keystorePath).protection;
69
- print({ action: 'init', data: { home, config: configPath, keystore: keystorePath, created, protection } });
155
+ const network = options.network ? assertSupportedNetwork(options.network) : undefined;
156
+ const result = runInit(g, { dev: options.dev, force: options.force, network });
157
+ print({
158
+ action : 'init',
159
+ data : {
160
+ home : result.home,
161
+ config : result.config,
162
+ keystore : result.keystore,
163
+ network : result.network,
164
+ created : result.created,
165
+ protection : result.protection,
166
+ },
167
+ });
70
168
  if (!g.quiet && g.output !== 'json') {
71
- process.stderr.write(`btcr2 home ready at ${home}. Next: btcr2 key generate --set-active\n`);
169
+ process.stderr.write(`btcr2 home ready at ${result.home} on ${result.network}. Next: btcr2 key generate --set-active\n`);
72
170
  }
73
171
  });
74
172
  }
@@ -1,20 +1,38 @@
1
1
  import type { Command } from 'commander';
2
2
  import { existsSync } from 'node:fs';
3
- import { resolveKeystorePath } from '../config.js';
3
+ import { resolveDefaultNetwork, resolveKeystorePath } from '../config.js';
4
4
  import { CLIError } from '../error.js';
5
- import { changeKeystorePassphrase, initKeystore, keystoreSummary } from '../keystore/file-key-store.js';
5
+ import {
6
+ changeKeystorePassphrase,
7
+ initKeystore,
8
+ keystoreSummary,
9
+ keystoreVerifierId,
10
+ verifyKeystorePassphrase,
11
+ } from '../keystore/file-key-store.js';
6
12
  import { acquirePassphrase } from '../keystore/passphrase.js';
13
+ import {
14
+ clearSession,
15
+ DEFAULT_SESSION_TTL_MS,
16
+ ENV_KEYSTORE_TTL,
17
+ MAX_SESSION_TTL_MS,
18
+ parseTtlToMs,
19
+ readSessionStatus,
20
+ type SessionFile,
21
+ writeSession,
22
+ } from '../keystore/session.js';
7
23
  import { formatResult } from '../output.js';
8
- import type { CommandResult, GlobalOptions } from '../types.js';
24
+ import { defaultSessionPath } from '../paths.js';
25
+ import { blankToUndef, type CommandResult, type GlobalOptions, type NetworkOption } from '../types.js';
9
26
 
10
27
  /**
11
28
  * Registers the `keystore` command group: establish, inspect, and re-key the
12
- * encrypted keystore (ADR 080). These operate on the keystore file directly (no
13
- * Bitcoin connection or KeyManager) and never decrypt a key except when
14
- * re-sealing during `change-passphrase`.
29
+ * encrypted keystore (ADR 080), plus the session unlock agent (ADR 081). These
30
+ * operate on the keystore and session files directly (no Bitcoin connection or
31
+ * KeyManager) and never decrypt a key except when re-sealing during
32
+ * `change-passphrase`.
15
33
  */
16
34
  export function registerKeystoreCommand(program: Command, globals: () => GlobalOptions): void {
17
- const keystore = program.command('keystore').description('Establish, inspect, and re-key the keystore.');
35
+ const keystore = program.command('keystore').description('Establish, inspect, re-key, and unlock the keystore.');
18
36
  const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
19
37
 
20
38
  keystore
@@ -51,22 +69,27 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
51
69
  protection : options.dev ? 'none' : 'passphrase',
52
70
  getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
53
71
  });
72
+ // A re-established keystore mints a new verifier (or none, for --dev), so any
73
+ // cached session now holds a passphrase for a keystore that no longer exists.
74
+ // Drop it rather than leave a stale plaintext passphrase behind (ADR 081).
75
+ clearSession(defaultSessionPath(g));
54
76
  print({ action: 'keystore-init', data: { path, protection: options.dev ? 'dev' : 'encrypted' } });
55
77
  });
56
78
 
57
79
  keystore
58
80
  .command('status')
59
- .description('Show the keystore path, protection mode, and key count. Never decrypts or prompts.')
81
+ .description('Show the keystore path, protection mode, key count, and session state. Never decrypts or prompts.')
60
82
  .action(() => {
61
83
  const g = globals();
62
84
  // Diagnostic command: report status even when the config is malformed,
63
85
  // rather than crashing on the config you ran this to inspect.
64
86
  const path = resolveKeystorePath(g, { lenient: true });
65
87
  const summary = keystoreSummary(path);
88
+ const session = readSessionStatus(defaultSessionPath(g), path, keystoreVerifierId(path));
66
89
  if (summary.protection === 'dev' && !g.quiet && g.output !== 'json') {
67
90
  process.stderr.write('warning: this is an UNENCRYPTED dev keystore; keys are stored in plaintext.\n');
68
91
  }
69
- print({ action: 'keystore-status', data: { path, ...summary } });
92
+ print({ action: 'keystore-status', data: { path, ...summary, session } });
70
93
  });
71
94
 
72
95
  keystore
@@ -93,6 +116,157 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
93
116
  const oldPassphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Current keystore passphrase: ' });
94
117
  const newPassphrase = acquirePassphrase({ forcePrompt: true, confirm: true, prompt: 'New keystore passphrase: ' });
95
118
  const rekeyed = changeKeystorePassphrase(path, oldPassphrase, newPassphrase);
119
+ // The rotated verifier already invalidates a cached session by fingerprint,
120
+ // but the session file still holds the OLD passphrase in plaintext; delete it.
121
+ clearSession(defaultSessionPath(g));
96
122
  print({ action: 'keystore-change-passphrase', data: { path, rekeyed } });
97
123
  });
124
+
125
+ keystore
126
+ .command('unlock')
127
+ .description('Cache the keystore passphrase for a session so later commands do not re-prompt (ADR 081).')
128
+ .option('--ttl <duration>', `Session lifetime: bare seconds or an s/m/h suffix (default 1h, max 24h). Also $${ENV_KEYSTORE_TTL}.`)
129
+ .option('--allow-mainnet', 'Permit unlocking when the active network is mainnet (bitcoin); this suspends per-use passphrase auth for the session.', false)
130
+ .action((options: { ttl?: string; allowMainnet?: boolean }) => {
131
+ const g = globals();
132
+ const path = resolveKeystorePath(g);
133
+ const ttlMs = resolveSessionTtl(options.ttl);
134
+ // The op network for the mainnet gate is the configured default here; the
135
+ // session records `allowMainnet` and the authoritative check happens at
136
+ // consumption, where a `bitcoin` operation (network derived from the DID) is
137
+ // withheld from a session that lacks it.
138
+ const session = unlockSession({
139
+ g,
140
+ keystorePath : path,
141
+ network : resolveDefaultNetwork(g),
142
+ allowMainnet : !!options.allowMainnet,
143
+ ttlMs,
144
+ });
145
+ print({ action: 'keystore-unlock', data: { keystore: path, expiresAt: session.expiresAt, ttlSeconds: session.ttlSeconds } });
146
+ });
147
+
148
+ keystore
149
+ .command('lock')
150
+ .description('Revoke the cached session so later commands prompt for the passphrase again (ADR 081).')
151
+ .action(() => {
152
+ const g = globals();
153
+ // Resolve the session from the home only (defaultSessionPath never reads the
154
+ // config), so lock revokes even under a malformed config.
155
+ const sessionPath = defaultSessionPath(g);
156
+ const cleared = clearSession(sessionPath);
157
+ print({ action: 'keystore-lock', data: { path: sessionPath, cleared } });
158
+ });
159
+ }
160
+
161
+ /** Input for the shared {@link unlockSession} step. */
162
+ export interface UnlockSessionInput {
163
+ g : GlobalOptions;
164
+ /** The resolved keystore path to unlock. */
165
+ keystorePath : string;
166
+ /** The operation network for the mainnet gate, passed explicitly (never re-derived). */
167
+ network : NetworkOption;
168
+ /** Whether a mainnet (bitcoin) unlock is permitted (`--allow-mainnet`). */
169
+ allowMainnet : boolean;
170
+ /** Session lifetime in milliseconds (already resolved via {@link resolveSessionTtl}). */
171
+ ttlMs : number;
172
+ /**
173
+ * A pre-acquired passphrase to reuse instead of prompting (the establish-time
174
+ * passphrase from `quickstart --unlock` on a fresh keystore). Still verified
175
+ * against the keystore verifier before caching.
176
+ */
177
+ passphrase? : string;
178
+ }
179
+
180
+ /**
181
+ * The shared unlock step behind `keystore unlock` and `quickstart --unlock` (ADR
182
+ * 081/083): validate the keystore, enforce the mainnet gate against the passed
183
+ * `network`, acquire (or reuse) and verify the passphrase, and write the session.
184
+ * Refuses an absent, dev, or unestablished keystore. A wrong passphrase writes no
185
+ * session file. Returns the written {@link SessionFile}. The op network is passed
186
+ * in rather than re-derived so the mainnet gate is order-independent even when a
187
+ * caller has just written `defaults.network`.
188
+ */
189
+ export function unlockSession(input: UnlockSessionInput): SessionFile {
190
+ const { g, keystorePath: path, network, allowMainnet, ttlMs } = input;
191
+ const summary = keystoreSummary(path);
192
+ if (summary.protection === 'absent') {
193
+ throw new CLIError(`No keystore at ${path}. Run "btcr2 init" or "btcr2 keystore init" first.`, 'INVALID_ARGUMENT_ERROR', { path });
194
+ }
195
+ if (summary.protection === 'dev') {
196
+ throw new CLIError(
197
+ `The keystore at ${path} is an unencrypted dev keystore; it has no passphrase to cache, so no unlock is needed.`,
198
+ 'INVALID_ARGUMENT_ERROR',
199
+ { path },
200
+ );
201
+ }
202
+ if (!summary.established) {
203
+ throw new CLIError(
204
+ `The keystore at ${path} has no passphrase established yet. `
205
+ + 'Establish one with "btcr2 keystore init" or the first "btcr2 key generate".',
206
+ 'INVALID_ARGUMENT_ERROR',
207
+ { path },
208
+ );
209
+ }
210
+ // An unlocked encrypted keystore signs prompt-free for the whole TTL, silently
211
+ // removing per-use passphrase auth. Refuse a bitcoin context unless allowed; the
212
+ // authoritative per-use check still happens at consumption from the session's
213
+ // recorded `allowMainnet` (ADR 081).
214
+ if (!allowMainnet && network === 'bitcoin') {
215
+ throw new CLIError(
216
+ 'Refusing to unlock for a mainnet (bitcoin) context: caching the passphrase suspends per-use '
217
+ + 'authentication for the session. Pass --allow-mainnet to override, or keep signing mainnet '
218
+ + 'updates with a per-use passphrase prompt.',
219
+ 'MAINNET_UNLOCK_REFUSED_ERROR',
220
+ { path },
221
+ );
222
+ }
223
+ // Acquire the passphrase directly (env / file / prompt) with NO session
224
+ // consultation and NO confirm, or reuse a caller-provided one, then verify it
225
+ // against the keystore verifier before caching. A wrong passphrase writes no
226
+ // session file.
227
+ const passphrase = input.passphrase ?? acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Keystore passphrase: ' });
228
+ if (!verifyKeystorePassphrase(path, passphrase)) {
229
+ throw new CLIError(`Incorrect passphrase for the keystore at ${path}; no session was created.`, 'DECRYPT_ERROR', { path });
230
+ }
231
+ const verifierId = keystoreVerifierId(path);
232
+ if (!verifierId) {
233
+ // An established keystore always carries a verifier; defensive guard.
234
+ throw new CLIError(`The keystore at ${path} has no verifier to bind a session to.`, 'INVALID_ARGUMENT_ERROR', { path });
235
+ }
236
+ return writeSession(defaultSessionPath(g), {
237
+ keystorePath : path,
238
+ verifierId,
239
+ passphrase,
240
+ ttlMs,
241
+ allowMainnet,
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Resolves the session TTL in milliseconds from the `--ttl` flag, then
247
+ * `$BTCR2_KEYSTORE_TTL`, then the one-hour default. Rejects a non-positive,
248
+ * malformed, or over-24h value with a {@link CLIError} that names the actual
249
+ * source (the flag or the env var) so the operator fixes the right input.
250
+ */
251
+ export function resolveSessionTtl(flag?: string): number {
252
+ const fromFlag = blankToUndef(flag);
253
+ const raw = fromFlag ?? blankToUndef(process.env[ENV_KEYSTORE_TTL]);
254
+ if (raw === undefined) return DEFAULT_SESSION_TTL_MS;
255
+ const source = fromFlag !== undefined ? '--ttl' : `$${ENV_KEYSTORE_TTL}`;
256
+ const ms = parseTtlToMs(raw);
257
+ if (ms === undefined || ms <= 0) {
258
+ throw new CLIError(
259
+ `Invalid ${source} "${raw}": expected seconds or a value with an s/m/h suffix, e.g. 3600, 45m, or 2h.`,
260
+ 'INVALID_ARGUMENT_ERROR',
261
+ { value: raw, source },
262
+ );
263
+ }
264
+ if (ms > MAX_SESSION_TTL_MS) {
265
+ throw new CLIError(
266
+ `${source} "${raw}" exceeds the 24h maximum for a cached passphrase.`,
267
+ 'INVALID_ARGUMENT_ERROR',
268
+ { value: raw, source },
269
+ );
270
+ }
271
+ return ms;
98
272
  }
@@ -0,0 +1,209 @@
1
+ import { faucetUrl } from '@did-btcr2/api';
2
+ import type { Command } from 'commander';
3
+ import {
4
+ assertSupportedNetwork,
5
+ readConfiguredDefaultNetwork,
6
+ runDoctor,
7
+ type DoctorReport,
8
+ } from '../config.js';
9
+ import { CLIError } from '../error.js';
10
+ import { keystoreVerifierId } from '../keystore/file-key-store.js';
11
+ import { ENV_KEYSTORE_TTL, readSessionStatus } from '../keystore/session.js';
12
+ import { formatResult } from '../output.js';
13
+ import { defaultSessionPath } from '../paths.js';
14
+ import type { CommandResult, GlobalOptions, NetworkOption } from '../types.js';
15
+ import { runInit, type RunInitResult } from './init.js';
16
+ import { resolveSessionTtl, unlockSession } from './keystore.js';
17
+
18
+ /** The opinionated default network for `quickstart`: zero local infra, a free faucet, 30s blocks. */
19
+ const QUICKSTART_DEFAULT_NETWORK: NetworkOption = 'mutinynet';
20
+
21
+ /** The session sub-object reported in the quickstart envelope. */
22
+ type SessionReport = { expiresAt: number; ttlSeconds: number };
23
+
24
+ /**
25
+ * Registers the top-level `btcr2 quickstart` (ADR 083): a one-command onboarding
26
+ * that COMPOSES the existing primitives - the {@link runInit} scaffold, the
27
+ * network record, the optional {@link unlockSession} cache, and the advisory
28
+ * {@link runDoctor} probe - into a single step for a workshop follow-along.
29
+ * Reimplements nothing; the ADR 080/081 keystore and session guarantees hold by
30
+ * construction.
31
+ */
32
+ export function registerQuickstartCommand(program: Command, globals: () => GlobalOptions): void {
33
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
34
+
35
+ program
36
+ .command('quickstart')
37
+ .description('One-command onboarding: create the home + config + keystore, record the network, and (optionally) cache the session and probe endpoints.')
38
+ .option(
39
+ '-n, --network <network>',
40
+ 'Bitcoin network to set up <bitcoin|testnet3|testnet4|signet|mutinynet|regtest> (default: mutinynet)',
41
+ )
42
+ .option('--dev', 'Establish an UNENCRYPTED dev keystore: plaintext keys, no passphrase. Testnet only.', false)
43
+ .option('--unlock', 'Cache the passphrase for the session so later commands do not re-prompt (ADR 081).', false)
44
+ .option('--ttl <duration>', `Session lifetime with --unlock: bare seconds or an s/m/h suffix (default 1h, max 24h). Also $${ENV_KEYSTORE_TTL}.`)
45
+ .option('--no-doctor', 'Skip the endpoint reachability probe.')
46
+ .option('--allow-mainnet', 'Permit -n bitcoin (records mainnet as the default; dev keystores are still refused).', false)
47
+ .option('--force', 'Re-create the config even if it already exists (never the keystore).', false)
48
+ .action(async (options: {
49
+ network? : string;
50
+ dev? : boolean;
51
+ unlock? : boolean;
52
+ ttl? : string;
53
+ doctor : boolean; // commander sets false for --no-doctor, true otherwise
54
+ allowMainnet? : boolean;
55
+ force? : boolean;
56
+ }) => {
57
+ const g = globals();
58
+ const explicit = options.network ? assertSupportedNetwork(options.network) : undefined;
59
+ // The network quickstart will operate on, computed BEFORE any write so the
60
+ // mainnet guard sees the real target: explicit -n, else an existing
61
+ // defaults.network, else the mutinynet default. runInit resolves to the
62
+ // same value.
63
+ const network = explicit ?? readConfiguredDefaultNetwork(g) ?? QUICKSTART_DEFAULT_NETWORK;
64
+
65
+ // Mainnet is guarded before any files are written (ADR 083). A dev keystore
66
+ // never operates on mainnet; an encrypted mainnet setup needs the explicit
67
+ // opt-in that also gates the session-unlock mainnet suspension.
68
+ if (network === 'bitcoin') {
69
+ if (options.dev) {
70
+ throw new CLIError(
71
+ 'Refusing to quickstart a mainnet (bitcoin) dev keystore: dev keystores store keys in plaintext '
72
+ + 'and never operate on mainnet. Drop --dev, or choose a testnet with -n.',
73
+ 'MAINNET_QUICKSTART_REFUSED_ERROR',
74
+ { network },
75
+ );
76
+ }
77
+ if (!options.allowMainnet) {
78
+ throw new CLIError(
79
+ 'Refusing to quickstart on mainnet (bitcoin) without --allow-mainnet. Pass --allow-mainnet to '
80
+ + 'record mainnet as the default, or choose a testnet with -n (the default is mutinynet).',
81
+ 'MAINNET_QUICKSTART_REFUSED_ERROR',
82
+ { network },
83
+ );
84
+ }
85
+ }
86
+
87
+ // 1-2. Scaffold and record the network. An explicit -n always writes; a
88
+ // merely-defaulted mutinynet writes only when defaults.network is unset.
89
+ const init = runInit(g, {
90
+ dev : options.dev,
91
+ force : options.force,
92
+ network : explicit,
93
+ fallbackNetwork : explicit ? undefined : QUICKSTART_DEFAULT_NETWORK,
94
+ captureEstablishedPassphrase : !!options.unlock && !options.dev,
95
+ });
96
+
97
+ // 3. Optionally cache the session (ADR 081 opt-in; never on a dev keystore).
98
+ let unlocked = false;
99
+ let session: SessionReport | undefined;
100
+ if (options.unlock && !options.dev) {
101
+ const outcome = cacheSession(g, init, options.ttl, !!options.allowMainnet);
102
+ unlocked = outcome.unlocked;
103
+ session = outcome.session;
104
+ }
105
+
106
+ // 4. Advisory endpoint probe (on by default; a failed probe warns, exit 0).
107
+ let doctor: DoctorReport | undefined;
108
+ if (options.doctor) {
109
+ doctor = await runDoctor(init.network, g);
110
+ }
111
+
112
+ print({
113
+ action : 'quickstart',
114
+ data : {
115
+ home : init.home,
116
+ config : init.config,
117
+ keystore : init.keystore,
118
+ network : init.network,
119
+ created : init.created,
120
+ protection : init.protection,
121
+ unlocked,
122
+ ...(session ? { session } : {}),
123
+ ...(doctor ? { doctor } : {}),
124
+ },
125
+ });
126
+
127
+ if (!g.quiet && g.output !== 'json') {
128
+ printNextSteps(init, unlocked, session, doctor);
129
+ }
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Caches the session for `quickstart --unlock`. On a fresh keystore, reuses the
135
+ * establish-time confirmed passphrase (no second prompt). On an existing keystore
136
+ * with a live matching session, skips (idempotent re-run). Otherwise acquires and
137
+ * verifies the passphrase. In a non-interactive context with no passphrase source
138
+ * on an existing keystore, the step is a non-fatal skip (warn, `unlocked: false`),
139
+ * so `quickstart` still exits 0 (ADR 083).
140
+ */
141
+ function cacheSession(
142
+ g : GlobalOptions,
143
+ init : RunInitResult,
144
+ ttlFlag : string | undefined,
145
+ allowMainnet : boolean,
146
+ ): { unlocked: boolean; session?: SessionReport } {
147
+ const ttlMs = resolveSessionTtl(ttlFlag);
148
+ const freshlyEstablished = init.created.includes('keystore');
149
+
150
+ // Existing encrypted keystore already unlocked: report it and skip re-writing.
151
+ if (!freshlyEstablished) {
152
+ const status = readSessionStatus(defaultSessionPath(g), init.keystore, keystoreVerifierId(init.keystore));
153
+ if (status.active && status.expiresAt !== undefined) {
154
+ return { unlocked: true, session: { expiresAt: status.expiresAt, ttlSeconds: status.secondsRemaining ?? 0 } };
155
+ }
156
+ }
157
+
158
+ try {
159
+ const written = unlockSession({
160
+ g,
161
+ keystorePath : init.keystore,
162
+ network : init.network,
163
+ allowMainnet,
164
+ ttlMs,
165
+ // Reuse the establish-time passphrase on a fresh keystore: no second prompt.
166
+ passphrase : freshlyEstablished ? init.establishedPassphrase : undefined,
167
+ });
168
+ return { unlocked: true, session: { expiresAt: written.expiresAt, ttlSeconds: written.ttlSeconds } };
169
+ } catch (error) {
170
+ // On an EXISTING keystore with no passphrase source and no terminal, caching
171
+ // is a non-fatal skip: the scaffold already succeeded (ADR 083). A fresh
172
+ // keystore cannot reach here (its passphrase was just captured), and an
173
+ // interactive wrong passphrase still propagates.
174
+ const type = (error as { type?: string }).type;
175
+ if (!freshlyEstablished && !process.stdin.isTTY && type === 'PASSPHRASE_REQUIRED_ERROR') {
176
+ if (!g.quiet) {
177
+ process.stderr.write(
178
+ 'note: no passphrase source and no terminal; skipped caching the session. '
179
+ + 'Run "btcr2 keystore unlock" later to cache it.\n',
180
+ );
181
+ }
182
+ return { unlocked: false };
183
+ }
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /** Prints the text-mode next-step hints after quickstart (ADR 082/083). */
189
+ function printNextSteps(
190
+ init : RunInitResult,
191
+ unlocked : boolean,
192
+ session : SessionReport | undefined,
193
+ doctor : DoctorReport | undefined,
194
+ ): void {
195
+ const lines: string[] = [`btcr2 home ready at ${init.home} on ${init.network}.`];
196
+ if (unlocked && session) {
197
+ lines.push(`Session cached until ${new Date(session.expiresAt).toISOString()}; signing will not re-prompt until it expires.`);
198
+ }
199
+ if (init.protection === 'dev') {
200
+ lines.push('Dev keystore: keys are stored in plaintext; mainnet operations are refused.');
201
+ }
202
+ if (doctor && doctor.checks.some((c) => !c.ok)) {
203
+ lines.push('Warning: one or more endpoints were unreachable (see the doctor report). Re-run "btcr2 config doctor" for detail.');
204
+ }
205
+ lines.push('Next: btcr2 key generate --name demo --set-active');
206
+ const faucet = faucetUrl(init.network);
207
+ if (faucet) lines.push(`Faucet (fund your beacon after "btcr2 create"): ${faucet}`);
208
+ process.stderr.write(`${lines.join('\n')}\n`);
209
+ }