@did-btcr2/cli 0.10.3 → 0.12.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 +1028 -114
- package/dist/esm/src/cli.js +31 -13
- 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/create.js +109 -30
- package/dist/esm/src/commands/create.js.map +1 -1
- 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 +119 -12
- 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/create.d.ts +19 -1
- package/dist/types/src/commands/create.d.ts.map +1 -1
- 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 +57 -5
- 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 +93 -5
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +9 -4
- package/src/cli.ts +37 -12
- package/src/commands/completion.ts +40 -0
- package/src/commands/config.ts +84 -0
- package/src/commands/create.ts +140 -52
- 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 +165 -20
- 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 +31 -18
|
@@ -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
|
*
|
|
@@ -153,23 +232,49 @@ export function profileToOverrides(
|
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
235
|
+
* Resolves the default Bitcoin network for offline identifier creation when no
|
|
236
|
+
* `--network` flag is given. Resolution order: the config file's
|
|
237
|
+
* `defaults.network`, then an active profile named for a network (an explicit
|
|
238
|
+
* `--profile` flag or `defaults.profile`), then `regtest` as the development
|
|
239
|
+
* fallback. Generation itself is offline; this only fixes which network the
|
|
240
|
+
* identifier encodes.
|
|
241
|
+
*/
|
|
242
|
+
export function resolveDefaultNetwork(overrides?: ConnectionOverrides): NetworkOption {
|
|
243
|
+
const configPath = overrides?.config ?? defaultConfigPath();
|
|
244
|
+
const file = readConfigFile(configPath);
|
|
245
|
+
|
|
246
|
+
const explicit = file?.defaults?.network;
|
|
247
|
+
if (explicit && SUPPORTED_NETWORKS.includes(explicit)) return explicit;
|
|
248
|
+
|
|
249
|
+
const profile = overrides?.profile ?? file?.defaults?.profile;
|
|
250
|
+
if (profile && SUPPORTED_NETWORKS.includes(profile as NetworkOption)) {
|
|
251
|
+
return profile as NetworkOption;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return 'regtest';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolves the Bitcoin and CAS connection config for a network by merging,
|
|
259
|
+
* in precedence order, CLI flags, environment variables, and the config-file
|
|
260
|
+
* profile on top of the per-network defaults (handled by `BitcoinConnection`).
|
|
159
261
|
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
262
|
+
* Returns an empty config when no network is given, since offline operations
|
|
263
|
+
* (create, key management) need no connection.
|
|
162
264
|
*
|
|
163
|
-
* When no `--profile` is given, the network name is used as the profile
|
|
164
|
-
*
|
|
265
|
+
* When no `--profile` is given, the network name is used as the profile key
|
|
266
|
+
* (e.g. a regtest DID auto-selects the `"regtest"` profile).
|
|
165
267
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
268
|
+
function resolveConnectionConfig(
|
|
269
|
+
network? : NetworkOption,
|
|
270
|
+
overrides?: ConnectionOverrides,
|
|
271
|
+
): { btc?: BitcoinApiConfig; cas?: { gateway: string } } {
|
|
272
|
+
if (!network) return {};
|
|
168
273
|
|
|
169
274
|
// Layer 1: Config file profile (lowest precedence of the three override layers)
|
|
170
275
|
const configPath = overrides?.config ?? defaultConfigPath();
|
|
171
|
-
const profileName = overrides?.profile ?? network;
|
|
172
276
|
const file = readConfigFile(configPath);
|
|
277
|
+
const profileName = overrides?.profile ?? file?.defaults?.profile ?? network;
|
|
173
278
|
const fileOverrides = file ? profileToOverrides(file, profileName) : {};
|
|
174
279
|
|
|
175
280
|
// Layer 2: Environment variables
|
|
@@ -200,7 +305,47 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
|
|
|
200
305
|
|
|
201
306
|
const cas = merged.casGateway ? { gateway: merged.casGateway } : undefined;
|
|
202
307
|
|
|
203
|
-
return
|
|
308
|
+
return { btc, ...(cas && { cas }) };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Default {@link ApiFactory} backed by network defaults from
|
|
313
|
+
* `@did-btcr2/bitcoin` (mempool.space for public networks, localhost for
|
|
314
|
+
* regtest). Keystore-free: suitable for offline `create` and read-only
|
|
315
|
+
* `resolve`, which never need a signing identity.
|
|
316
|
+
*
|
|
317
|
+
* Override precedence (highest wins):
|
|
318
|
+
* CLI flags -> env vars -> config file profile -> network defaults.
|
|
319
|
+
*/
|
|
320
|
+
export function defaultApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
|
|
321
|
+
return createApi(resolveConnectionConfig(network, overrides));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Builds a keystore-backed {@link KeyManager} reading secret keys from the
|
|
326
|
+
* encrypted on-disk keystore. The passphrase is acquired lazily, so building
|
|
327
|
+
* this never prompts; a prompt happens only when a secret is actually sealed
|
|
328
|
+
* or opened. The persisted active-key pointer is re-applied (a non-decrypting
|
|
329
|
+
* existence check) so "the active key" survives across invocations.
|
|
330
|
+
*/
|
|
331
|
+
function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
|
|
332
|
+
return new FileBackedKeyManager({
|
|
333
|
+
path : overrides?.keystore ?? defaultKeystorePath(),
|
|
334
|
+
getPassphrase : () => acquirePassphrase({ passphraseFile: overrides?.passphraseFile }),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Keystore-aware {@link ApiFactory} for commands that need a signing identity
|
|
340
|
+
* (key management, update, deactivate). Identical to {@link defaultApiFactory}
|
|
341
|
+
* for Bitcoin and CAS, plus an injected keystore-backed KeyManager. Offline key
|
|
342
|
+
* commands (no network) still get the keystore.
|
|
343
|
+
*/
|
|
344
|
+
export function keystoreApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
|
|
345
|
+
return createApi({
|
|
346
|
+
...resolveConnectionConfig(network, overrides),
|
|
347
|
+
kms : buildKeystoreKms(overrides),
|
|
348
|
+
});
|
|
204
349
|
}
|
|
205
350
|
|
|
206
351
|
/**
|
|
@@ -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
|
+
}
|