@did-btcr2/cli 0.16.0 → 0.17.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 +310 -42
- package/dist/esm/src/commands/init.js +7 -1
- package/dist/esm/src/commands/init.js.map +1 -1
- package/dist/esm/src/commands/keystore.js +107 -8
- package/dist/esm/src/commands/keystore.js.map +1 -1
- package/dist/esm/src/config.js +27 -6
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/keystore/file-key-store.js +48 -0
- package/dist/esm/src/keystore/file-key-store.js.map +1 -1
- package/dist/esm/src/keystore/passphrase.js +13 -0
- package/dist/esm/src/keystore/passphrase.js.map +1 -1
- package/dist/esm/src/keystore/session.js +250 -0
- package/dist/esm/src/keystore/session.js.map +1 -0
- package/dist/esm/src/paths.js +12 -0
- package/dist/esm/src/paths.js.map +1 -1
- package/dist/esm/src/types.js.map +1 -1
- package/dist/types/src/commands/init.d.ts.map +1 -1
- package/dist/types/src/commands/keystore.d.ts +5 -4
- package/dist/types/src/commands/keystore.d.ts.map +1 -1
- package/dist/types/src/config.d.ts.map +1 -1
- package/dist/types/src/keystore/file-key-store.d.ts +16 -0
- package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
- package/dist/types/src/keystore/passphrase.d.ts +10 -0
- package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
- package/dist/types/src/keystore/session.d.ts +105 -0
- package/dist/types/src/keystore/session.d.ts.map +1 -0
- package/dist/types/src/paths.d.ts +10 -0
- package/dist/types/src/paths.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +15 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/commands/init.ts +7 -1
- package/src/commands/keystore.ts +143 -9
- package/src/config.ts +28 -6
- package/src/keystore/file-key-store.ts +43 -0
- package/src/keystore/passphrase.ts +23 -0
- package/src/keystore/session.ts +303 -0
- package/src/paths.ts +13 -0
- package/src/types.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@did-btcr2/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI for interacting with did-btcr2-js, the JavaScript/TypeScript reference implementation of the did:btcr2 method. Exposes various parts of multiple packages in the did-btcr2-js monorepo.",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -62,10 +62,10 @@
|
|
|
62
62
|
"@web5/dids": "^1.2.0",
|
|
63
63
|
"commander": "^13.1.0",
|
|
64
64
|
"@did-btcr2/api": "^0.16.1",
|
|
65
|
-
"@did-btcr2/keypair": "^0.13.1",
|
|
66
|
-
"@did-btcr2/common": "^9.1.0",
|
|
67
65
|
"@did-btcr2/key-manager": "^0.7.0",
|
|
68
|
-
"@did-btcr2/
|
|
66
|
+
"@did-btcr2/common": "^9.1.0",
|
|
67
|
+
"@did-btcr2/method": "^0.54.1",
|
|
68
|
+
"@did-btcr2/keypair": "^0.13.1"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@eslint/js": "^9.21.0",
|
package/src/commands/init.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { defaultConfigPath, resolveKeystorePath, writeDefaultConfigFile } from '
|
|
|
4
4
|
import { ensureDir } from '../keystore/atomic.js';
|
|
5
5
|
import { initKeystore, keystoreSummary } from '../keystore/file-key-store.js';
|
|
6
6
|
import { acquirePassphrase } from '../keystore/passphrase.js';
|
|
7
|
+
import { clearSession } from '../keystore/session.js';
|
|
7
8
|
import { formatResult } from '../output.js';
|
|
8
|
-
import { resolveHome } from '../paths.js';
|
|
9
|
+
import { defaultSessionPath, resolveHome } from '../paths.js';
|
|
9
10
|
import type { CommandResult, GlobalOptions } from '../types.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -62,6 +63,11 @@ export function registerInitCommand(program: Command, globals: () => GlobalOptio
|
|
|
62
63
|
protection : options.dev ? 'none' : 'passphrase',
|
|
63
64
|
getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
|
|
64
65
|
});
|
|
66
|
+
// A freshly established keystore mints a new verifier (or none, for --dev),
|
|
67
|
+
// so any cached session holds a passphrase for a keystore that no longer
|
|
68
|
+
// exists. Drop it, matching `keystore init` / `change-passphrase`, rather
|
|
69
|
+
// than leave a stale plaintext passphrase behind (ADR 081).
|
|
70
|
+
clearSession(defaultSessionPath(g));
|
|
65
71
|
created.push('keystore');
|
|
66
72
|
}
|
|
67
73
|
|
package/src/commands/keystore.ts
CHANGED
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { resolveKeystorePath } from '../config.js';
|
|
3
|
+
import { resolveDefaultNetwork, resolveKeystorePath } from '../config.js';
|
|
4
4
|
import { CLIError } from '../error.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
changeKeystorePassphrase,
|
|
7
|
+
initKeystore,
|
|
8
|
+
keystoreSummary,
|
|
9
|
+
keystoreVerifierId,
|
|
10
|
+
verifyKeystorePassphrase,
|
|
11
|
+
} from '../keystore/file-key-store.js';
|
|
6
12
|
import { acquirePassphrase } from '../keystore/passphrase.js';
|
|
13
|
+
import {
|
|
14
|
+
clearSession,
|
|
15
|
+
DEFAULT_SESSION_TTL_MS,
|
|
16
|
+
ENV_KEYSTORE_TTL,
|
|
17
|
+
MAX_SESSION_TTL_MS,
|
|
18
|
+
parseTtlToMs,
|
|
19
|
+
readSessionStatus,
|
|
20
|
+
writeSession,
|
|
21
|
+
} from '../keystore/session.js';
|
|
7
22
|
import { formatResult } from '../output.js';
|
|
8
|
-
import
|
|
23
|
+
import { defaultSessionPath } from '../paths.js';
|
|
24
|
+
import { blankToUndef, type CommandResult, type GlobalOptions } from '../types.js';
|
|
9
25
|
|
|
10
26
|
/**
|
|
11
27
|
* Registers the `keystore` command group: establish, inspect, and re-key the
|
|
12
|
-
* encrypted keystore (ADR 080)
|
|
13
|
-
*
|
|
14
|
-
* re-sealing during
|
|
28
|
+
* encrypted keystore (ADR 080), plus the session unlock agent (ADR 081). These
|
|
29
|
+
* operate on the keystore and session files directly (no Bitcoin connection or
|
|
30
|
+
* KeyManager) and never decrypt a key except when re-sealing during
|
|
31
|
+
* `change-passphrase`.
|
|
15
32
|
*/
|
|
16
33
|
export function registerKeystoreCommand(program: Command, globals: () => GlobalOptions): void {
|
|
17
|
-
const keystore = program.command('keystore').description('Establish, inspect,
|
|
34
|
+
const keystore = program.command('keystore').description('Establish, inspect, re-key, and unlock the keystore.');
|
|
18
35
|
const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
|
|
19
36
|
|
|
20
37
|
keystore
|
|
@@ -51,22 +68,27 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
|
|
|
51
68
|
protection : options.dev ? 'none' : 'passphrase',
|
|
52
69
|
getPassphrase : (opts) => acquirePassphrase({ passphraseFile: g.passphraseFile, confirm: opts?.confirm, prompt: 'New keystore passphrase: ' }),
|
|
53
70
|
});
|
|
71
|
+
// A re-established keystore mints a new verifier (or none, for --dev), so any
|
|
72
|
+
// cached session now holds a passphrase for a keystore that no longer exists.
|
|
73
|
+
// Drop it rather than leave a stale plaintext passphrase behind (ADR 081).
|
|
74
|
+
clearSession(defaultSessionPath(g));
|
|
54
75
|
print({ action: 'keystore-init', data: { path, protection: options.dev ? 'dev' : 'encrypted' } });
|
|
55
76
|
});
|
|
56
77
|
|
|
57
78
|
keystore
|
|
58
79
|
.command('status')
|
|
59
|
-
.description('Show the keystore path, protection mode, and
|
|
80
|
+
.description('Show the keystore path, protection mode, key count, and session state. Never decrypts or prompts.')
|
|
60
81
|
.action(() => {
|
|
61
82
|
const g = globals();
|
|
62
83
|
// Diagnostic command: report status even when the config is malformed,
|
|
63
84
|
// rather than crashing on the config you ran this to inspect.
|
|
64
85
|
const path = resolveKeystorePath(g, { lenient: true });
|
|
65
86
|
const summary = keystoreSummary(path);
|
|
87
|
+
const session = readSessionStatus(defaultSessionPath(g), path, keystoreVerifierId(path));
|
|
66
88
|
if (summary.protection === 'dev' && !g.quiet && g.output !== 'json') {
|
|
67
89
|
process.stderr.write('warning: this is an UNENCRYPTED dev keystore; keys are stored in plaintext.\n');
|
|
68
90
|
}
|
|
69
|
-
print({ action: 'keystore-status', data: { path, ...summary } });
|
|
91
|
+
print({ action: 'keystore-status', data: { path, ...summary, session } });
|
|
70
92
|
});
|
|
71
93
|
|
|
72
94
|
keystore
|
|
@@ -93,6 +115,118 @@ export function registerKeystoreCommand(program: Command, globals: () => GlobalO
|
|
|
93
115
|
const oldPassphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Current keystore passphrase: ' });
|
|
94
116
|
const newPassphrase = acquirePassphrase({ forcePrompt: true, confirm: true, prompt: 'New keystore passphrase: ' });
|
|
95
117
|
const rekeyed = changeKeystorePassphrase(path, oldPassphrase, newPassphrase);
|
|
118
|
+
// The rotated verifier already invalidates a cached session by fingerprint,
|
|
119
|
+
// but the session file still holds the OLD passphrase in plaintext; delete it.
|
|
120
|
+
clearSession(defaultSessionPath(g));
|
|
96
121
|
print({ action: 'keystore-change-passphrase', data: { path, rekeyed } });
|
|
97
122
|
});
|
|
123
|
+
|
|
124
|
+
keystore
|
|
125
|
+
.command('unlock')
|
|
126
|
+
.description('Cache the keystore passphrase for a session so later commands do not re-prompt (ADR 081).')
|
|
127
|
+
.option('--ttl <duration>', `Session lifetime: bare seconds or an s/m/h suffix (default 1h, max 24h). Also $${ENV_KEYSTORE_TTL}.`)
|
|
128
|
+
.option('--allow-mainnet', 'Permit unlocking when the active network is mainnet (bitcoin); this suspends per-use passphrase auth for the session.', false)
|
|
129
|
+
.action((options: { ttl?: string; allowMainnet?: boolean }) => {
|
|
130
|
+
const g = globals();
|
|
131
|
+
const path = resolveKeystorePath(g);
|
|
132
|
+
const summary = keystoreSummary(path);
|
|
133
|
+
if (summary.protection === 'absent') {
|
|
134
|
+
throw new CLIError(`No keystore at ${path}. Run "btcr2 init" or "btcr2 keystore init" first.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
135
|
+
}
|
|
136
|
+
if (summary.protection === 'dev') {
|
|
137
|
+
throw new CLIError(
|
|
138
|
+
`The keystore at ${path} is an unencrypted dev keystore; it has no passphrase to cache, so no unlock is needed.`,
|
|
139
|
+
'INVALID_ARGUMENT_ERROR',
|
|
140
|
+
{ path },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!summary.established) {
|
|
144
|
+
throw new CLIError(
|
|
145
|
+
`The keystore at ${path} has no passphrase established yet. `
|
|
146
|
+
+ 'Establish one with "btcr2 keystore init" or the first "btcr2 key generate".',
|
|
147
|
+
'INVALID_ARGUMENT_ERROR',
|
|
148
|
+
{ path },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// An unlocked encrypted keystore signs prompt-free for the whole TTL,
|
|
152
|
+
// silently removing per-use passphrase auth. Two guards, both keyed to
|
|
153
|
+
// --allow-mainnet (ADR 081): this early refusal when the *configured* default
|
|
154
|
+
// network is mainnet (a clear signal before caching anything), plus the
|
|
155
|
+
// authoritative one at consumption, where the session records `allowMainnet`
|
|
156
|
+
// (below) and a `bitcoin` operation, whose network is derived from the DID
|
|
157
|
+
// rather than the config, is withheld from a session that lacks it. The
|
|
158
|
+
// active network defaults to a testnet, so this early refusal never fires
|
|
159
|
+
// for the demo.
|
|
160
|
+
if (!options.allowMainnet && resolveDefaultNetwork(g) === 'bitcoin') {
|
|
161
|
+
throw new CLIError(
|
|
162
|
+
`Refusing to unlock for a mainnet (bitcoin) context: caching the passphrase suspends per-use `
|
|
163
|
+
+ 'authentication for the session. Pass --allow-mainnet to override, or keep signing mainnet '
|
|
164
|
+
+ 'updates with a per-use passphrase prompt.',
|
|
165
|
+
'MAINNET_UNLOCK_REFUSED_ERROR',
|
|
166
|
+
{ path },
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const ttlMs = resolveSessionTtl(options.ttl);
|
|
170
|
+
// Acquire the passphrase directly (env / file / prompt) with NO session
|
|
171
|
+
// consultation and NO confirm, verify it against the keystore verifier, and
|
|
172
|
+
// only then cache it. A wrong passphrase writes no session file.
|
|
173
|
+
const passphrase = acquirePassphrase({ passphraseFile: g.passphraseFile, prompt: 'Keystore passphrase: ' });
|
|
174
|
+
if (!verifyKeystorePassphrase(path, passphrase)) {
|
|
175
|
+
throw new CLIError(`Incorrect passphrase for the keystore at ${path}; no session was created.`, 'DECRYPT_ERROR', { path });
|
|
176
|
+
}
|
|
177
|
+
const verifierId = keystoreVerifierId(path);
|
|
178
|
+
if (!verifierId) {
|
|
179
|
+
// An established keystore always carries a verifier; defensive guard.
|
|
180
|
+
throw new CLIError(`The keystore at ${path} has no verifier to bind a session to.`, 'INVALID_ARGUMENT_ERROR', { path });
|
|
181
|
+
}
|
|
182
|
+
const session = writeSession(defaultSessionPath(g), {
|
|
183
|
+
keystorePath : path,
|
|
184
|
+
verifierId,
|
|
185
|
+
passphrase,
|
|
186
|
+
ttlMs,
|
|
187
|
+
allowMainnet : !!options.allowMainnet,
|
|
188
|
+
});
|
|
189
|
+
print({ action: 'keystore-unlock', data: { keystore: path, expiresAt: session.expiresAt, ttlSeconds: session.ttlSeconds } });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
keystore
|
|
193
|
+
.command('lock')
|
|
194
|
+
.description('Revoke the cached session so later commands prompt for the passphrase again (ADR 081).')
|
|
195
|
+
.action(() => {
|
|
196
|
+
const g = globals();
|
|
197
|
+
// Resolve the session from the home only (defaultSessionPath never reads the
|
|
198
|
+
// config), so lock revokes even under a malformed config.
|
|
199
|
+
const sessionPath = defaultSessionPath(g);
|
|
200
|
+
const cleared = clearSession(sessionPath);
|
|
201
|
+
print({ action: 'keystore-lock', data: { path: sessionPath, cleared } });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolves the session TTL in milliseconds from the `--ttl` flag, then
|
|
207
|
+
* `$BTCR2_KEYSTORE_TTL`, then the one-hour default. Rejects a non-positive,
|
|
208
|
+
* malformed, or over-24h value with a {@link CLIError} that names the actual
|
|
209
|
+
* source (the flag or the env var) so the operator fixes the right input.
|
|
210
|
+
*/
|
|
211
|
+
function resolveSessionTtl(flag?: string): number {
|
|
212
|
+
const fromFlag = blankToUndef(flag);
|
|
213
|
+
const raw = fromFlag ?? blankToUndef(process.env[ENV_KEYSTORE_TTL]);
|
|
214
|
+
if (raw === undefined) return DEFAULT_SESSION_TTL_MS;
|
|
215
|
+
const source = fromFlag !== undefined ? '--ttl' : `$${ENV_KEYSTORE_TTL}`;
|
|
216
|
+
const ms = parseTtlToMs(raw);
|
|
217
|
+
if (ms === undefined || ms <= 0) {
|
|
218
|
+
throw new CLIError(
|
|
219
|
+
`Invalid ${source} "${raw}": expected seconds or a value with an s/m/h suffix, e.g. 3600, 45m, or 2h.`,
|
|
220
|
+
'INVALID_ARGUMENT_ERROR',
|
|
221
|
+
{ value: raw, source },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (ms > MAX_SESSION_TTL_MS) {
|
|
225
|
+
throw new CLIError(
|
|
226
|
+
`${source} "${raw}" exceeds the 24h maximum for a cached passphrase.`,
|
|
227
|
+
'INVALID_ARGUMENT_ERROR',
|
|
228
|
+
{ value: raw, source },
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return ms;
|
|
98
232
|
}
|
package/src/config.ts
CHANGED
|
@@ -7,10 +7,11 @@ import { dirname } from 'node:path';
|
|
|
7
7
|
import { CLIError } from './error.js';
|
|
8
8
|
import { ensureDir, writeFileAtomic } from './keystore/atomic.js';
|
|
9
9
|
import { FileBackedKeyManager } from './keystore/file-backed-key-manager.js';
|
|
10
|
-
import { keystoreProtection } from './keystore/file-key-store.js';
|
|
10
|
+
import { keystoreProtection, keystoreVerifierId } from './keystore/file-key-store.js';
|
|
11
11
|
import { defaultKeystorePath } from './keystore/paths.js';
|
|
12
12
|
import { acquirePassphrase } from './keystore/passphrase.js';
|
|
13
|
-
import {
|
|
13
|
+
import { readLiveSessionPassphrase } from './keystore/session.js';
|
|
14
|
+
import { defaultConfigPath, defaultSessionPath } from './paths.js';
|
|
14
15
|
import { blankToUndef, SUPPORTED_NETWORKS, type KeystoreProtectionLabel, type NetworkOption, type OutputFormat } from './types.js';
|
|
15
16
|
|
|
16
17
|
export { defaultConfigPath };
|
|
@@ -947,13 +948,34 @@ export function defaultApiFactory(network?: NetworkOption, overrides?: Connectio
|
|
|
947
948
|
* or opened. The persisted active-key pointer is re-applied (a non-decrypting
|
|
948
949
|
* existence check) so "the active key" survives across invocations.
|
|
949
950
|
*/
|
|
950
|
-
function buildKeystoreKms(overrides?: ConnectionOverrides): KeyManager {
|
|
951
|
+
function buildKeystoreKms(overrides?: ConnectionOverrides, network?: NetworkOption): KeyManager {
|
|
952
|
+
const keystorePath = resolveKeystorePath(overrides);
|
|
953
|
+
const sessionPath = defaultSessionPath(overrides);
|
|
954
|
+
// The network the operation will sign under, known here because the factory
|
|
955
|
+
// receives it. A `bitcoin` operation must not consume a session that was not
|
|
956
|
+
// unlocked with `--allow-mainnet`, so mainnet keeps per-use authentication even
|
|
957
|
+
// while a session is live (ADR 081). Key commands pass no network, so the
|
|
958
|
+
// session serves them as before.
|
|
959
|
+
const isMainnetOperation = network === 'bitcoin';
|
|
951
960
|
return new FileBackedKeyManager({
|
|
952
|
-
path :
|
|
961
|
+
path : keystorePath,
|
|
953
962
|
// The store decides when to confirm: it passes `confirm: true` only while
|
|
954
963
|
// establishing a fresh keystore's passphrase, so a first-key typo is caught
|
|
955
964
|
// by a second entry (ADR 080). confirm is a no-op for env/file sources.
|
|
956
|
-
|
|
965
|
+
//
|
|
966
|
+
// On the non-establishing path, a cached session (ADR 081) is consulted below
|
|
967
|
+
// the env var / --passphrase-file and above the interactive prompt. It is
|
|
968
|
+
// wired ONLY when not confirming, so establishment never consults the session
|
|
969
|
+
// and a first passphrase is always entered fresh and twice. The session is
|
|
970
|
+
// bound to this keystore's verifier, so a rotated passphrase invalidates it.
|
|
971
|
+
getPassphrase : (opts) => acquirePassphrase({
|
|
972
|
+
passphraseFile : overrides?.passphraseFile,
|
|
973
|
+
confirm : opts?.confirm,
|
|
974
|
+
...(opts?.confirm ? {} : {
|
|
975
|
+
beforePrompt : (): string | undefined =>
|
|
976
|
+
readLiveSessionPassphrase(sessionPath, keystorePath, keystoreVerifierId(keystorePath), isMainnetOperation),
|
|
977
|
+
}),
|
|
978
|
+
}),
|
|
957
979
|
});
|
|
958
980
|
}
|
|
959
981
|
|
|
@@ -1048,7 +1070,7 @@ export function resolveSigningKeyRef(overrides?: ConnectionOverrides): string |
|
|
|
1048
1070
|
export function keystoreApiFactory(network?: NetworkOption, overrides?: ConnectionOverrides): DidBtcr2Api {
|
|
1049
1071
|
return createApi({
|
|
1050
1072
|
...resolveConnectionConfig(network, overrides),
|
|
1051
|
-
kms : buildKeystoreKms(overrides),
|
|
1073
|
+
kms : buildKeystoreKms(overrides, network),
|
|
1052
1074
|
});
|
|
1053
1075
|
}
|
|
1054
1076
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import type { KeyEntry, KeyIdentifier, KeyValueStore } from '@did-btcr2/key-manager';
|
|
4
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
4
5
|
import { utf8ToBytes } from '@noble/hashes/utils.js';
|
|
5
6
|
import { base64urlnopad } from '@scure/base';
|
|
6
7
|
import type { KeystoreProtectionLabel } from '../types.js';
|
|
@@ -664,6 +665,48 @@ export function keystoreProtection(path: string): KeystoreProtectionLabel {
|
|
|
664
665
|
return keystoreSummary(path).protection;
|
|
665
666
|
}
|
|
666
667
|
|
|
668
|
+
/**
|
|
669
|
+
* A stable fingerprint of a keystore's passphrase verifier, or `undefined` when
|
|
670
|
+
* the file is absent, unparsable, or carries no verifier. Compared by equality
|
|
671
|
+
* to detect a rotated passphrase (`change-passphrase`, `init --force`) or a
|
|
672
|
+
* re-established keystore, so a cached session (ADR 081) stops matching a
|
|
673
|
+
* keystore whose passphrase has changed. Never decrypts, never throws.
|
|
674
|
+
*/
|
|
675
|
+
export function keystoreVerifierId(path: string): string | undefined {
|
|
676
|
+
if (!existsSync(path)) return undefined;
|
|
677
|
+
let parsed: KeystoreFile;
|
|
678
|
+
try {
|
|
679
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8')) as KeystoreFile;
|
|
680
|
+
} catch {
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
if (!parsed.verifier) return undefined;
|
|
684
|
+
return base64urlnopad.encode(sha256(utf8ToBytes(JSON.stringify(parsed.verifier))));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Checks a candidate passphrase against the keystore's verifier without
|
|
689
|
+
* constructing a store or opening any key. Returns `false` for an absent,
|
|
690
|
+
* unparsable, dev, or verifier-less keystore and for a wrong passphrase; `true`
|
|
691
|
+
* only when the passphrase opens the verifier sentinel. Never throws. Used by
|
|
692
|
+
* `keystore unlock` (ADR 081) to refuse caching a wrong passphrase.
|
|
693
|
+
*/
|
|
694
|
+
export function verifyKeystorePassphrase(path: string, passphrase: string): boolean {
|
|
695
|
+
if (!existsSync(path)) return false;
|
|
696
|
+
let parsed: KeystoreFile;
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8')) as KeystoreFile;
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
if (parsed.protection !== 'passphrase' || !parsed.verifier) return false;
|
|
703
|
+
try {
|
|
704
|
+
return bytesEqual(decryptSecret(parsed.verifier, passphrase), VERIFIER_PLAINTEXT);
|
|
705
|
+
} catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
667
710
|
/** Options for {@link initKeystore}. */
|
|
668
711
|
export interface InitKeystoreOptions {
|
|
669
712
|
protection : KeystoreProtection;
|
|
@@ -19,6 +19,16 @@ export type PassphraseOptions = {
|
|
|
19
19
|
* satisfy the new one (which would make the change a no-op).
|
|
20
20
|
*/
|
|
21
21
|
forcePrompt?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* An optional non-interactive source consulted *after* the env var and
|
|
24
|
+
* passphrase file and *before* the terminal prompt (and before the "no TTY"
|
|
25
|
+
* failure). The session unlock agent (ADR 081) wires this to a cached
|
|
26
|
+
* passphrase, so a returning command consumes the session instead of
|
|
27
|
+
* prompting, and a non-interactive follow-on command does not hard-fail. It
|
|
28
|
+
* returns `undefined` when no session is available. Skipped when `forcePrompt`
|
|
29
|
+
* is set, and never wired during passphrase establishment (`confirm`).
|
|
30
|
+
*/
|
|
31
|
+
beforePrompt?: () => string | undefined;
|
|
22
32
|
};
|
|
23
33
|
|
|
24
34
|
/**
|
|
@@ -39,6 +49,19 @@ export function acquirePassphrase(options: PassphraseOptions = {}): string {
|
|
|
39
49
|
if (options.passphraseFile) {
|
|
40
50
|
return assertNonEmpty(readFileSync(options.passphraseFile, 'utf-8').replace(/\r?\n$/, ''));
|
|
41
51
|
}
|
|
52
|
+
|
|
53
|
+
// A cached session (ADR 081) sits below the env var and file but above the
|
|
54
|
+
// interactive prompt, so it is consulted before the "no TTY" failure: a
|
|
55
|
+
// scripted or piped follow-on command consumes the session instead of
|
|
56
|
+
// hard-failing. Establishment never reaches here (its caller omits it).
|
|
57
|
+
//
|
|
58
|
+
// The session already holds the exact, keystore-verified passphrase (encoded
|
|
59
|
+
// and decoded byte-for-byte), not a raw source needing newline normalization.
|
|
60
|
+
// Return it verbatim: re-stripping a trailing newline here would corrupt the
|
|
61
|
+
// KDF input for a passphrase that legitimately ends in one, even though unlock
|
|
62
|
+
// itself succeeded. assertNonEmpty is a defensive guard only.
|
|
63
|
+
const fromSession = options.beforePrompt?.();
|
|
64
|
+
if (fromSession) return assertNonEmpty(fromSession);
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
if (!process.stdin.isTTY) {
|