@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
@@ -3,6 +3,7 @@ import { KeyManagerSigner } from '@did-btcr2/key-manager';
3
3
  import type { Command } from 'commander';
4
4
  import { assertKeystoreAllowedForNetwork, deriveNetwork, resolveBroadcastOptions, resolveSigningKeyRef, type ApiFactory } from '../config.js';
5
5
  import { CLIError } from '../error.js';
6
+ import { printWatchHint } from '../hints.js';
6
7
  import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
7
8
  import { formatResult } from '../output.js';
8
9
  import type { GlobalOptions, UpdateCommandOptions } from '../types.js';
@@ -117,6 +118,7 @@ export function registerUpdateCommand(
117
118
  ...(broadcastOptions ? { broadcastOptions } : {}),
118
119
  });
119
120
  console.log(formatResult({ action: 'update', data }, globals()));
121
+ printWatchHint(globals(), network, data.txid);
120
122
  });
121
123
  }
122
124
 
package/src/config.ts CHANGED
@@ -7,10 +7,11 @@ import { dirname } from 'node:path';
7
7
  import { CLIError } from './error.js';
8
8
  import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
9
9
  import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
10
- import { keystoreProtection } from './keystore/file-key-store.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 { defaultConfigPath } from './paths.js';
13
+ import { readLiveSessionPassphrase } from './keystore/session.js';
14
+ import { defaultConfigPath, defaultSessionPath } from './paths.js';
14
15
  import { blankToUndef, SUPPORTED_NETWORKS, type KeystoreProtectionLabel, type NetworkOption, type OutputFormat } from './types.js';
15
16
 
16
17
  export { defaultConfigPath };
@@ -397,6 +398,81 @@ export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkO
397
398
  return 'regtest';
398
399
  }
399
400
 
401
+ /**
402
+ * The network recorded at `defaults.network` in the config file, validated, or
403
+ * `undefined` when the file is absent, malformed, or the value is unset/unknown.
404
+ * Unlike {@link resolveDefaultNetwork} this consults ONLY the raw
405
+ * `defaults.network` (no profile fallback, no regtest default), so `quickstart`
406
+ * can distinguish "the operator set a default" from "there is none yet" before
407
+ * it writes (ADR 083). Never throws: a malformed config is surfaced loudly by
408
+ * the write path, so this pre-write read stays quiet.
409
+ */
410
+ export function readConfiguredDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption | undefined {
411
+ const configPath = overrides?.config ?? defaultConfigPath(overrides);
412
+ let raw: Record<string, unknown> | undefined;
413
+ try {
414
+ raw = parseConfigFileRaw(configPath);
415
+ } catch {
416
+ return undefined;
417
+ }
418
+ const value = raw ? getConfigPath(raw, 'defaults.network') : undefined;
419
+ return typeof value === 'string' && SUPPORTED_NETWORKS.includes(value as NetworkOption)
420
+ ? value as NetworkOption
421
+ : undefined;
422
+ }
423
+
424
+ /**
425
+ * Persists `defaults.network` idempotently and returns the resolved network,
426
+ * the shared network-recording step behind `btcr2 init -n` and `btcr2 quickstart`
427
+ * (ADR 083). Writes when `explicit` (an explicit `-n`) is given, or when a
428
+ * `fallback` is given and the raw config has no `defaults.network` yet. Keyed on
429
+ * the **raw** file value (not {@link resolveDefaultNetwork}, which never returns
430
+ * undefined), so a defaulted re-run never clobbers a network the operator set
431
+ * earlier. When neither condition writes, the existing default is returned
432
+ * unchanged. Assumes `configPath` names a parseable config (the caller has just
433
+ * scaffolded one, or an existing one that a malformed-JSON read surfaces loudly).
434
+ */
435
+ export function persistDefaultNetwork(
436
+ configPath : string,
437
+ opts : { explicit?: NetworkOption; fallback?: NetworkOption; overrides?: ConnectionOverrides },
438
+ ): { network: NetworkOption; wrote: boolean } {
439
+ const raw = parseConfigFileRaw(configPath);
440
+ const rawValue = raw ? getConfigPath(raw, 'defaults.network') : undefined;
441
+ const rawNetwork = typeof rawValue === 'string' && SUPPORTED_NETWORKS.includes(rawValue as NetworkOption)
442
+ ? rawValue as NetworkOption
443
+ : undefined;
444
+
445
+ if (opts.explicit) {
446
+ if (opts.explicit !== rawNetwork) {
447
+ writeConfigFile(configPath, (r) => setConfigPath(r, 'defaults.network', opts.explicit));
448
+ return { network: opts.explicit, wrote: true };
449
+ }
450
+ return { network: opts.explicit, wrote: false };
451
+ }
452
+ if (rawNetwork) return { network: rawNetwork, wrote: false };
453
+ if (opts.fallback) {
454
+ writeConfigFile(configPath, (r) => setConfigPath(r, 'defaults.network', opts.fallback));
455
+ return { network: opts.fallback, wrote: true };
456
+ }
457
+ return { network: resolveDefaultNetwork(opts.overrides), wrote: false };
458
+ }
459
+
460
+ /**
461
+ * Validates an explicit network string against {@link SUPPORTED_NETWORKS},
462
+ * returning it typed as a {@link NetworkOption} or throwing a {@link CLIError}.
463
+ * Shared by the `-n/--network` flags on `init` and `quickstart` (ADR 083).
464
+ */
465
+ export function assertSupportedNetwork(value: string): NetworkOption {
466
+ if (!SUPPORTED_NETWORKS.includes(value as NetworkOption)) {
467
+ throw new CLIError(
468
+ `Invalid network "${value}". Must be one of ${SUPPORTED_NETWORKS.join(', ')}.`,
469
+ 'INVALID_ARGUMENT_ERROR',
470
+ { network: value },
471
+ );
472
+ }
473
+ return value as NetworkOption;
474
+ }
475
+
400
476
  /**
401
477
  * Reports a coherence conflict between the network a `create` run is about to
402
478
  * encode and the network the active profile declares, so the CLI can warn
@@ -947,13 +1023,34 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
947
1023
  * or opened. The persisted active-key pointer is re-applied (a non-decrypting
948
1024
  * existence check) so "the active key" survives across invocations.
949
1025
  */
