@did-btcr2/cli 0.10.2 → 0.11.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 (89) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +889 -43
  3. package/dist/esm/src/cli.js +30 -12
  4. package/dist/esm/src/cli.js.map +1 -1
  5. package/dist/esm/src/commands/completion.js +36 -0
  6. package/dist/esm/src/commands/completion.js.map +1 -0
  7. package/dist/esm/src/commands/config.js +69 -0
  8. package/dist/esm/src/commands/config.js.map +1 -0
  9. package/dist/esm/src/commands/deactivate.js +21 -8
  10. package/dist/esm/src/commands/deactivate.js.map +1 -1
  11. package/dist/esm/src/commands/index.js +4 -0
  12. package/dist/esm/src/commands/index.js.map +1 -1
  13. package/dist/esm/src/commands/key.js +175 -0
  14. package/dist/esm/src/commands/key.js.map +1 -0
  15. package/dist/esm/src/commands/profile.js +63 -0
  16. package/dist/esm/src/commands/profile.js.map +1 -0
  17. package/dist/esm/src/commands/update.js +19 -9
  18. package/dist/esm/src/commands/update.js.map +1 -1
  19. package/dist/esm/src/config.js +100 -13
  20. package/dist/esm/src/config.js.map +1 -1
  21. package/dist/esm/src/keystore/atomic.js +64 -0
  22. package/dist/esm/src/keystore/atomic.js.map +1 -0
  23. package/dist/esm/src/keystore/envelope.js +123 -0
  24. package/dist/esm/src/keystore/envelope.js.map +1 -0
  25. package/dist/esm/src/keystore/error.js +16 -0
  26. package/dist/esm/src/keystore/error.js.map +1 -0
  27. package/dist/esm/src/keystore/file-backed-key-manager.js +78 -0
  28. package/dist/esm/src/keystore/file-backed-key-manager.js.map +1 -0
  29. package/dist/esm/src/keystore/file-key-store.js +184 -0
  30. package/dist/esm/src/keystore/file-key-store.js.map +1 -0
  31. package/dist/esm/src/keystore/passphrase.js +87 -0
  32. package/dist/esm/src/keystore/passphrase.js.map +1 -0
  33. package/dist/esm/src/keystore/paths.js +20 -0
  34. package/dist/esm/src/keystore/paths.js.map +1 -0
  35. package/dist/esm/src/keystore/resolve-key-ref.js +47 -0
  36. package/dist/esm/src/keystore/resolve-key-ref.js.map +1 -0
  37. package/dist/types/src/cli.d.ts +6 -2
  38. package/dist/types/src/cli.d.ts.map +1 -1
  39. package/dist/types/src/commands/completion.d.ts +5 -0
  40. package/dist/types/src/commands/completion.d.ts.map +1 -0
  41. package/dist/types/src/commands/config.d.ts +5 -0
  42. package/dist/types/src/commands/config.d.ts.map +1 -0
  43. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  44. package/dist/types/src/commands/index.d.ts +4 -0
  45. package/dist/types/src/commands/index.d.ts.map +1 -1
  46. package/dist/types/src/commands/key.d.ts +10 -0
  47. package/dist/types/src/commands/key.d.ts.map +1 -0
  48. package/dist/types/src/commands/profile.d.ts +5 -0
  49. package/dist/types/src/commands/profile.d.ts.map +1 -0
  50. package/dist/types/src/commands/update.d.ts.map +1 -1
  51. package/dist/types/src/config.d.ts +50 -7
  52. package/dist/types/src/config.d.ts.map +1 -1
  53. package/dist/types/src/keystore/atomic.d.ts +19 -0
  54. package/dist/types/src/keystore/atomic.d.ts.map +1 -0
  55. package/dist/types/src/keystore/envelope.d.ts +64 -0
  56. package/dist/types/src/keystore/envelope.d.ts.map +1 -0
  57. package/dist/types/src/keystore/error.d.ts +14 -0
  58. package/dist/types/src/keystore/error.d.ts.map +1 -0
  59. package/dist/types/src/keystore/file-backed-key-manager.d.ts +41 -0
  60. package/dist/types/src/keystore/file-backed-key-manager.d.ts.map +1 -0
  61. package/dist/types/src/keystore/file-key-store.d.ts +52 -0
  62. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -0
  63. package/dist/types/src/keystore/passphrase.d.ts +20 -0
  64. package/dist/types/src/keystore/passphrase.d.ts.map +1 -0
  65. package/dist/types/src/keystore/paths.d.ts +13 -0
  66. package/dist/types/src/keystore/paths.d.ts.map +1 -0
  67. package/dist/types/src/keystore/resolve-key-ref.d.ts +19 -0
  68. package/dist/types/src/keystore/resolve-key-ref.d.ts.map +1 -0
  69. package/dist/types/src/types.d.ts +91 -0
  70. package/dist/types/src/types.d.ts.map +1 -1
  71. package/package.json +9 -4
  72. package/src/cli.ts +36 -11
  73. package/src/commands/completion.ts +40 -0
  74. package/src/commands/config.ts +84 -0
  75. package/src/commands/deactivate.ts +25 -12
  76. package/src/commands/index.ts +4 -0
  77. package/src/commands/key.ts +193 -0
  78. package/src/commands/profile.ts +65 -0
  79. package/src/commands/update.ts +23 -13
  80. package/src/config.ts +144 -22
  81. package/src/keystore/atomic.ts +73 -0
  82. package/src/keystore/envelope.ts +172 -0
  83. package/src/keystore/error.ts +16 -0
  84. package/src/keystore/file-backed-key-manager.ts +99 -0
  85. package/src/keystore/file-key-store.ts +242 -0
  86. package/src/keystore/passphrase.ts +99 -0
  87. package/src/keystore/paths.ts +20 -0
  88. package/src/keystore/resolve-key-ref.ts +62 -0
  89. package/src/types.ts +30 -11
