@did-btcr2/cli 0.15.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.
Files changed (76) hide show
  1. package/README.md +47 -6
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +836 -122
  4. package/dist/esm/src/cli.js +7 -4
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/config.js +11 -15
  7. package/dist/esm/src/commands/config.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +4 -1
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/deactivate.js +3 -1
  11. package/dist/esm/src/commands/deactivate.js.map +1 -1
  12. package/dist/esm/src/commands/index.js +2 -0
  13. package/dist/esm/src/commands/index.js.map +1 -1
  14. package/dist/esm/src/commands/init.js +69 -0
  15. package/dist/esm/src/commands/init.js.map +1 -0
  16. package/dist/esm/src/commands/keystore.js +180 -0
  17. package/dist/esm/src/commands/keystore.js.map +1 -0
  18. package/dist/esm/src/commands/profile.js +1 -1
  19. package/dist/esm/src/commands/profile.js.map +1 -1
  20. package/dist/esm/src/commands/update.js +3 -1
  21. package/dist/esm/src/commands/update.js.map +1 -1
  22. package/dist/esm/src/config.js +109 -31
  23. package/dist/esm/src/config.js.map +1 -1
  24. package/dist/esm/src/keystore/file-key-store.js +388 -32
  25. package/dist/esm/src/keystore/file-key-store.js.map +1 -1
  26. package/dist/esm/src/keystore/passphrase.js +53 -10
  27. package/dist/esm/src/keystore/passphrase.js.map +1 -1
  28. package/dist/esm/src/keystore/paths.js +6 -18
  29. package/dist/esm/src/keystore/paths.js.map +1 -1
  30. package/dist/esm/src/keystore/session.js +250 -0
  31. package/dist/esm/src/keystore/session.js.map +1 -0
  32. package/dist/esm/src/paths.js +71 -0
  33. package/dist/esm/src/paths.js.map +1 -0
  34. package/dist/esm/src/types.js.map +1 -1
  35. package/dist/types/src/cli.d.ts.map +1 -1
  36. package/dist/types/src/commands/config.d.ts.map +1 -1
  37. package/dist/types/src/commands/create.d.ts.map +1 -1
  38. package/dist/types/src/commands/deactivate.d.ts.map +1 -1
  39. package/dist/types/src/commands/index.d.ts +2 -0
  40. package/dist/types/src/commands/index.d.ts.map +1 -1
  41. package/dist/types/src/commands/init.d.ts +13 -0
  42. package/dist/types/src/commands/init.d.ts.map +1 -0
  43. package/dist/types/src/commands/keystore.d.ts +11 -0
  44. package/dist/types/src/commands/keystore.d.ts.map +1 -0
  45. package/dist/types/src/commands/update.d.ts.map +1 -1
  46. package/dist/types/src/config.d.ts +38 -14
  47. package/dist/types/src/config.d.ts.map +1 -1
  48. package/dist/types/src/keystore/file-key-store.d.ts +102 -10
  49. package/dist/types/src/keystore/file-key-store.d.ts.map +1 -1
  50. package/dist/types/src/keystore/passphrase.d.ts +26 -1
  51. package/dist/types/src/keystore/passphrase.d.ts.map +1 -1
  52. package/dist/types/src/keystore/paths.d.ts +6 -10
  53. package/dist/types/src/keystore/paths.d.ts.map +1 -1
  54. package/dist/types/src/keystore/session.d.ts +105 -0
  55. package/dist/types/src/keystore/session.d.ts.map +1 -0
  56. package/dist/types/src/paths.d.ts +64 -0
  57. package/dist/types/src/paths.d.ts.map +1 -0
  58. package/dist/types/src/types.d.ts +53 -0
  59. package/dist/types/src/types.d.ts.map +1 -1
  60. package/package.json +3 -3
  61. package/src/cli.ts +8 -3
  62. package/src/commands/config.ts +12 -14
  63. package/src/commands/create.ts +4 -1
  64. package/src/commands/deactivate.ts +3 -1
  65. package/src/commands/index.ts +2 -0
  66. package/src/commands/init.ts +80 -0
  67. package/src/commands/keystore.ts +232 -0
  68. package/src/commands/profile.ts +1 -1
  69. package/src/commands/update.ts +3 -1
  70. package/src/config.ts +118 -35
  71. package/src/keystore/file-key-store.ts +498 -43
  72. package/src/keystore/passphrase.ts +71 -8
  73. package/src/keystore/paths.ts +6 -19
  74. package/src/keystore/session.ts +303 -0
  75. package/src/paths.ts +92 -0
  76. package/src/types.ts +16 -1