950
- function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
1026
+ function buildKeystoreKms(overrides?: ConnectionOverrides, network?: NetworkOption): KeyManager {
1027
+ const keystorePath = resolveKeystorePath(overrides);
1028
+ const sessionPath = defaultSessionPath(overrides);
1029
+ // The network the operation will sign under, known here because the factory
1030
+ // receives it. A `bitcoin` operation must not consume a session that was not
1031
+ // unlocked with `--allow-mainnet`, so mainnet keeps per-use authentication even
1032
+ // while a session is live (ADR 081). Key commands pass no network, so the
1033
+ // session serves them as before.
1034
+ const isMainnetOperation = network === 'bitcoin';
951
1035
  return new FileBackedKeyManager({
952
- path : resolveKeystorePath(overrides),
1036
+ path : keystorePath,
953
1037
  // The store decides when to confirm: it passes `confirm: true` only while
954
1038
  // establishing a fresh keystore's passphrase, so a first-key typo is caught
955
1039
  // by a second entry (ADR 080). confirm is a no-op for env/file sources.
956
- getPassphrase : (opts) => acquirePassphrase({ passphraseFile: overrides?.passphraseFile, confirm: opts?.confirm }),
1040
+ //
1041
+ // On the non-establishing path, a cached session (ADR 081) is consulted below
1042
+ // the env var / --passphrase-file and above the interactive prompt. It is
1043
+ // wired ONLY when not confirming, so establishment never consults the session
1044
+ // and a first passphrase is always entered fresh and twice. The session is
1045
+ // bound to this keystore's verifier, so a rotated passphrase invalidates it.
1046
+ getPassphrase : (opts) => acquirePassphrase({
1047
+ passphraseFile : overrides?.passphraseFile,
1048
+ confirm : opts?.confirm,
1049
+ ...(opts?.confirm ? {} : {
1050
+ beforePrompt : (): string | undefined =>
1051
+ readLiveSessionPassphrase(sessionPath, keystorePath, keystoreVerifierId(keystorePath), isMainnetOperation),
1052
+ }),
1053
+ }),
957
1054
  });
958
1055
  }
959
1056
 
@@ -1048,7 +1145,7 @@ export function resolveSigningKeyRef(overrides?: ConnectionOverrides): string |
1048
1145
  export function keystoreApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
1049
1146
  return createApi({
1050
1147
  ...resolveConnectionConfig(network, overrides),
1051
- kms : buildKeystoreKms(overrides),
1148
+ kms : buildKeystoreKms(overrides, network),
1052
1149
  });