@@ -0,0 +1,193 @@
1
+ import type { KeyManager } from '@did-btcr2/key-manager';
2
+ import { SchnorrKeyPair } from '@did-btcr2/keypair';
3
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
4
+ import type { Command } from 'commander';
5
+ import { closeSync, openSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import type { ApiFactory } from '../config.js';
7
+ import { CLIError } from '../error.js';
8
+ import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
9
+ import { formatResult } from '../output.js';
10
+ import type { CommandResult, GlobalOptions } from '../types.js';
11
+
12
+ /**
13
+ * Registers the `key` command group for managing keypairs in the encrypted
14
+ * keystore. All subcommands operate offline (no Bitcoin connection) through the
15
+ * keystore-backed KeyManager injected by the factory.
16
+ */
17
+ export function registerKeyCommand(
18
+ program : Command,
19
+ factory : ApiFactory,
20
+ globals : () => GlobalOptions,
21
+ ): void {
22
+ const key = program.command('key').description('Manage keypairs in the encrypted keystore.');
23
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
24
+
25
+ key
26
+ .command('generate')
27
+ .description('Generate a new keypair and store it.')
28
+ .option('--name <name>', 'A human-friendly name, stored as a tag and usable as a key reference.')
29
+ .option('--set-active', 'Make this the active key.', false)
30
+ .action((options: { name?: string; setActive?: boolean }) => {
31
+ const api = factory(undefined, globals());
32
+ assertNameAvailable(api.kms.kms, options.name);
33
+ const setActive = options.setActive ?? false;
34
+ const id = api.kms.generateKey({ ...(options.name && { tags: { name: options.name } }), setActive });
35
+ print({ action: 'key-generate', data: { keyId: id, publicKey: bytesToHex(api.kms.getPublicKey(id)), active: setActive } });
36
+ });
37
+
38
+ key
39
+ .command('list')
40
+ .alias('ls')
41
+ .description('List stored keys.')
42
+ .action(() => {
43
+ const kms = factory(undefined, globals()).kms.kms;
44
+ const active = kms.activeKeyId;
45
+ const data = kms.listKeys().map(id => {
46
+ const entry = kms.getEntry(id);
47
+ return {
48
+ keyId : id,
49
+ fingerprint : id.split(':').pop() ?? id,
50
+ ...(entry.tags?.name && { name: entry.tags.name }),
51
+ active : id === active,
52
+ };
53
+ });
54
+ print({ action: 'key-list', data });
55
+ });
56
+
57
+ key
58
+ .command('show <ref>')
59
+ .description('Show a key\'s public material and tags. Never prints the secret.')
60
+ .action((ref: string) => {
61
+ const kms = factory(undefined, globals()).kms.kms;
62
+ const id = resolveKeyRef(kms, ref);
63
+ const entry = kms.getEntry(id);
64
+ print({ action: 'key-show', data: { keyId: id, publicKey: bytesToHex(entry.publicKey), ...(entry.tags && { tags: entry.tags }) } });
65
+ });
66
+
67
+ key
68
+ .command('import')
69
+ .description('Import a key: a secret from a hex file, or a public key as watch-only.')
70
+ .option('--secret-file <path>', 'Path to a file containing a 32-byte secret key as hex.')
71
+ .option('--public <hex>', 'A 33-byte compressed public key as hex (imported watch-only).')
72
+ .option('--name <name>', 'A human-friendly name, stored as a tag.')
73
+ .option('--set-active', 'Make this the active key.', false)
74
+ .action((options: { secretFile?: string; public?: string; name?: string; setActive?: boolean }) => {
75
+ if (Boolean(options.secretFile) === Boolean(options.public)) {
76
+ throw new CLIError('Provide exactly one of --secret-file or --public.', 'INVALID_ARGUMENT_ERROR');
77
+ }
78
+ const api = factory(undefined, globals());
79
+ assertNameAvailable(api.kms.kms, options.name);
80
+ const keyPair = options.secretFile
81
+ ? new SchnorrKeyPair({ secretKey: readHexFile(options.secretFile, 32, '--secret-file') })
82
+ : new SchnorrKeyPair({ publicKey: parseHex(options.public ?? '', 33, '--public') });
83
+ const setActive = options.setActive ?? false;
84
+ const id = api.kms.import(keyPair, { ...(options.name && { tags: { name: options.name } }), setActive });
85
+ print({ action: 'key-import', data: { keyId: id, publicKey: bytesToHex(api.kms.getPublicKey(id)), watchOnly: !options.secretFile, active: setActive } });
86
+ });
87
+
88
+ key
89
+ .command('export <ref>')
90
+ .description('Export a key. Public material by default; --secret writes the secret to a file.')
91
+ .option('--secret', 'Export the secret key. Requires --out.', false)
92
+ .option('--out <path>', 'Write the exported secret to this file (created 0600).')
93
+ .action((ref: string, options: { secret?: boolean; out?: string }) => {
94
+ const api = factory(undefined, globals());
95
+ const id = resolveKeyRef(api.kms.kms, ref);
96
+ if (!options.secret) {
97
+ print({ action: 'key-export', data: { keyId: id, publicKey: bytesToHex(api.kms.getPublicKey(id)) } });
98
+ return;
99
+ }
100
+ if (!options.out) {
101
+ throw new CLIError('Exporting a secret requires --out <file> so it is not written to the terminal.', 'INVALID_ARGUMENT_ERROR');
102
+ }
103
+ const keyPair = api.kms.export(id);
104
+ if (!keyPair.hasSecretKey) {
105
+ throw new CLIError(`Key ${id} is watch-only and has no secret to export.`, 'INVALID_ARGUMENT_ERROR', { keyId: id });
106
+ }
107
+ process.stderr.write('warning: writing an unencrypted secret key to disk. Protect this file and delete it when done.\n');
108
+ writeSecretFile(options.out, bytesToHex(keyPair.secretKey.bytes));
109
+ print({ action: 'key-export', data: { keyId: id, secretWrittenTo: options.out } });
110
+ });
111
+
112
+ key
113
+ .command('delete <ref>')
114
+ .alias('rm')
115
+ .description('Delete a key from the keystore.')
116
+ .option('--force', 'Delete even if it is the active key.', false)
117
+ .action((ref: string, options: { force?: boolean }) => {
118
+ const api = factory(undefined, globals());
119
+ const id = resolveKeyRef(api.kms.kms, ref);
120
+ api.kms.removeKey(id, { force: options.force ?? false });
121
+ print({ action: 'key-delete', data: { keyId: id, deleted: true } });
122
+ });
123
+
124
+ key
125
+ .command('use <ref>')
126
+ .description('Set the active key, persisted across invocations.')
127
+ .action((ref: string) => {
128
+ const api = factory(undefined, globals());
129
+ const id = resolveKeyRef(api.kms.kms, ref);
130
+ api.kms.setActive(id);
131
+ print({ action: 'key-use', data: { keyId: id, active: true } });
132
+ });
133
+ }
134
+
135
+ /** Throws if a name tag is already used by another key. */
136
+ function assertNameAvailable(kms: KeyManager, name?: string): void {
137
+ if (!name) return;
138
+ if (kms.listKeys().some(id => kms.getEntry(id).tags?.name === name)) {
139
+ throw new CLIError(`A key named "${name}" already exists.`, 'INVALID_ARGUMENT_ERROR', { name });
140
+ }
141
+ }
142
+
143
+ /** Parses and length-checks a hex string into bytes. */
144
+ function parseHex(hex: string, expectedBytes: number, label: string): Uint8Array {
145
+ let bytes: Uint8Array;
146
+ try {
147
+ bytes = hexToBytes(hex.trim());
148
+ } catch {
149
+ throw new CLIError(`Invalid hex for ${label}.`, 'INVALID_ARGUMENT_ERROR', { label });
150
+ }
151
+ if (bytes.length !== expectedBytes) {
152
+ throw new CLIError(
153
+ `${label} must be ${expectedBytes} bytes (${expectedBytes * 2} hex chars), got ${bytes.length}.`,
154
+ 'INVALID_ARGUMENT_ERROR',
155
+ { label },
156
+ );
157
+ }
158
+ return bytes;
159
+ }
160
+
161
+ /** Reads a file and parses its contents as length-checked hex bytes. */
162
+ function readHexFile(path: string, expectedBytes: number, label: string): Uint8Array {
163
+ let content: string;
164
+ try {
165
+ content = readFileSync(path, 'utf-8');
166
+ } catch {
167
+ throw new CLIError(`Cannot read ${label} at ${path}.`, 'INVALID_ARGUMENT_ERROR', { label, path });
168
+ }
169
+ return parseHex(content, expectedBytes, label);
170
+ }
171
+
172
+ /**
173
+ * Writes an exported secret to a new file, created exclusively (O_CREAT|O_EXCL,
174
+ * mode 0600). The exclusive flag refuses to clobber an existing file or follow
175
+ * a pre-placed symlink, so the secret never lands in a loose-permissions or
176
+ * redirected target.
177
+ */
178
+ function writeSecretFile(path: string, contents: string): void {
179
+ let fd: number;
180
+ try {
181
+ fd = openSync(path, 'wx', 0o600);
182
+ } catch (error) {
183
+ if ((error as { code?: string }).code === 'EEXIST') {
184
+ throw new CLIError(`Refusing to overwrite existing file ${path}. Choose a new --out path.`, 'INVALID_ARGUMENT_ERROR', { path });
185
+ }
186
+ throw error;
187
+ }
188
+ try {
189
+ writeFileSync(fd, contents);
190
+ } finally {
191
+ closeSync(fd);
192
+ }
193
+ }
@@ -0,0 +1,65 @@
1
+ import type { Command } from 'commander';
2
+ import { defaultConfigPath, readConfigFile, writeConfigFile } from '../config.js';
3
+ import { CLIError } from '../error.js';
4
+ import { formatResult } from '../output.js';
5
+ import type { CommandResult, GlobalOptions } from '../types.js';
6
+
7
+ /** Registers the `profile` command group for managing configuration profiles. */
8
+ export function registerProfileCommand(program: Command, globals: () => GlobalOptions): void {
9
+ const profile = program.command('profile').description('Manage configuration profiles.');
10
+ const path = (): string => globals().config ?? defaultConfigPath();
11
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
12
+
13
+ profile
14
+ .command('add <name>')
15
+ .description('Add an empty profile.')
16
+ .action((name: string) => {
17
+ writeConfigFile(path(), raw => {
18
+ if (raw.profiles === undefined || raw.profiles === null) raw.profiles = {};
19
+ const profiles = raw.profiles as Record<string, unknown>;
20
+ if (profiles[name]) throw new CLIError(`Profile "${name}" already exists.`, 'INVALID_ARGUMENT_ERROR', { name });
21
+ profiles[name] = {};
22
+ });
23
+ print({ action: 'profile-add', data: { profile: name } });
24
+ });
25
+
26
+ profile
27
+ .command('use <name>')
28
+ .description('Set the active profile (writes defaults.profile).')
29
+ .action((name: string) => {
30
+ writeConfigFile(path(), raw => {
31
+ if (raw.defaults === undefined || raw.defaults === null) raw.defaults = {};
32
+ (raw.defaults as Record<string, unknown>).profile = name;
33
+ });
34
+ print({ action: 'profile-use', data: { profile: name } });
35
+ });
36
+
37
+ profile
38
+ .command('show [name]')
39
+ .description('Show a profile (defaults to the active profile).')
40
+ .action((name?: string) => {
41
+ const file = readConfigFile(path()) ?? {};
42
+ const target = name ?? file.defaults?.profile;
43
+ if (!target) {
44
+ throw new CLIError('No profile specified and no active profile is set.', 'INVALID_ARGUMENT_ERROR');
45
+ }
46
+ const data = file.profiles?.[target];
47
+ if (!data) {
48
+ throw new CLIError(`Profile "${target}" not found.`, 'INVALID_ARGUMENT_ERROR', { profile: target });
49
+ }
50
+ print({ action: 'profile-show', data: { profile: target, ...data } });
51
+ });
52
+
53
+ profile
54
+ .command('remove <name>')
55
+ .alias('rm')
56
+ .description('Remove a profile.')
57
+ .action((name: string) => {
58
+ writeConfigFile(path(), raw => {
59
+ const profiles = raw.profiles as Record<string, unknown> | undefined;
60
+ if (!profiles?.[name]) throw new CLIError(`Profile "${name}" not found.`, 'INVALID_ARGUMENT_ERROR', { name });
61
+ delete profiles[name];
62
+ });
63
+ print({ action: 'profile-remove', data: { profile: name } });
64
+ });
65
+ }
@@ -1,6 +1,9 @@
1
+ import { KeyManagerSigner } from '@did-btcr2/key-manager';
1
2
  import type { Command } from 'commander';
2
3
  import { deriveNetwork, type ApiFactory } from '../config.js';
3
4
  import { CLIError } from '../error.js';
5
+ import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
6
+ import { formatResult } from '../output.js';
4
7
  import type { GlobalOptions, UpdateCommandOptions } from '../types.js';
5
8
 
6
9
  export function registerUpdateCommand(
@@ -41,6 +44,13 @@ export function registerUpdateCommand(
41
44
  verificationMethodId : string;
42
45
  beaconId : unknown;
43
46
  }) => {
47
+ if (!/^\d+$/.test(options.sourceVersionId)) {
48
+ throw new CLIError(
49
+ '--source-version-id must be a non-negative integer.',
50
+ 'INVALID_ARGUMENT_ERROR',
51
+ { value: options.sourceVersionId },
52
+ );
53
+ }
44
54
  const parsed: UpdateCommandOptions = {
45
55
  sourceDocument : options.sourceDocument as UpdateCommandOptions['sourceDocument'],
46
56
  patches : options.patches as UpdateCommandOptions['patches'],
@@ -56,19 +66,19 @@ export function registerUpdateCommand(
56
66
  options
57
67
  );
58
68
  }
59
- // The CLI does not yet have a way to load signing material (keystore,
60
- // KMS config, hardware wallet, etc.), so signed updates from the CLI are
61
- // not yet wired up. Drive the SDK directly with a `Signer` for now.
62
- // Variables above are kept so command parsing + validation still works.
63
- void deriveNetwork(did);
64
- void factory;
65
- void globals;
66
- void parsed;
67
- throw new CLIError(
68
- 'CLI signing is not yet implemented. Use @did-btcr2/api with a Signer directly.',
69
- 'NOT_IMPLEMENTED_ERROR',
70
- { command: 'update' }
71
- );
69
+ const network = deriveNetwork(did);
70
+ const api = factory(network, globals());
71
+ const keyId = resolveKeyRef(api.kms.kms, globals().signingKey);
72
+ const signer = new KeyManagerSigner(api.kms.kms, keyId);
73
+ const data = await api.btcr2.update({
74
+ sourceDocument : parsed.sourceDocument,
75
+ patches : parsed.patches,
76
+ sourceVersionId : parsed.sourceVersionId,
77
+ verificationMethodId : parsed.verificationMethodId,
78
+ beaconId : parsed.beaconId,
79
+ signer,
80
+ });
81
+ console.log(formatResult({ action: 'update', data }, globals()));
72
82
  });