@@ -1,7 +1,10 @@
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';
5
+ import { utf8ToBytes } from '@noble/hashes/utils.js';
4
6
  import { base64urlnopad } from '@scure/base';
7
+ import type { KeystoreProtectionLabel } from '../types.js';
5
8
  import { assertSecurePerms, ensureDir, writeFileAtomic } from './atomic.js';
6
9
  import { DEFAULT_ARGON_PARAMS, decryptSecret, encryptSecret } from './envelope.js';
7
10
  import type { ArgonParams, SecretEnvelope } from './envelope.js';
@@ -12,18 +15,56 @@ import { defaultKeystorePath } from './paths.js';
12
15
  /** Current on-disk keystore file format version. */
13
16
  export const KEYSTORE_VERSION = 1 as const;
14
17
 
15
- /** One key as stored on disk: public material in clear, secret sealed (or absent for watch-only). */
18
+ /**
19
+ * How a keystore protects its secrets on disk:
20
+ * - `passphrase`: each secret is sealed in its own argon2id + XChaCha20-Poly1305
21
+ * envelope, all opened by one shared, verifier-checked passphrase.
22
+ * - `none`: a dev keystore; secrets are stored as plaintext bytes. Never prompts;
23
+ * refused for mainnet (ADR 080). For disposable testnet material only.
24
+ */
25
+ export type KeystoreProtection = 'passphrase' | 'none';
26
+
27
+ /**
28
+ * Fixed sentinel sealed under the keystore passphrase and stored as the file's
29
+ * `verifier`. Decrypting it checks a candidate passphrase before any real secret
30
+ * is sealed or opened, so a typo fails loudly instead of corrupting the store.
31
+ */
32
+ const VERIFIER_PLAINTEXT = utf8ToBytes('did-btcr2-keystore-verifier-v1');
33
+
34
+ /** Constant-length-independent byte compare for the verifier sentinel. */
35
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
36
+ if (a.length !== b.length) return false;
37
+ let diff = 0;
38
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
39
+ return diff === 0;
40
+ }
41
+
42
+ /** Whether two secret envelopes are byte-identical (structural JSON compare). */
43
+ function sameEnvelope(a: SecretEnvelope | undefined, b: SecretEnvelope | undefined): boolean {
44
+ return JSON.stringify(a) === JSON.stringify(b);
45
+ }
46
+
47
+ /**
48
+ * One key as stored on disk: public material in clear, and the secret either
49
+ * sealed (`secret`, encrypted keystore), stored in the clear (`plainSecret`, dev
50
+ * keystore), or absent (watch-only).
51
+ */
16
52
  type StoredKey = {
17
- publicKey : string;
18
- tags? : Record<string, string>;
19
- secret? : SecretEnvelope;
53
+ publicKey : string;
54
+ tags? : Record<string, string>;
55
+ secret? : SecretEnvelope;
56
+ plainSecret? : string;
20
57
  };
21
58
 
22
59
  /** The whole keystore file. */
23
60
  type KeystoreFile = {
24
- v : typeof KEYSTORE_VERSION;
25
- active? : string;
26
- keys : Record<string, StoredKey>;
61
+ v : typeof KEYSTORE_VERSION;
62
+ /** Protection mode, present on every keystore this CLI writes: `passphrase` (encrypted) or `none` (dev). */
63
+ protection? : KeystoreProtection;
64
+ /** Passphrase verifier, present once an encrypted keystore's passphrase is established. */
65
+ verifier? : SecretEnvelope;
66
+ active? : string;
67
+ keys : Record<string, StoredKey>;
27
68
  };
28
69
 
29
70
  /** One key in the in-memory cache; the materialized secret is retained per session once decrypted. */
@@ -31,6 +72,8 @@ type CacheEntry = {
31
72
  publicKey : Uint8Array;
32
73
  tags? : Record<string, string>;
33
74
  secret? : SecretEnvelope;
75
+ /** True for a dev-keystore entry whose `decrypted` bytes are stored plaintext, not sealed. */
76
+ plaintext? : boolean;
34
77
  decrypted? : Uint8Array;
35
78
  };
36
79
 
