@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +889 -43
- package/dist/esm/src/cli.js +30 -12
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/completion.js +36 -0
- package/dist/esm/src/commands/completion.js.map +1 -0
- package/dist/esm/src/commands/config.js +69 -0
- package/dist/esm/src/commands/config.js.map +1 -0
- package/dist/esm/src/commands/deactivate.js +21 -8
- package/dist/esm/src/commands/deactivate.js.map +1 -1
- package/dist/esm/src/commands/index.js +4 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/key.js +175 -0
- package/dist/esm/src/commands/key.js.map +1 -0
- package/dist/esm/src/commands/profile.js +63 -0
- package/dist/esm/src/commands/profile.js.map +1 -0
- package/dist/esm/src/commands/update.js +19 -9
- package/dist/esm/src/commands/update.js.map +1 -1
- package/dist/esm/src/config.js +100 -13
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/keystore/atomic.js +64 -0
- package/dist/esm/src/keystore/atomic.js.map +1 -0
- package/dist/esm/src/keystore/envelope.js +123 -0
- package/dist/esm/src/keystore/envelope.js.map +1 -0
- package/dist/esm/src/keystore/error.js +16 -0
- package/dist/esm/src/keystore/error.js.map +1 -0
- package/dist/esm/src/keystore/file-backed-key-manager.js +78 -0
- package/dist/esm/src/keystore/file-backed-key-manager.js.map +1 -0
- package/dist/esm/src/keystore/file-key-store.js +184 -0
- package/dist/esm/src/keystore/file-key-store.js.map +1 -0
- package/dist/esm/src/keystore/passphrase.js +87 -0
- package/dist/esm/src/keystore/passphrase.js.map +1 -0
- package/dist/esm/src/keystore/paths.js +20 -0
- package/dist/esm/src/keystore/paths.js.map +1 -0
- package/dist/esm/src/keystore/resolve-key-ref.js +47 -0
- package/dist/esm/src/keystore/resolve-key-ref.js.map +1 -0
- package/dist/types/src/cli.d.ts +6 -2
- package/dist/types/src/cli.d.ts.map +1 -1
- package/dist/types/src/commands/completion.d.ts +5 -0
- package/dist/types/src/commands/completion.d.ts.map +1 -0
- package/dist/types/src/commands/config.d.ts +5 -0
- package/dist/types/src/commands/config.d.ts.map +1 -0
- package/dist/types/src/commands/deactivate.d.ts.map +1 -1
- package/dist/types/src/commands/index.d.ts +4 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/key.d.ts +10 -0
- package/dist/types/src/commands/key.d.ts.map +1 -0
- package/dist/types/src/commands/profile.d.ts +5 -0
- package/dist/types/src/commands/profile.d.ts.map +1 -0
- package/dist/types/src/commands/update.d.ts.map +1 -1
- package/dist/types/src/config.d.ts +50 -7
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/keystore/atomic.d.ts +19 -0
- package/dist/types/src/keystore/atomic.d.ts.map +1 -0
- package/dist/types/src/keystore/envelope.d.ts +64 -0
- package/dist/types/src/keystore/envelope.d.ts.map +1 -0
- package/dist/types/src/keystore/error.d.ts +14 -0
- package/dist/types/src/keystore/error.d.ts.map +1 -0
- package/dist/types/src/keystore/file-backed-key-manager.d.ts +41 -0
- package/dist/types/src/keystore/file-backed-key-manager.d.ts.map +1 -0
- package/dist/types/src/keystore/file-key-store.d.ts +52 -0
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -0
- package/dist/types/src/keystore/passphrase.d.ts +20 -0
- package/dist/types/src/keystore/passphrase.d.ts.map +1 -0
- package/dist/types/src/keystore/paths.d.ts +13 -0
- package/dist/types/src/keystore/paths.d.ts.map +1 -0
- package/dist/types/src/keystore/resolve-key-ref.d.ts +19 -0
- package/dist/types/src/keystore/resolve-key-ref.d.ts.map +1 -0
- package/dist/types/src/types.d.ts +91 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/src/cli.ts +36 -11
- package/src/commands/completion.ts +40 -0
- package/src/commands/config.ts +84 -0
- package/src/commands/deactivate.ts +25 -12
- package/src/commands/index.ts +4 -0
- package/src/commands/key.ts +193 -0
- package/src/commands/profile.ts +65 -0
- package/src/commands/update.ts +23 -13
- package/src/config.ts +144 -22
- package/src/keystore/atomic.ts +73 -0
- package/src/keystore/envelope.ts +172 -0
- package/src/keystore/error.ts +16 -0
- package/src/keystore/file-backed-key-manager.ts +99 -0
- package/src/keystore/file-key-store.ts +242 -0
- package/src/keystore/passphrase.ts +99 -0
- package/src/keystore/paths.ts +20 -0
- package/src/keystore/resolve-key-ref.ts +62 -0
- 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
|
+
}
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 {
|
|
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?
|
|
18
|
-
btcRpcUrl?
|
|
19
|
-
btcRpcUser
|
|
20
|
-
btcRpcPass
|
|
21
|
-
casGateway
|
|
22
|
-
config?
|
|
23
|
-
profile?
|
|
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
|
|
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
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
*
|
|
161
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|