73
83
  }
74
84
 
package/src/config.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { createApi, Identifier, type BitcoinApiConfig, type DidBtcr2Api } from '@did-btcr2/api';
2
+ import type { KeyManager } from '@did-btcr2/key-manager';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
5
6
  import { CLIError } from './error.js';
6
- import { SUPPORTED_NETWORKS, type NetworkOption } from './types.js';
7
+ import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
8
+ import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
9
+ import { defaultKeystorePath } from './keystore/paths.js';
10
+ import { acquirePassphrase } from './keystore/passphrase.js';
11
+ import { SUPPORTED_NETWORKS, type NetworkOption, type OutputFormat } from './types.js';
7
12
 
8
13
  /**
9
14
  * Endpoint overrides provided via CLI flags, env vars, or config file.
@@ -14,13 +19,17 @@ import { SUPPORTED_NETWORKS, type NetworkOption } from './types.js';
14
19
  * meaningful when passed through the full merge chain.
15
20
  */
16
21
  export type ConnectionOverrides = {
17
- btcRest? : string;
18
- btcRpcUrl? : string;
19
- btcRpcUser?: string;
20
- btcRpcPass?: string;
21
- casGateway?: string;
22
- config? : string;
23
- profile? : string;
22
+ btcRest? : string;
23
+ btcRpcUrl? : string;
24
+ btcRpcUser? : string;
25
+ btcRpcPass? : string;
26
+ casGateway? : string;
27
+ config? : string;
28
+ profile? : string;
29
+ /** Keystore file path. Overrides the default `$XDG_DATA_HOME/btcr2/keystore.json`. */
30
+ keystore? : string;
31
+ /** Path to a file holding the keystore passphrase (for unattended use). */
32
+ passphraseFile? : string;
24
33
  };