@@ -38,28 +81,45 @@ type CacheEntry = {
38
81
  export type FileKeyStoreOptions = {
39
82
  /** Keystore file path. Defaults to {@link defaultKeystorePath}. */
40
83
  path?: string;
41
- /** Supplies the passphrase lazily, called only when a secret must be sealed or opened. */
42
- getPassphrase: () => string;
84
+ /**
85
+ * Supplies the passphrase lazily, called only when a secret must be sealed or
86
+ * opened. `confirm` is passed as `true` only while establishing a fresh
87
+ * encrypted keystore's passphrase, so the provider prompts twice and requires
88
+ * a match; it is a no-op for non-interactive sources.
89
+ */
90
+ getPassphrase: (opts?: { confirm?: boolean }) => string;
43
91
  /** argon2id cost parameters used when sealing new secrets. Defaults to {@link DEFAULT_ARGON_PARAMS}. */
44
92
  argonParams?: ArgonParams;
45
93
  /** Tuning for the cross-process write lock. Defaults documented on {@link LockOptions}. */
46
94
  lock?: LockOptions;
95
+ /**
96
+ * Protection mode to use when *establishing* a fresh keystore. Defaults to
97
+ * `passphrase` (encrypted). Ignored for an existing keystore, whose on-disk
98
+ * `protection` always wins.
99
+ */
100
+ protection?: KeystoreProtection;
47
101
  };
48
102
 
49
103
  /**
50
- * A Node-only, file-backed {@link KeyValueStore} that encrypts secret keys at
104
+ * A Node-only, file-backed {@link KeyValueStore} that protects secret keys at
51
105
  * rest. It satisfies the synchronous store contract by caching the parsed file
52
106
  * in memory at construction and flushing the whole file atomically on every
53
107
  * mutation.
54
108
  *
109
+ * Encrypted keystores seal each secret in its own argon2id + XChaCha20-Poly1305
110
+ * envelope under one shared passphrase, and store a `verifier` sentinel so a
111
+ * candidate passphrase is checked before it is used (ADR 080): the first
112
+ * passphrase is established with a confirm prompt, and every later use is
113
+ * verified, so a typo is a loud failure rather than a key sealed under an
114
+ * unknown or divergent passphrase. Dev keystores (`protection: 'none'`) store
115
+ * secrets as plaintext and never prompt; they are refused for mainnet by the
116
+ * command layer.
117
+ *
55
118
  * Every mutation runs under an exclusive cross-process lock and, inside that
56
- * lock, reloads the file from disk before applying its change and flushing.
57
- * Caching at construction and flushing the whole file is otherwise a lost-update
58
- * race: two `btcr2` processes that each load, then each write, would have the
59
- * second overwrite the first. The lock serializes the writers and the reload
60
- * merges any change the other made, so concurrent invocations compose instead of
61
- * clobbering. Reads stay lock-free: an atomic rename means a concurrent reader
62
- * always sees a complete file, old or new.
119
+ * lock, reloads the file from disk before applying its change and flushing, so
120
+ * concurrent `btcr2` invocations compose instead of clobbering. Reads stay
121
+ * lock-free: an atomic rename means a concurrent reader always sees a complete
122
+ * file, old or new.
63
123
  *
64
124
  * Secrets are materialized only through {@link FileKeyStore.get}. The
65
125
  * {@link FileKeyStore.list} and {@link FileKeyStore.entries} projections omit
@@ -70,10 +130,12 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
70
130
  readonly #path: string;
71
131
  readonly #lockPath: string;
72
132
  readonly #lockOptions: LockOptions;
73
- readonly #getPassphrase: () => string;
133
+ readonly #getPassphrase: (opts?: { confirm?: boolean }) => string;
74
134
  readonly #argonParams: ArgonParams;
75
135
  readonly #cache: Map<KeyIdentifier, CacheEntry> = new Map();
76
136
  #active: string | undefined;
137
+ #protection: KeystoreProtection;
138
+ #verifier: SecretEnvelope | undefined;
77
139
 
78
140
  constructor(options: FileKeyStoreOptions) {
79
141
  this.#path = options.path ?? defaultKeystorePath();
@@ -81,6 +143,9 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
81
143
  this.#lockOptions = options.lock ?? {};
82
144
  this.#getPassphrase = options.getPassphrase;
83
145
  this.#argonParams = options.argonParams ?? DEFAULT_ARGON_PARAMS;
146
+ // The requested mode applies only when establishing a fresh keystore; an
147
+ // existing file's on-disk protection overrides it in #loadFromDisk.
148
+ this.#protection = options.protection ?? 'passphrase';
84
149
  ensureDir(dirname(this.#path), 0o700);
85
150
  this.#loadFromDisk();
86
151
  }
@@ -105,7 +170,34 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
105
170
  { version: parsed.v },
106
171
  );
107
172
  }
173
+ // Every keystore this CLI writes carries a recognized protection header. A
174
+ // file without one was not written by this CLI (there is no pre-header
175
+ // format to accommodate); refuse it rather than guess how its secrets are
176
+ // protected.
177
+ if (parsed.protection !== 'none' && parsed.protection !== 'passphrase') {
178
+ throw new KeyStoreError(
179
+ `Keystore at ${this.#path} has no recognized protection header; it was not written by this CLI.`,
180
+ 'KEYSTORE_CORRUPT_ERROR',
181
+ { path: this.#path },
182
+ );
183
+ }
184
+ this.#protection = parsed.protection;
185
+ this.#verifier = parsed.verifier;
108
186
  this.#active = parsed.active;
187
+ // An encrypted keystore that holds sealed keys must carry the verifier that
188
+ // established its passphrase. Without it, the verify-or-establish decision in
189
+ // #sealPassphrase could take the establish path over existing sealed keys and
190
+ // seal a new key under a divergent passphrase; refuse the file instead.
191
+ const sealedPresent = Object.values(parsed.keys ?? {}).some(
192
+ k => k && typeof k === 'object' && (k as StoredKey).secret !== undefined,
193
+ );
194
+ if (this.#protection === 'passphrase' && sealedPresent && this.#verifier === undefined) {
195
+ throw new KeyStoreError(
196
+ `Keystore at ${this.#path} holds sealed keys but no passphrase verifier; it is corrupt or was tampered with.`,
197
+ 'KEYSTORE_CORRUPT_ERROR',
198
+ { path: this.#path },
199
+ );
200
+ }
109
201
  for (const [ id, stored ] of Object.entries(parsed.keys ?? {})) {
110
202
  let publicKey: Uint8Array;
111
203
  try {
@@ -125,25 +217,75 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
125
217
  { path: this.#path, keyId: id },
126
218
  );
127
219
  }
128
- this.#cache.set(id, {
129
- publicKey,
130
- ...(stored.tags && { tags: stored.tags }),
131
- ...(stored.secret && { secret: stored.secret }),
132
- });
220
+ const entry: CacheEntry = { publicKey, ...(stored.tags && { tags: stored.tags }) };
221
+ // A secret's storage form must match the keystore's protection mode: a dev
222
+ // keystore holds plaintext, an encrypted keystore holds sealed envelopes. A
223
+ // mismatch is a tampered or foreign file, not something to open blindly.
224
+ if (stored.plainSecret !== undefined) {
225
+ if (this.#protection !== 'none') {
226
+ throw new KeyStoreError(
227
+ `Keystore entry ${id} holds a plaintext secret in an encrypted keystore at ${this.#path}.`,
228
+ 'KEYSTORE_CORRUPT_ERROR',
229
+ { path: this.#path, keyId: id },
230
+ );
231
+ }
232
+ entry.plaintext = true;
233
+ entry.decrypted = this.#decodePlainSecret(stored.plainSecret, id);
234
+ } else if (stored.secret) {
235
+ if (this.#protection !== 'passphrase') {
236
+ throw new KeyStoreError(
237
+ `Keystore entry ${id} holds a sealed secret in a dev keystore at ${this.#path}.`,
238
+ 'KEYSTORE_CORRUPT_ERROR',
239
+ { path: this.#path, keyId: id },
240
+ );
241
+ }
242
+ entry.secret = stored.secret;
243
+ }
244
+ this.#cache.set(id, entry);
245
+ }
246
+ }
247
+
248
+ /** Decodes and length-checks a dev-keystore plaintext secret. */
249
+ #decodePlainSecret(encoded: string, id: KeyIdentifier): Uint8Array {
250
+ let secret: Uint8Array;
251
+ try {
252
+ secret = base64urlnopad.decode(encoded);
253
+ } catch {
254
+ throw new KeyStoreError(
255
+ `Keystore entry ${id} has a malformed plaintext secret.`,
256
+ 'KEYSTORE_CORRUPT_ERROR',
257
+ { path: this.#path, keyId: id },
258
+ );
259
+ }
260
+ if (secret.length !== 32) {
261
+ throw new KeyStoreError(
262
+ `Keystore entry ${id} has a ${secret.length}-byte secret; expected 32.`,
263
+ 'KEYSTORE_CORRUPT_ERROR',
264
+ { path: this.#path, keyId: id },
265
+ );
133
266
  }
267
+ return secret;
134
268
  }
135
269
 
136
270
  #flush(): void {
137
271
  const keys: Record<string, StoredKey> = {};
138
272
  for (const [ id, entry ] of this.#cache) {
139
- keys[id] = {
140
- publicKey : base64urlnopad.encode(entry.publicKey),
141
- ...(entry.tags && { tags: entry.tags }),
142
- ...(entry.secret && { secret: entry.secret }),
143
- };
273
+ const stored: StoredKey = { publicKey: base64urlnopad.encode(entry.publicKey) };
274
+ if (entry.tags) stored.tags = entry.tags;
275
+ if (entry.plaintext && entry.decrypted) {
276
+ stored.plainSecret = base64urlnopad.encode(entry.decrypted);
277
+ } else if (entry.secret) {
278
+ stored.secret = entry.secret;
279
+ }
280
+ keys[id] = stored;
144
281
  }
282
+ // Every keystore this CLI writes self-describes: the protection header is
283
+ // always present (`passphrase` for encrypted, `none` for dev), so a file is
284
+ // never ambiguous about how its secrets are protected.
145
285
  const file: KeystoreFile = {
146
- v : KEYSTORE_VERSION,
286
+ v : KEYSTORE_VERSION,
287
+ protection : this.#protection,
288
+ ...(this.#verifier && { verifier: this.#verifier }),
147
289
  ...(this.#active && { active: this.#active }),
148
290
  keys,
149
291
  };
@@ -155,7 +297,8 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
155
297
  * mutation applies on top of whatever other processes have written. Secrets
156
298
  * already decrypted this session are carried over for entries whose sealed
157
299
  * envelope is byte-identical on disk, so a mid-session write does not force a
158
- * re-prompt for keys it did not touch.
300
+ * re-prompt for keys it did not touch. (Dev-keystore plaintext secrets are
301
+ * reloaded from disk, so they need no carry.)
159
302
  */
160
303
  #reload(): void {
161
304
  const carried = new Map<KeyIdentifier, { secret: SecretEnvelope; decrypted: Uint8Array }>();
@@ -188,6 +331,56 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
188
331
  }, this.#lockOptions);
189
332
  }
190
333
 
334
+ /**
335
+ * Verifies a candidate passphrase against the keystore verifier, throwing when
336
+ * it does not open the sentinel. A no-op only on a fresh keystore whose
337
+ * passphrase has not been established yet (no verifier to check against).
338
+ */
339
+ #assertPassphrase(passphrase: string): void {
340
+ if (!this.#verifier) return;
341
+ let plain: Uint8Array;
342
+ try {
343
+ plain = decryptSecret(this.#verifier, passphrase);
344
+ } catch {
345
+ throw new KeyStoreError(
346
+ `Incorrect passphrase for the keystore at ${this.#path}.`,
347
+ 'DECRYPT_ERROR',
348
+ { path: this.#path },
349
+ );
350
+ }
351
+ if (!bytesEqual(plain, VERIFIER_PLAINTEXT)) {
352
+ throw new KeyStoreError(
353
+ `Keystore verifier at ${this.#path} did not match; the file may be corrupt.`,
354
+ 'KEYSTORE_CORRUPT_ERROR',
355
+ { path: this.#path },
356
+ );
357
+ }
358
+ }
359
+
360
+ /** Passphrase for opening an existing secret: verified when a verifier exists. */
361
+ #openPassphrase(): string {
362
+ const passphrase = this.#getPassphrase();
363
+ this.#assertPassphrase(passphrase);
364
+ return passphrase;
365
+ }
366
+
367
+ /**
368
+ * Passphrase for sealing a secret. When the keystore's passphrase is already
369
+ * established (a verifier exists), prompt once and verify it. Otherwise this is
370
+ * establishment on a fresh keystore: prompt with confirm and mint a verifier for
371
+ * the caller to persist alongside the first sealed key.
372
+ */
373
+ #sealPassphrase(): { passphrase: string; newVerifier?: SecretEnvelope } {
374
+ if (this.#verifier) {
375
+ const passphrase = this.#getPassphrase();
376
+ this.#assertPassphrase(passphrase);
377
+ return { passphrase };
378
+ }
379
+ const passphrase = this.#getPassphrase({ confirm: true });
380
+ const newVerifier = encryptSecret(VERIFIER_PLAINTEXT, passphrase, this.#argonParams);
381
+ return { passphrase, newVerifier };
382
+ }
383
+
191
384
  get(id: KeyIdentifier): KeyEntry | undefined {
192
385
  const entry = this.#cache.get(id);
193
386
  if (!entry) return undefined;
@@ -195,18 +388,25 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
195
388
  publicKey : entry.publicKey,
196
389
  ...(entry.tags && { tags: entry.tags }),
197
390
  };
198
- if (entry.secret) {
199
- // Materialize the secret lazily, only when it is actually accessed, so
200
- // reads that need just public material (an active-key existence check,
201
- // getPublicKey, getEntry) never trigger a passphrase prompt. The property
202
- // is non-enumerable so spreading or serializing the entry cannot silently
203
- // decrypt the secret.
391
+ if (entry.plaintext && entry.decrypted) {
392
+ // Dev keystore: plaintext already materialized; expose without a prompt.
393
+ const secret = entry.decrypted;
394
+ Object.defineProperty(result, 'secretKey', {
395
+ configurable : true,
396
+ enumerable : false,
397
+ get : (): Uint8Array => secret,
398
+ });
399
+ } else if (entry.secret) {
400
+ // Materialize the sealed secret lazily, only when it is actually accessed,
401
+ // so reads that need just public material never trigger a passphrase
402
+ // prompt. The property is non-enumerable so spreading or serializing the
403
+ // entry cannot silently decrypt the secret.
204
404
  const sealed = entry.secret;
205
405
  Object.defineProperty(result, 'secretKey', {
206
406
  configurable : true,
207
407
  enumerable : false,
208
408
  get : (): Uint8Array => {
209
- entry.decrypted ??= decryptSecret(sealed, this.#getPassphrase());
409
+ entry.decrypted ??= decryptSecret(sealed, this.#openPassphrase());
210
410
  return entry.decrypted;
211
411
  },
212
412
  });
@@ -219,16 +419,59 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
219
419
  }
220
420
 
221
421
  set(id: KeyIdentifier, value: KeyEntry): void {
222
- // Seal the secret before taking the lock: argon2id is deliberately slow and
223
- // must not extend the critical section that blocks other processes.
224
- const secret = value.secretKey
225
- ? encryptSecret(value.secretKey, this.#getPassphrase(), this.#argonParams)
226
- : undefined;
422
+ if (this.#protection === 'none') {
423
+ // Dev keystore: store the secret in the clear, never prompt.
424
+ this.#mutate(() => {
425
+ this.#cache.set(id, {
426
+ publicKey : value.publicKey,
427
+ ...(value.tags && { tags: value.tags }),
428
+ ...(value.secretKey && { plaintext: true, decrypted: value.secretKey }),
429
+ });
430
+ });
431
+ return;
432
+ }
433
+
434
+ // Encrypted keystore. Resolve (and, if establishing, confirm) the passphrase
435
+ // and seal the secret before taking the lock: argon2id is deliberately slow
436
+ // and must not extend the critical section that blocks other processes.
437
+ const verifierAtSeal = this.#verifier;
438
+ let sealed: SecretEnvelope | undefined;
439
+ let newVerifier: SecretEnvelope | undefined;
440
+ let passphrase: string | undefined;
441
+ if (value.secretKey) {
442
+ const resolved = this.#sealPassphrase();
443
+ passphrase = resolved.passphrase;
444
+ newVerifier = resolved.newVerifier;
445
+ sealed = encryptSecret(value.secretKey, passphrase, this.#argonParams);
446
+ }
227
447
  this.#mutate(() => {
448
+ // A secret sealed outside the lock must never be persisted under a
449
+ // passphrase that diverges from the keystore verifier (the key-loss class
450
+ // ADR 080 exists to prevent). Reconcile our seal-time view with whatever the
451
+ // reload sees before committing.
452
+ if (value.secretKey) {
453
+ if (this.#verifier === undefined && newVerifier !== undefined) {
454
+ // Establishing, and no concurrent writer beat us to it: record our verifier.
455
+ this.#verifier = newVerifier;
456
+ } else if (this.#verifier !== undefined && passphrase !== undefined) {
457
+ // A verifier exists: it pre-existed, was established concurrently while we
458
+ // sealed, or was rotated by a concurrent change-passphrase. If it was
459
+ // rotated out from under our seal, abort with a clear message; otherwise
460
+ // assert our passphrase still opens it before persisting the key.
461
+ if (verifierAtSeal !== undefined && !sameEnvelope(this.#verifier, verifierAtSeal)) {
462
+ throw new KeyStoreError(
463
+ `The keystore passphrase at ${this.#path} changed concurrently; re-run the command.`,
464
+ 'KEYSTORE_CONCURRENT_CHANGE_ERROR',
465
+ { path: this.#path },
466
+ );
467
+ }
468
+ this.#assertPassphrase(passphrase);
469
+ }
470
+ }
228
471
  this.#cache.set(id, {
229
472
  publicKey : value.publicKey,
230
473
  ...(value.tags && { tags: value.tags }),
231
- ...(secret && { secret }),
474
+ ...(sealed && { secret: sealed }),
232
475
  ...(value.secretKey && { decrypted: value.secretKey }),
233
476
  });
234
477
  });
@@ -302,4 +545,216 @@ export class FileKeyStore implements KeyValueStore<KeyIdentifier, KeyEntry> {
302
545
  this.#active = id;
303
546
  });
304
547
  }
548
+
549
+ /** The keystore's protection mode. */
550
+ get protection(): KeystoreProtection {
551
+ return this.#protection;
552
+ }
553
+
554
+ /**
555
+ * Re-seals every sealed secret and the verifier under a new passphrase (ADR
556
+ * 080), returning the number of secrets re-sealed. Verifies the current
557
+ * passphrase against the verifier first. Refused on a dev keystore, which has
558
+ * no passphrase.
559
+ */
560
+ changePassphrase(oldPassphrase: string, newPassphrase: string): number {
561
+ if (this.#protection === 'none') {
562
+ throw new KeyStoreError(
563
+ `The keystore at ${this.#path} is an unencrypted dev keystore; it has no passphrase to change.`,
564
+ 'KEYSTORE_PROTECTION_ERROR',
565
+ { path: this.#path },
566
+ );
567
+ }
568
+ // Fail a wrong current passphrase up front (when a verifier exists) so a typo
569
+ // does not pay for a full re-seal before failing.
570
+ if (this.#verifier) this.#assertPassphrase(oldPassphrase);
571
+ // Precompute every re-seal and the new verifier BEFORE taking the lock:
572
+ // argon2id is deliberately slow, and holding the exclusive lock across many
573
+ // derivations could exceed the lock's stale threshold and let a concurrent
574
+ // process break a still-live lock. Each re-seal records the source envelope
575
+ // it derived from, so the locked section can abort on any concurrent change
576
+ // instead of corrupting. This follows the same do-expensive-work-before-the-
577
+ // lock contract as set().
578
+ const resealed = new Map<KeyIdentifier, { from: SecretEnvelope; to: SecretEnvelope; plain: Uint8Array }>();
579
+ for (const [ id, entry ] of this.#cache) {
580
+ if (!entry.secret) continue;
581
+ let plain: Uint8Array;
582
+ try {
583
+ plain = decryptSecret(entry.secret, oldPassphrase);
584
+ } catch {
585
+ throw new KeyStoreError(
586
+ `Incorrect current passphrase for the keystore at ${this.#path}.`,
587
+ 'DECRYPT_ERROR',
588
+ { path: this.#path },
589
+ );
590
+ }
591
+ resealed.set(id, { from: entry.secret, to: encryptSecret(plain, newPassphrase, this.#argonParams), plain });
592
+ }
593
+ const newVerifier = encryptSecret(VERIFIER_PLAINTEXT, newPassphrase, this.#argonParams);
594
+ let rekeyed = 0;
595
+ this.#mutate(() => {
596
+ // The reload reflects any concurrent writer. Apply a precomputed re-seal
597
+ // only where the on-disk envelope still matches what it was derived from;
598
+ // abort if any sealed key changed or a new sealed key appeared that was not
599
+ // re-sealed, rather than leave a key under the old passphrase.
600
+ for (const [ id, entry ] of this.#cache) {
601
+ if (!entry.secret) continue;
602
+ const pre = resealed.get(id);
603
+ if (!pre || !sameEnvelope(pre.from, entry.secret)) {
604
+ throw new KeyStoreError(
605
+ `The keystore at ${this.#path} changed while its passphrase was being changed; re-run the command.`,
606
+ 'KEYSTORE_CONCURRENT_CHANGE_ERROR',
607
+ { path: this.#path },
608
+ );
609
+ }
610
+ entry.secret = pre.to;
611
+ entry.decrypted = pre.plain;
612
+ rekeyed++;
613
+ }
614
+ this.#verifier = newVerifier;
615
+ });
616
+ return rekeyed;
617
+ }
618
+ }
619
+
620
+ /** A no-decrypt, no-prompt summary of a keystore file for `keystore status`. */
621
+ export interface KeystoreSummary {
622
+ protection : KeystoreProtectionLabel;
623
+ established : boolean;
624
+ keyCount : number;
625
+ active : string | undefined;
626
+ }
627
+
628
+ /**
629
+ * Summarizes a keystore file by structure alone: protection mode, whether a
630
+ * passphrase is established, key count, and active key. Never decrypts, never
631
+ * prompts, and never throws (a missing or unreadable file reports `absent`), so
632
+ * it is safe for `keystore status`, `config path`, and the mainnet dev-keystore
633
+ * guard.
634
+ */
635
+ export function keystoreSummary(path: string): KeystoreSummary {
636
+ const absent: KeystoreSummary = { protection: 'absent', established: false, keyCount: 0, active: undefined };
637
+ if (!existsSync(path)) return absent;
638
+ let parsed: KeystoreFile;
639
+ try {
640
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as KeystoreFile;
641
+ } catch {
642
+ return absent;
643
+ }
644
+ const keys = (parsed.keys && typeof parsed.keys === 'object') ? parsed.keys : {};
645
+ const keyCount = Object.keys(keys).length;
646
+ const active = typeof parsed.active === 'string' ? parsed.active : undefined;
647
+
648
+ if (parsed.protection === 'none') {
649
+ return { protection: 'dev', established: true, keyCount, active };
650
+ }
651
+ if (parsed.protection === 'passphrase') {
652
+ // An encrypted keystore is "established" once its passphrase verifier exists
653
+ // (written by `init` or the first key-seal). Without it the passphrase has not
654
+ // been set yet: a freshly-created, still-empty encrypted keystore.
655
+ return { protection: 'encrypted', established: parsed.verifier !== undefined, keyCount, active };
656
+ }
657
+ // No recognized protection header: not a keystore this CLI wrote. Report absent
658
+ // (never throw) so `keystore status` stays a safe, no-decrypt introspection; an
659
+ // actual open of such a file is refused by FileKeyStore.
660
+ return absent;
661
+ }
662
+
663
+ /** The protection label of a keystore file, without decrypting or prompting. */
664
+ export function keystoreProtection(path: string): KeystoreProtectionLabel {
665
+ return keystoreSummary(path).protection;
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
+
710
+ /** Options for {@link initKeystore}. */
711
+ export interface InitKeystoreOptions {
712
+ protection : KeystoreProtection;
713
+ getPassphrase : (opts?: { confirm?: boolean }) => string;
714
+ argonParams? : ArgonParams;
715
+ }
716
+
717
+ /**
718
+ * Establishes a fresh keystore file (ADR 080). An encrypted keystore prompts
719
+ * (with confirm) for the passphrase and writes the verifier; a dev keystore
720
+ * writes a plaintext-mode header with no passphrase. The caller is responsible
721
+ * for refusing to overwrite an existing keystore; this always writes the file.
722
+ */
723
+ export function initKeystore(path: string, options: InitKeystoreOptions): void {
724
+ ensureDir(dirname(path), 0o700);
725
+ let file: KeystoreFile;
726
+ if (options.protection === 'none') {
727
+ file = { v: KEYSTORE_VERSION, protection: 'none', keys: {} };
728
+ } else {
729
+ const passphrase = options.getPassphrase({ confirm: true });
730
+ const verifier = encryptSecret(VERIFIER_PLAINTEXT, passphrase, options.argonParams ?? DEFAULT_ARGON_PARAMS);
731
+ file = { v: KEYSTORE_VERSION, protection: 'passphrase', verifier, keys: {} };
732
+ }
733
+ writeFileAtomic(path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
734
+ }
735
+
736
+ /**
737
+ * Re-seals every secret in an encrypted keystore under a new passphrase (ADR
738
+ * 080), returning the count re-sealed. The old and new passphrases are supplied
739
+ * explicitly, so the store's own passphrase provider is never invoked (a wrong
740
+ * current passphrase is caught by the verifier). Refused on a dev keystore.
741
+ */
742
+ export function changeKeystorePassphrase(
743
+ path : string,
744
+ oldPassphrase : string,
745
+ newPassphrase : string,
746
+ argonParams? : ArgonParams,
747
+ ): number {
748
+ const store = new FileKeyStore({
749
+ path,
750
+ ...(argonParams && { argonParams }),
751
+ getPassphrase : () => {
752
+ throw new KeyStoreError(
753
+ `Unexpected passphrase prompt while changing the passphrase for ${path}.`,
754
+ 'KEYSTORE_INTERNAL_ERROR',
755
+ { path },
756
+ );
757
+ },
758
+ });
759
+ return store.changePassphrase(oldPassphrase, newPassphrase);
305
760
  }