1053
1150
  }
1054
1151
 
package/src/hints.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { explorerAddressUrl, explorerTxUrl, faucetUrl } from '@did-btcr2/api';
2
+ import { BeaconUtils } from '@did-btcr2/method';
3
+ import type { GlobalOptions, NetworkOption } from './types.js';
4
+
5
+ /**
6
+ * Text-mode stderr hints derived from the per-network presets (ADR 082). All of
7
+ * these are suppressed under `--quiet` and `--output json` so machine output is
8
+ * never touched, and they never throw: a presentation hint must never break the
9
+ * command that produced the real result.
10
+ */
11
+
12
+ /**
13
+ * Prints a funding hint after a KEY `create` on a network with a public faucet:
14
+ * the derived initial P2WPKH beacon address next to the faucet and explorer
15
+ * links the operator would otherwise hand-copy. A no-op on a network without a
16
+ * faucet (regtest/mainnet), which also keeps mainnet from showing a fund-me
17
+ * affordance. The beacon address is derived from the DID string alone via
18
+ * {@link BeaconUtils.createBeaconService}, so it matches the resolver's
19
+ * `#initialP2WPKH` service rather than a divergent re-derivation.
20
+ */
21
+ export function printCreateFundingHint(g: GlobalOptions, network: NetworkOption, did: string): void {
22
+ if (g.quiet || g.output === 'json') return;
23
+ const faucet = faucetUrl(network);
24
+ if (!faucet) return;
25
+ let beaconAddress: string;
26
+ try {
27
+ const { serviceEndpoint } = BeaconUtils.createBeaconService(did, 'p2wpkh', 'SingletonBeacon');
28
+ beaconAddress = serviceEndpoint.replace(/^bitcoin:/, '');
29
+ } catch {
30
+ return;
31
+ }
32
+ const explorer = explorerAddressUrl(network, beaconAddress);
33
+ const lines = [
34
+ 'Fund the initial beacon to anchor updates:',
35
+ ` Beacon: ${beaconAddress}`,
36
+ ` Faucet: ${faucet}`,
37
+ ];
38
+ if (explorer) lines.push(` Explorer: ${explorer}`);
39
+ process.stderr.write(`${lines.join('\n')}\n`);
40
+ }
41
+
42
+ /**
43
+ * Prints a watch link after an `update`/`deactivate` broadcast: the
44
+ * block-explorer URL for the signal txid. A no-op on a network without an
45
+ * explorer (regtest).
46
+ */
47
+ export function printWatchHint(g: GlobalOptions, network: NetworkOption, txid: string): void {
48
+ if (g.quiet || g.output === 'json') return;
49
+ const url = explorerTxUrl(network, txid);
50
+ if (url) process.stderr.write(`Watch: ${url}\n`);
51
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import type { KeyEntry, KeyIdentifier, KeyValueStore } from '@did-btcr2/key-manager';
4
+ import { sha256 } from '@noble/hashes/sha2.js';
4
5
  import { utf8ToBytes } from '@noble/hashes/utils.js';
5
6
  import { base64urlnopad } from '@scure/base';
6
7
  import type { KeystoreProtectionLabel } from '../types.js';
@@ -664,6 +665,48 @@ export function keystoreProtection(path: string): KeystoreProtectionLabel {
664
665
  return keystoreSummary(path).protection;
665
666
  }
666
667
 
668
+ /**
669
+ * A stable fingerprint of a keystore's passphrase verifier, or `undefined` when
670
+ * the file is absent, unparsable, or carries no verifier. Compared by equality
671
+ * to detect a rotated passphrase (`change-passphrase`, `init --force`) or a
672
+ * re-established keystore, so a cached session (ADR 081) stops matching a
673
+ * keystore whose passphrase has changed. Never decrypts, never throws.
674
+ */
675
+ export function keystoreVerifierId(path: string): string | undefined {
676
+ if (!existsSync(path)) return undefined;
677
+ let parsed: KeystoreFile;
678
+ try {
679
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as KeystoreFile;
680
+ } catch {
681
+ return undefined;
682
+ }
683
+ if (!parsed.verifier) return undefined;
684
+ return base64urlnopad.encode(sha256(utf8ToBytes(JSON.stringify(parsed.verifier))));
685
+ }
686
+
687
+ /**
688
+ * Checks a candidate passphrase against the keystore's verifier without
689
+ * constructing a store or opening any key. Returns `false` for an absent,
690
+ * unparsable, dev, or verifier-less keystore and for a wrong passphrase; `true`
691
+ * only when the passphrase opens the verifier sentinel. Never throws. Used by
692
+ * `keystore unlock` (ADR 081) to refuse caching a wrong passphrase.
693
+ */
694
+ export function verifyKeystorePassphrase(path: string, passphrase: string): boolean {
695
+ if (!existsSync(path)) return false;
696
+ let parsed: KeystoreFile;
697
+ try {
698
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as KeystoreFile;
699
+ } catch {
700
+ return false;
701
+ }
702
+ if (parsed.protection !== 'passphrase' || !parsed.verifier) return false;
703
+ try {
704
+ return bytesEqual(decryptSecret(parsed.verifier, passphrase), VERIFIER_PLAINTEXT);
705
+ } catch {
706
+ return false;
707
+ }
708
+ }
709
+
667
710
  /** Options for {@link initKeystore}. */
668
711
  export interface InitKeystoreOptions {
669
712
  protection : KeystoreProtection;
@@ -19,6 +19,16 @@ export type PassphraseOptions = {
19
19
  * satisfy the new one (which would make the change a no-op).
20
20
  */
21
21
  forcePrompt?: boolean;
22
+ /**
23
+ * An optional non-interactive source consulted *after* the env var and
24
+ * passphrase file and *before* the terminal prompt (and before the "no TTY"
25
+ * failure). The session unlock agent (ADR 081) wires this to a cached
26
+ * passphrase, so a returning command consumes the session instead of
27
+ * prompting, and a non-interactive follow-on command does not hard-fail. It
28
+ * returns `undefined` when no session is available. Skipped when `forcePrompt`
29
+ * is set, and never wired during passphrase establishment (`confirm`).
30
+ */
31
+ beforePrompt?: () => string | undefined;
22
32
  };
23
33
 
24
34
  /**
@@ -39,6 +49,19 @@ export function acquirePassphrase(options: PassphraseOptions = {}): string {
39
49
  if (options.passphraseFile) {
40
50
  return assertNonEmpty(readFileSync(options.passphraseFile, 'utf-8').replace(/\r?\n$/, ''));
41
51
  }
52
+
53
+ // A cached session (ADR 081) sits below the env var and file but above the
54
+ // interactive prompt, so it is consulted before the "no TTY" failure: a
55
+ // scripted or piped follow-on command consumes the session instead of
56
+ // hard-failing. Establishment never reaches here (its caller omits it).
57
+ //
58
+ // The session already holds the exact, keystore-verified passphrase (encoded
59
+ // and decoded byte-for-byte), not a raw source needing newline normalization.
60
+ // Return it verbatim: re-stripping a trailing newline here would corrupt the
61
+ // KDF input for a passphrase that legitimately ends in one, even though unlock
62
+ // itself succeeded. assertNonEmpty is a defensive guard only.
63
+ const fromSession = options.beforePrompt?.();
64
+ if (fromSession) return assertNonEmpty(fromSession);
42
65
  }
43
66
 
44
67
  if (!process.stdin.isTTY) {
@@ -0,0 +1,303 @@
1
+ import { closeSync, constants, existsSync, fstatSync, openSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { utf8ToBytes } from '@noble/hashes/utils.js';
4
+ import { base64urlnopad } from '@scure/base';
5
+ import { ensureDir, writeFileAtomic } from './atomic.js';
6
+
7
+ /**
8
+ * The session unlock agent (ADR 081). `keystore unlock` caches the verified
9
+ * keystore passphrase in a single `<home>/session.json`, and subsequent commands
10
+ * read it in place of a prompt until it expires or `keystore lock` revokes it.
11
+ *
12
+ * This is an on-disk v1 design. The cached passphrase is base64url-*encoded*, not
13
+ * encrypted: its only protection at rest is the file's `0600` mode. That is the
14
+ * deliberate, documented cost of a portable, minimal-diff convenience; a future
15
+ * in-memory agent (v2) that never persists the secret is the real fix. Every read
16
+ * here is defensive and never throws, so a bad or hostile session degrades to a
17
+ * passphrase prompt rather than a crash.
18
+ */
19
+
20
+ /** Current session-file format version. */
21
+ export const SESSION_VERSION = 1 as const;
22
+
23
+ /** Default session lifetime: one hour. */
24
+ export const DEFAULT_SESSION_TTL_MS = 60 * 60 * 1000;
25
+ /** Hard cap on a cached-passphrase lifetime: 24 hours. A longer TTL is refused. */
26
+ export const MAX_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
27
+ /** Environment variable supplying a default TTL below the `--ttl` flag. */
28
+ export const ENV_KEYSTORE_TTL = 'BTCR2_KEYSTORE_TTL';
29
+
30
+ /**
31
+ * The on-disk session file. `passphrase` is base64url(utf8(passphrase)): an
32
+ * encoding, not encryption. `keystore` binds the session to one keystore, and
33
+ * `verifierId` (a hash of that keystore's verifier) invalidates the session when
34
+ * the passphrase is rotated. `allowMainnet` records whether the operator unlocked
35
+ * with `--allow-mainnet`; a session without it is withheld from a `bitcoin`
36
+ * operation so mainnet keeps per-use authentication (ADR 081). No derived key,
37
+ * keystore ciphertext, or signing-key bytes ever appear here.
38
+ */
39
+ export interface SessionFile {
40
+ v : typeof SESSION_VERSION;
41
+ keystore : string;
42
+ verifierId : string;
43
+ passphrase : string;
44
+ allowMainnet : boolean;
45
+ createdAt : number;
46
+ expiresAt : number;
47
+ ttlSeconds : number;
48
+ }
49
+
50
+ /** Inputs for {@link writeSession}. */
51
+ export interface WriteSessionInput {
52
+ /** The keystore this session unlocks (stored resolved/normalized). */
53
+ keystorePath : string;
54
+ /** Fingerprint of the keystore verifier, from `keystoreVerifierId`. */
55
+ verifierId : string;
56
+ /** The verified passphrase to cache. */
57
+ passphrase : string;
58
+ /** Lifetime in milliseconds. */
59
+ ttlMs : number;
60
+ /**
61
+ * Whether this session may be consumed for a mainnet (`bitcoin`) operation,
62
+ * from the `unlock --allow-mainnet` flag. Defaults to `false` (deny), so
63
+ * mainnet operations fall through to a per-use passphrase prompt (ADR 081).
64
+ */
65
+ allowMainnet? : boolean;
66
+ }
67
+
68
+ /**
69
+ * Writes the session file atomically at `0600` (temp sibling + rename), returning
70
+ * the written record so the caller can report expiry without re-reading. The
71
+ * caller is responsible for verifying the passphrase first; this only persists it.
72
+ */
73
+ export function writeSession(sessionPath: string, input: WriteSessionInput): SessionFile {
74
+ const createdAt = Date.now();
75
+ const session: SessionFile = {
76
+ v : SESSION_VERSION,
77
+ keystore : resolve(input.keystorePath),
78
+ verifierId : input.verifierId,
79
+ passphrase : base64urlnopad.encode(utf8ToBytes(input.passphrase)),
80
+ allowMainnet : input.allowMainnet ?? false,
81
+ createdAt,
82
+ expiresAt : createdAt + input.ttlMs,
83
+ ttlSeconds : Math.round(input.ttlMs / 1000),
84
+ };
85
+ ensureDir(dirname(sessionPath), 0o700);
86
+ writeFileAtomic(sessionPath, `${JSON.stringify(session, null, 2)}\n`, 0o600);
87
+ return session;
88
+ }
89
+
90
+ /**
91
+ * Deletes the session file and any crash-orphaned `writeFileAtomic` temp sibling
92
+ * (each of which would hold a plaintext passphrase). Idempotent; needs no
93
+ * passphrase. Returns whether a session file was present. Unlink is best-effort:
94
+ * it removes the name, it does not securely erase the bytes (see ADR 081).
95
+ */
96
+ export function clearSession(sessionPath: string): boolean {
97
+ let existed = false;
98
+ try {
99
+ existed = existsSync(sessionPath);
100
+ if (existed) rmSync(sessionPath, { force: true });
101
+ } catch {
102
+ // Best effort: a session we cannot remove is reported as not-cleared below.
103
+ existed = false;
104
+ }
105
+ sweepSessionTemps(sessionPath);
106
+ return existed;
107
+ }
108
+
109
+ /** Removes `.session.json.<pid>.<n>.tmp` leftovers from an interrupted atomic write. */
110
+ function sweepSessionTemps(sessionPath: string): void {
111
+ const dir = dirname(sessionPath);
112
+ const prefix = `.${basename(sessionPath)}.`;
113
+ try {
114
+ for (const name of readdirSync(dir)) {
115
+ if (name.startsWith(prefix) && name.endsWith('.tmp')) {
116
+ try {
117
+ rmSync(join(dir, name), { force: true });
118
+ } catch {
119
+ // A temp file we cannot remove is left for the next sweep.
120
+ }
121
+ }
122
+ }
123
+ } catch {
124
+ // Directory unreadable or absent: nothing to sweep.
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Returns the cached passphrase for `keystorePath` when a live, matching session
130
+ * exists, else `undefined`. Never throws. A session that is expired, stale (the
131
+ * keystore passphrase rotated), future-dated, or malformed is pruned on read; a
132
+ * live session bound to a *different* keystore is left in place (it prompts for
133
+ * the current keystore instead).
134
+ *
135
+ * `isMainnetOperation` gates the one case the network is known at consumption: a
136
+ * live session that was not unlocked with `--allow-mainnet` is withheld from a
137
+ * `bitcoin` operation (returning `undefined` so the caller falls through to a
138
+ * per-use prompt) but *not* pruned, since it remains valid for the non-mainnet
139
+ * operations the operator unlocked for (ADR 081).
140
+ */
141
+ export function readLiveSessionPassphrase(
142
+ sessionPath : string,
143
+ keystorePath : string,
144
+ currentVerifierId : string | undefined,
145
+ isMainnetOperation = false,
146
+ ): string | undefined {
147
+ const verdict = evaluateSession(sessionPath, keystorePath, currentVerifierId, Date.now());
148
+ if (verdict.status === 'live') {
149
+ // Withhold a session lacking mainnet allowance from a mainnet operation, so
150
+ // signing a `bitcoin` DID still authenticates per use. Leave it in place: it
151
+ // is a valid session, just not for this operation (like a foreign keystore).
152
+ if (isMainnetOperation && !verdict.session.allowMainnet) return undefined;
153
+ try {
154
+ return Buffer.from(base64urlnopad.decode(verdict.session.passphrase)).toString('utf-8');
155
+ } catch {
156
+ clearSession(sessionPath);
157
+ return undefined;
158
+ }
159
+ }
160
+ // Prune a session that is dead for everyone or dead for this keystore. A
161
+ // 'foreign' (live, different keystore) or 'none' session is left untouched.
162
+ if (verdict.status === 'expired' || verdict.status === 'stale'
163
+ || verdict.status === 'future' || verdict.status === 'malformed') {
164
+ clearSession(sessionPath);
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ /** Public, redacted view of the session state for `keystore status`. Never emits the passphrase. */
170
+ export interface SessionStatus {
171
+ active : boolean;
172
+ expiresAt? : number;
173
+ secondsRemaining? : number;
174
+ /** Whether the session may sign a mainnet operation prompt-free (unlocked with `--allow-mainnet`). */
175
+ allowMainnet? : boolean;
176
+ }
177
+
178
+ /**
179
+ * Reports whether a live session exists for `keystorePath` and its remaining
180
+ * lifetime, without decrypting, prompting, throwing, or emitting the passphrase.
181
+ * An expired, foreign, stale, or malformed session reports inactive. Read-only:
182
+ * unlike {@link readLiveSessionPassphrase}, it does not prune.
183
+ */
184
+ export function readSessionStatus(
185
+ sessionPath : string,
186
+ keystorePath : string,
187
+ currentVerifierId : string | undefined,
188
+ ): SessionStatus {
189
+ const now = Date.now();
190
+ const verdict = evaluateSession(sessionPath, keystorePath, currentVerifierId, now);
191
+ if (verdict.status !== 'live') return { active: false };
192
+ return {
193
+ active : true,
194
+ expiresAt : verdict.session.expiresAt,
195
+ secondsRemaining : Math.max(0, Math.round((verdict.session.expiresAt - now) / 1000)),
196
+ allowMainnet : verdict.session.allowMainnet,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Parses a TTL string into milliseconds: a bare integer is seconds; an `s`, `m`,
202
+ * or `h` suffix scales it. Returns `undefined` for any malformed input. The
203
+ * caller applies the default, the 24h cap, and the `<= 0` rejection.
204
+ */
205
+ export function parseTtlToMs(raw: string): number | undefined {
206
+ const match = /^(\d+)([smh]?)$/.exec(raw.trim());
207
+ if (!match) return undefined;
208
+ const n = Number(match[1]);
209
+ if (!Number.isFinite(n)) return undefined;
210
+ const unitMs = match[2] === 'h' ? 3_600_000 : match[2] === 'm' ? 60_000 : 1_000;
211
+ return n * unitMs;
212
+ }
213
+
214
+ /** The outcome of inspecting a session file against the current keystore and clock. */
215
+ type SessionVerdict =
216
+ | { status: 'live'; session: SessionFile }
217
+ | { status: 'none' | 'foreign' | 'expired' | 'stale' | 'future' | 'malformed' };
218
+
219
+ /**
220
+ * Classifies the session file: read it securely, then check version, shape,
221
+ * clock, keystore binding, and verifier fingerprint in that order. Ordering
222
+ * `expired` before `foreign` means an expired session is pruned regardless of
223
+ * which keystore it was for. Never throws.
224
+ */
225
+ function evaluateSession(
226
+ sessionPath : string,
227
+ keystorePath : string,
228
+ currentVerifierId : string | undefined,
229
+ now : number,
230
+ ): SessionVerdict {
231
+ const session = secureReadSession(sessionPath);
232
+ if (!session) return { status: 'none' };
233
+ if (session.v !== SESSION_VERSION) return { status: 'malformed' };
234
+ if (typeof session.passphrase !== 'string' || typeof session.keystore !== 'string'
235
+ || typeof session.verifierId !== 'string' || typeof session.allowMainnet !== 'boolean'
236
+ || typeof session.createdAt !== 'number' || typeof session.expiresAt !== 'number') {
237
+ return { status: 'malformed' };
238
+ }
239
+ if (session.createdAt > now) return { status: 'future' };
240
+ if (now >= session.expiresAt) return { status: 'expired' };
241
+ if (resolve(session.keystore) !== resolve(keystorePath)) return { status: 'foreign' };
242
+ if (currentVerifierId === undefined || session.verifierId !== currentVerifierId) return { status: 'stale' };
243
+ return { status: 'live', session };
244
+ }
245
+
246
+ /**
247
+ * Reads and parses the session file with a plaintext secret in mind. On POSIX,
248
+ * opens with `O_NOFOLLOW` (refusing a symlink) and refuses a non-regular file, a
249
+ * file not owned by this user, or one accessible by group or other, best-effort
250
+ * deleting a rejected file and reading only from the opened descriptor (no TOCTOU
251
+ * re-open). On Windows those POSIX guards are skipped (they would throw), so the
252
+ * file is read normally under the same directory ACL the keystore trusts, keeping
253
+ * the session usable rather than silently ignored. Returns `undefined` on any
254
+ * failure; never throws.
255
+ */
256
+ function secureReadSession(sessionPath: string): SessionFile | undefined {
257
+ let raw: string;
258
+ if (process.platform === 'win32') {
259
+ try {
260
+ raw = readFileSync(sessionPath, 'utf-8');
261
+ } catch {
262
+ return undefined;
263
+ }
264
+ } else {
265
+ let fd: number;
266
+ try {
267
+ fd = openSync(sessionPath, constants.O_RDONLY | constants.O_NOFOLLOW);
268
+ } catch {
269
+ // ENOENT (no session), ELOOP (symlink refused by O_NOFOLLOW), or any other
270
+ // open failure: no usable session.
271
+ return undefined;
272
+ }
273
+ try {
274
+ const st = fstatSync(fd);
275
+ const myUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
276
+ if (!st.isFile() || (myUid !== undefined && st.uid !== myUid) || (st.mode & 0o077) !== 0) {
277
+ // Not a regular file we own with 0600 perms: it was not written by this
278
+ // process. Best-effort remove it (it may hold a plaintext passphrase) and
279
+ // fall back to a prompt.
280
+ try {
281
+ rmSync(sessionPath, { force: true });
282
+ } catch {
283
+ // Cannot remove a file we do not own; ignoring it is enough.
284
+ }
285
+ return undefined;
286
+ }
287
+ raw = readFileSync(fd, 'utf-8');
288
+ } catch {
289
+ return undefined;
290
+ } finally {
291
+ try {
292
+ closeSync(fd);
293
+ } catch {
294
+ // Descriptor already gone; nothing to close.
295
+ }
296
+ }
297
+ }
298
+ try {
299
+ return JSON.parse(raw) as SessionFile;
300
+ } catch {
301
+ return undefined;
302
+ }
303
+ }
package/src/paths.ts CHANGED
@@ -23,6 +23,8 @@ export const ENV_HOME = 'BTCR2_HOME';
23
23
  /** The config and keystore file names, kept side by side under the home root. */
24
24
  export const CONFIG_FILENAME = 'config.json';
25
25
  export const KEYSTORE_FILENAME = 'keystore.json';
26
+ /** The session file name, holding the unlock agent's cached passphrase (ADR 081). */
27
+ export const SESSION_FILENAME = 'session.json';
26
28
 
27
29
  /**
28
30
  * Resolves the CLI home directory: the single root that holds `config.json` and
@@ -77,3 +79,14 @@ export function defaultConfigPath(overrides?: PathOverrides): string {
77
79
  export function defaultKeystorePath(overrides?: PathOverrides): string {
78
80
  return join(resolveHome(overrides), KEYSTORE_FILENAME);
79
81
  }
82
+
83
+ /**
84
+ * Session file path: `<home>/session.json`, where the unlock agent caches the
85
+ * keystore passphrase (ADR 081). Deliberately derived from the home root alone,
86
+ * never from `--config` / `--keystore` or the config file, so `keystore lock`
87
+ * can revoke a session even under a malformed config, and so the read and write
88
+ * paths always agree on one location per home.
89
+ */
90
+ export function defaultSessionPath(overrides?: PathOverrides): string {
91
+ return join(resolveHome(overrides), SESSION_FILENAME);
92
+ }
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ import type { Btcr2DidDocument, ResolutionOptions } from '@did-btcr2/method';
4
4
  import type { DidResolutionResult } from '@web5/dids';
5
5
  import type { DoctorReport, EffectiveConfig } from './config.js';
6
6
  import type { ConfigIssue } from './config-schema.js';
7
+ import type { SessionStatus } from './keystore/session.js';
7
8
 
8
9
  export type NetworkOption = 'bitcoin' | 'testnet3' | 'testnet4' | 'signet' | 'mutinynet' | 'regtest';
9
10
  export type OutputFormat = 'json' | 'text';
@@ -55,7 +56,18 @@ export type CommandResult =
55
56
  | { action: 'key-export'; data: { keyId: string; publicKey?: string; secretWrittenTo?: string } }
56
57
  | { action: 'key-delete'; data: { keyId: string; deleted: true } }
57
58
  | { action: 'key-use'; data: { keyId: string; active: true } }
58
- | { action: 'init'; data: { home: string; config: string; keystore: string; created: string[]; protection: KeystoreProtectionLabel } }
59
+ | { action: 'init'; data: { home: string; config: string; keystore: string; network: NetworkOption; created: string[]; protection: KeystoreProtectionLabel } }
60
+ | { action: 'quickstart'; data: {
61
+ home : string;
62
+ config : string;
63
+ keystore : string;
64
+ network : NetworkOption;
65
+ created : string[];
66
+ protection : KeystoreProtectionLabel;
67
+ unlocked : boolean;
68
+ session? : { expiresAt: number; ttlSeconds: number };
69
+ doctor? : DoctorReport;
70
+ } }
59
71
  | { action: 'config-init'; data: { path: string } }
60
72
  | { action: 'config-get'; data: unknown }
61
73
  | { action: 'config-set'; data: { path: string } }
@@ -66,8 +78,10 @@ export type CommandResult =
66
78
  | { action: 'config-path'; data: { home: string; config: string; keystore: string } }
67
79
  | { action: 'config-doctor'; data: DoctorReport }
68
80
  | { action: 'keystore-init'; data: { path: string; protection: 'encrypted' | 'dev' } }
69
- | { action: 'keystore-status'; data: { path: string; protection: KeystoreProtectionLabel; established: boolean; keyCount: number; active: string | undefined } }
81
+ | { action: 'keystore-status'; data: { path: string; protection: KeystoreProtectionLabel; established: boolean; keyCount: number; active: string | undefined; session: SessionStatus } }
70
82
  | { action: 'keystore-change-passphrase'; data: { path: string; rekeyed: number } }
83
+ | { action: 'keystore-unlock'; data: { keystore: string; expiresAt: number; ttlSeconds: number } }
84
+ | { action: 'keystore-lock'; data: { path: string; cleared: boolean } }
71
85
  | { action: 'profile-add'; data: { profile: string } }
72
86
  | { action: 'profile-use'; data: { profile: string } }
73
87
  | { action: 'profile-show'; data: unknown }