25
34
 
26
35
  /**
@@ -47,6 +56,14 @@ export type ConnectionOverrides = {
47
56
  * ```
48
57
  */
49
58
  export type ConfigFile = {
59
+ /** Schema version, stamped on every write for forward compatibility. */
60
+ schemaVersion?: number;
61
+ /** Tool-wide defaults applied when not overridden by a flag or environment variable. */
62
+ defaults?: {
63
+ profile?: string;
64
+ network?: NetworkOption;
65
+ output?: OutputFormat;
66
+ };
50
67
  profiles?: Record<string, {
51
68
  btc?: {
52
69
  rest? : string;
@@ -57,9 +74,71 @@ export type ConfigFile = {
57
74
  cas?: {
58
75
  gateway?: string;
59
76
  };
77
+ /** Signing identity references. Never embeds key material; the secret lives in the keystore. */
78
+ identity?: {
79
+ keystore?: string;
80
+ default?: string;
81
+ };
82
+ /** Aggregation transport and cohort defaults, mirroring the aggregation runner inputs. */
83
+ aggregation?: {
84
+ transport?: 'nostr' | 'http' | 'didcomm';
85
+ relays?: string[];
86
+ httpBaseUrl?: string;
87
+ cohort?: Record<string, unknown>;
88
+ };
60
89
  }>;
61
90
  };
62
91
 
92
+ /** Current config-file schema version, stamped on every write. */
93
+ export const CONFIG_SCHEMA_VERSION = 1;
94
+
95
+ /**
96
+ * Read-modify-write a config file, preserving unknown keys. Reads the raw JSON
97
+ * (so keys outside {@link ConfigFile} survive a rewrite), applies `mutate`,
98
+ * stamps the schema version, and writes atomically (file 0600, dir 0700).
99
+ */
100
+ export function writeConfigFile(path: string, mutate: (raw: Record<string, unknown>) => void): void {
101
+ const raw: Record<string, unknown> = (readConfigFile(path) as Record<string, unknown> | undefined) ?? {};
102
+ mutate(raw);
103
+ raw.schemaVersion = CONFIG_SCHEMA_VERSION;
104
+ ensureDir(dirname(path), 0o700);
105
+ writeFileAtomic(path, `${JSON.stringify(raw, null, 2)}\n`, 0o600);
106
+ }
107
+
108
+ /** Reads the value at a dotted path (e.g. `profiles.regtest.btc.rest`). */
109
+ export function getConfigPath(config: Record<string, unknown>, path: string): unknown {
110
+ return path.split('.').reduce<unknown>(
111
+ (node, key) => (node as Record<string, unknown> | undefined)?.[key],
112
+ config,
113
+ );
114
+ }
115
+
116
+ /** Sets the value at a dotted path, creating intermediate objects. */
117
+ export function setConfigPath(config: Record<string, unknown>, path: string, value: unknown): void {
118
+ const keys = path.split('.');
119
+ const last = keys.pop();
120
+ if (!last) throw new CLIError('Config path must be non-empty.', 'INVALID_ARGUMENT_ERROR');
121
+ let node = config;
122
+ for (const key of keys) {
123
+ if (typeof node[key] !== 'object' || node[key] === null) node[key] = {};
124
+ node = node[key] as Record<string, unknown>;
125
+ }
126
+ node[last] = value;
127
+ }
128
+
129
+ /** Deletes the value at a dotted path. No-op if the path does not exist. */
130
+ export function unsetConfigPath(config: Record<string, unknown>, path: string): void {
131
+ const keys = path.split('.');
132
+ const last = keys.pop();
133
+ if (!last) return;
134
+ let node: Record<string, unknown> | undefined = config;
135
+ for (const key of keys) {
136
+ node = node?.[key] as Record<string, unknown> | undefined;
137
+ if (!node) return;
138
+ }
139
+ delete node[last];
140
+ }
141
+
63
142
  /**
64
143
  * Factory function that creates a configured {@link DidBtcr2Api} instance.
65
144
  *
@@ -67,7 +146,7 @@ export type ConfigFile = {
67
146
  * default Bitcoin endpoints (mempool.space for public networks, localhost
68
147
  * Polar for regtest). Optional `overrides` let callers replace individual
69
148
  * endpoints on top of the defaults. When `network` is omitted, no Bitcoin
70
- * or CAS is configured suitable for offline operations like `create`.
149
+ * or CAS is configured - suitable for offline operations like `create`.
71
150
  */
72
151
  export type ApiFactory = (network?: NetworkOption, overrides?: ConnectionOverrides) => DidBtcr2Api;
73
152
 
@@ -153,29 +232,32 @@ export function profileToOverrides(
153
232
  }
154
233
 
155
234
  /**
156
- * Default {@link ApiFactory} backed by network defaults from
157
- * `@did-btcr2/bitcoin` (mempool.space for public networks, localhost for
158
- * regtest).
235
+ * Resolves the Bitcoin and CAS connection config for a network by merging,
236
+ * in precedence order, CLI flags, environment variables, and the config-file
237
+ * profile on top of the per-network defaults (handled by `BitcoinConnection`).
159
238
  *
160
- * Override precedence (highest wins):
161
- * CLI flags env vars → config file profile → network defaults.
239
+ * Returns an empty config when no network is given, since offline operations
240
+ * (create, key management) need no connection.
162
241
  *
163
- * When no `--profile` is given, the network name is used as the profile
164
- * key (e.g. a regtest DID auto-selects the `"regtest"` profile).
242
+ * When no `--profile` is given, the network name is used as the profile key
243
+ * (e.g. a regtest DID auto-selects the `"regtest"` profile).
165
244
  */
166
- export function defaultApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
167
- if (!network) return createApi();
245
+ function resolveConnectionConfig(
246
+ network? : NetworkOption,
247
+ overrides?: ConnectionOverrides,
248
+ ): { btc?: BitcoinApiConfig; cas?: { gateway: string } } {
249
+ if (!network) return {};
168
250
 
169
251
  // Layer 1: Config file profile (lowest precedence of the three override layers)
170
252
  const configPath = overrides?.config ?? defaultConfigPath();
171
- const profileName = overrides?.profile ?? network;
172
253
  const file = readConfigFile(configPath);
254
+ const profileName = overrides?.profile ?? file?.defaults?.profile ?? network;
173
255
  const fileOverrides = file ? profileToOverrides(file, profileName) : {};
174
256
 
175
257
  // Layer 2: Environment variables
176
258
  const env = readEnvOverrides();
177
259
 
178
- // Merge: CLI flags env vars config file (network defaults handled by BitcoinConnection)
260
+ // Merge: CLI flags -> env vars -> config file -> (network defaults handled by BitcoinConnection)
179
261
  const merged: ConnectionOverrides = {
180
262
  btcRest : overrides?.btcRest ?? env.btcRest ?? fileOverrides.btcRest,
181
263
  btcRpcUrl : overrides?.btcRpcUrl ?? env.btcRpcUrl ?? fileOverrides.btcRpcUrl,
@@ -200,7 +282,47 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
200
282
 
201
283
  const cas = merged.casGateway ? { gateway: merged.casGateway } : undefined;
202
284
 
203
- return createApi({ btc, ...(cas && { cas }) });
285
+ return { btc, ...(cas && { cas }) };
286
+ }
287
+
288
+ /**
289
+ * Default {@link ApiFactory} backed by network defaults from
290
+ * `@did-btcr2/bitcoin` (mempool.space for public networks, localhost for
291
+ * regtest). Keystore-free: suitable for offline `create` and read-only
292
+ * `resolve`, which never need a signing identity.
293
+ *
294
+ * Override precedence (highest wins):
295
+ * CLI flags -> env vars -> config file profile -> network defaults.
296
+ */
297
+ export function defaultApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
298
+ return createApi(resolveConnectionConfig(network, overrides));
299
+ }
300
+
301
+ /**
302
+ * Builds a keystore-backed {@link KeyManager} reading secret keys from the
303
+ * encrypted on-disk keystore. The passphrase is acquired lazily, so building
304
+ * this never prompts; a prompt happens only when a secret is actually sealed
305
+ * or opened. The persisted active-key pointer is re-applied (a non-decrypting
306
+ * existence check) so "the active key" survives across invocations.
307
+ */
308
+ function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
309
+ return new FileBackedKeyManager({
310
+ path : overrides?.keystore ?? defaultKeystorePath(),
311
+ getPassphrase : () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Keystore-aware {@link ApiFactory} for commands that need a signing identity
317
+ * (key management, update, deactivate). Identical to {@link defaultApiFactory}
318
+ * for Bitcoin and CAS, plus an injected keystore-backed KeyManager. Offline key
319
+ * commands (no network) still get the keystore.
320
+ */
321
+ export function keystoreApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
322
+ return createApi({
323
+ ...resolveConnectionConfig(network, overrides),
324
+ kms : buildKeystoreKms(overrides),
325
+ });
204
326
  }
205
327
 
206
328
  /**
@@ -0,0 +1,73 @@
1
+ import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { KeyStoreError } from './error.js';
4
+
5
+ const isWindows = process.platform === 'win32';
6
+ let permsWarned = false;
7
+ let tmpCounter = 0;
8
+
9
+ /**
10
+ * Creates a directory (recursively) and, on POSIX systems, tightens it to the
11
+ * requested mode. `mkdir`'s mode is subject to the umask, so it is reapplied
12
+ * with an explicit `chmod`.
13
+ */
14
+ export function ensureDir(dir: string, mode: number): void {
15
+ mkdirSync(dir, { recursive: true, mode });
16
+ if (!isWindows) {
17
+ try {
18
+ chmodSync(dir, mode);
19
+ } catch {
20
+ // A pre-existing directory we do not own cannot be re-moded; best effort.
21
+ }
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Writes a file atomically: serialize to a sibling temporary file, tighten its
27
+ * permissions, then rename over the target so a crash mid-write cannot leave a
28
+ * truncated or partially-written file. The temporary file is removed on failure.
29
+ */
30
+ export function writeFileAtomic(path: string, data: string, mode: number): void {
31
+ const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.${tmpCounter++}.tmp`);
32
+ try {
33
+ writeFileSync(tmp, data, { mode });
34
+ if (!isWindows) chmodSync(tmp, mode);
35
+ renameSync(tmp, path);
36
+ } catch (error) {
37
+ try {
38
+ rmSync(tmp, { force: true });
39
+ } catch {
40
+ // Ignore cleanup failure; surface the original write error.
41
+ }
42
+ throw new KeyStoreError(
43
+ `Failed to write keystore at ${path}.`,
44
+ 'ATOMIC_WRITE_ERROR',
45
+ { path, cause: error instanceof Error ? error.message : String(error) },
46
+ );
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Fails closed if a keystore file is readable or writable by group or other.
52
+ * On Windows, where POSIX mode bits are not enforced, this is a no-op that
53
+ * warns once on standard error.
54
+ */
55
+ export function assertSecurePerms(path: string): void {
56
+ if (isWindows) {
57
+ if (!permsWarned) {
58
+ process.stderr.write(
59
+ 'warning: file permissions are not enforced on Windows; protect the keystore directory manually.\n',
60
+ );
61
+ permsWarned = true;
62
+ }
63
+ return;
64
+ }
65
+ const mode = statSync(path).mode & 0o777;
66
+ if ((mode & 0o077) !== 0) {
67
+ throw new KeyStoreError(
68
+ `Keystore at ${path} has insecure permissions 0${mode.toString(8)}; expected 0600.`,
69
+ 'KEYSTORE_PERMISSION_ERROR',
70
+ { path, mode: `0${mode.toString(8)}` },
71
+ );
72
+ }
73
+ }