Delete the superseded plaintext seed pair, move the test double to its test

163af256c853 · AtlantisPleb · · parent 7dc6c22a94a0

Delete the superseded plaintext seed pair, move the test double to its test

`inMemoryKeyStore`, `readSeedPhrase`, and `writeSeedPhrase` in
`packages/openagents-cli/src/seed-identity.ts` were flagged by
`uncalled-production-symbol-guard`: their only caller was their own test.
Scope item 1 of #126. The three are not the same case.

`readSeedPhrase` and `writeSeedPhrase` were one-line wrappers over the live
encrypted-envelope API that #98 introduced. `writeSeedPhrase(p, ks)` was
exactly `storeSeedPhrase(p, ks).path`, and `readSeedPhrase(ks)` was
`loadSeed(ks)`'s phrase. Both are deleted.

The legacy-plaintext question #126 asked to settle first is settled and
nothing is lost with them: `loadSeed` reads a pre-#98 plaintext seed itself
(`if (!looksSealed(text)) return { phrase: normalize(text), protection:
"plaintext_file" }`), and `protectSeed` migrates it under the keychain. The
deleted pair added no migration path of its own.

`inMemoryKeyStore` is a test double that was living in `src`. Deleting it
would have deleted real coverage of live code, so it moves verbatim into
`test/seed-identity.test.ts` instead. Six sites use it to exercise
`storeSeedPhrase`, `loadSeed`, and `protectSeed`, including the
sealed-without-key and wrong-key error paths.

The 20 call sites of the deleted pair are rewritten onto the live API rather
than removed, so their coverage survives: 14 `readSeedPhrase` to
`loadSeed(...)?.phrase`, 6 `writeSeedPhrase` to `storeSeedPhrase(...).path`.
No test is deleted; the file holds 20 tests before and after, all passing.
The two assertions that read `undefined` still do: `loadSeed` returns
`undefined` for both an absent and an empty seed file. The `toThrow`
assertions still name `loadSeed` and `storeSeedPhrase`, which is where those
throws always came from.

Also fixes the two now-dangling `{@link readSeedPhrase}` references in the
module header and in `loadSeed`'s own doc comment.

Nothing is added to `uncalled-production-symbol-baseline.json`: the owner
asked for deletion, not suppression.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/seed-identity.ts
  • modified packages/openagents-cli/test/seed-identity.test.ts

Diff

2 files changed, +44 -62

packages/openagents-cli/src/seed-identity.ts modified +3 -39

@@ -23,7 +23,7 @@

23 23
 * `@noble/hashes` 1.7.1, `@scure/bip32` 1.6.2, `@scure/bip39` 1.5.4.
24 24
 *
25 25
 * SECRETS. This module returns the mnemonic from exactly one function,
26
 * {@link readSeedPhrase}, and derives from it in memory. It never logs and never
26
 * {@link loadSeed}, and derives from it in memory. It never logs and never
27 27
 * returns an `nsec` or a raw private key. The public manifest
28 28
 * {@link SeedIdentity} carries public identifiers only and is safe to print,
29 29
 * store, and export.

@@ -507,25 +507,6 @@ export const noKeyStore: SeedKeyStore = {

507 507
  delete: () => {},
508 508
};
509 509
510
/**
511
 * A keychain that lives for the length of one test, so the seal, open, and
512
 * migration paths are exercised for real without writing to the developer's own
513
 * keychain or depending on one existing.
514
 */
515
export const inMemoryKeyStore = (): SeedKeyStore => {
516
  let held: Uint8Array | undefined;
517
  return {
518
    available: () => true,
519
    get: () => held,
520
    put: (key) => {
521
      held = Uint8Array.from(key);
522
    },
523
    delete: () => {
524
      held = undefined;
525
    },
526
  };
527
};
528
529 510
/** True when the environment asks for the plaintext store. */
530 511
const plaintextRequested = (): boolean => {
531 512
  const value = (process.env[PLAINTEXT_ENV] ?? "").trim().toLowerCase();

@@ -574,8 +555,8 @@ export const seedProtectionOnDisk = (): SeedProtection | undefined => {

574 555
};
575 556
576 557
/**
577
 * Read the stored seed and report what was protecting it. This and
578
 * {@link readSeedPhrase} are the only functions that return secret material.
558
 * Read the stored seed and report what was protecting it. It is the only
559
 * function in this module that returns secret material.
579 560
 */
580 561
export const loadSeed = (
581 562
  keyStore: SeedKeyStore = defaultSeedKeyStore(),

@@ -599,17 +580,6 @@ export const loadSeed = (

599 580
  return { phrase: openEnvelope(text, key, path), protection: "os_keychain" };
600 581
};
601 582
602
/**
603
 * Read the stored mnemonic. Every caller either derives from it or hands it to
604
 * the reader who asked for a backup. Returns `undefined` when no seed is stored.
605
 */
606
export const readSeedPhrase = (
607
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
608
): string | undefined => {
609
  const stored = loadSeed(keyStore);
610
  return stored === undefined || stored.phrase.length === 0 ? undefined : stored.phrase;
611
};
612
613 583
const writeAtomic = (body: string): string => {
614 584
  const directory = seedDirectory();
615 585
  mkdirSync(directory, { recursive: true, mode: 0o700 });

@@ -662,12 +632,6 @@ export const storeSeedPhrase = (

662 632
  return { path: writeAtomic(`${sealPhrase(normalized, key)}\n`), protection };
663 633
};
664 634
665
/** {@link storeSeedPhrase}, for callers that only need the path. */
666
export const writeSeedPhrase = (
667
  phrase: string,
668
  keyStore: SeedKeyStore = defaultSeedKeyStore(),
669
): string => storeSeedPhrase(phrase, keyStore).path;
670
671 635
/**
672 636
 * Move a plaintext seed under the OS keychain, and report what is protecting it
673 637
 * afterwards. `undefined` when nothing is stored.
packages/openagents-cli/test/seed-identity.test.ts modified +41 -23

@@ -42,18 +42,15 @@ import {

42 42
  generateSeedPhrase,
43 43
  identityKeychainCommandFor,
44 44
  IDENTITY_KEYCHAIN_SERVICE,
45
  inMemoryKeyStore,
46 45
  isValidSeedPhrase,
47 46
  loadSeed,
48 47
  noKeyStore,
49 48
  protectSeed,
50
  readSeedPhrase,
51 49
  seedEncryptedAtRest,
52 50
  seedPath,
53 51
  seedPresent,
54 52
  seedProtectionOnDisk,
55 53
  storeSeedPhrase,
56
  writeSeedPhrase,
57 54
  type SeedKeyStore,
58 55
} from "../src/seed-identity.js";
59 56

@@ -76,6 +73,27 @@ const FROZEN = {

76 73
  walletDerivationPath: "m/44'/0'/0'/0/0",
77 74
} as const;
78 75
76
/**
77
 * A keychain that lives for the length of one test, so the seal, open, and
78
 * migration paths are exercised for real without writing to the developer's own
79
 * keychain or depending on one existing. It lives here rather than in `src`
80
 * because production has no use for it: the real stores are the OS keychain and
81
 * the plaintext file.
82
 */
83
const inMemoryKeyStore = (): SeedKeyStore => {
84
  let held: Uint8Array | undefined;
85
  return {
86
    available: () => true,
87
    get: () => held,
88
    put: (key) => {
89
      held = Uint8Array.from(key);
90
    },
91
    delete: () => {
92
      held = undefined;
93
    },
94
  };
95
};
96
79 97
const isolatedIdentityDirectory = () => {
80 98
  const directory = mkdtempSync(join(tmpdir(), "openagents-identity-"));
81 99
  process.env["OPENAGENTS_IDENTITY_DIR"] = directory;

@@ -139,35 +157,35 @@ describe("seed storage", () => {

139 157
  it("writes the phrase 0600 and reads it back unchanged", () => {
140 158
    isolatedIdentityDirectory();
141 159
    expect(seedPresent()).toBe(false);
142
    const path = writeSeedPhrase(`  ${TEST_PHRASE}  `, keys);
160
    const path = storeSeedPhrase(`  ${TEST_PHRASE}  `, keys).path;
143 161
    expect(path).toBe(seedPath());
144 162
    expect(seedPresent()).toBe(true);
145 163
    expect(statSync(path).mode & 0o777).toBe(0o600);
146
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
147
    expect(deriveSeedIdentity(readSeedPhrase(keys) ?? "").npub).toBe(FROZEN.npub);
164
    expect(loadSeed(keys)?.phrase).toBe(TEST_PHRASE);
165
    expect(deriveSeedIdentity(loadSeed(keys)?.phrase ?? "").npub).toBe(FROZEN.npub);
148 166
  });
149 167
150 168
  it("restores 0600 when the file on disk was left readable", () => {
151 169
    isolatedIdentityDirectory();
152
    const path = writeSeedPhrase(TEST_PHRASE, keys);
170
    const path = storeSeedPhrase(TEST_PHRASE, keys).path;
153 171
    chmodSync(path, 0o644);
154
    writeSeedPhrase(TEST_PHRASE, keys);
172
    storeSeedPhrase(TEST_PHRASE, keys);
155 173
    expect(statSync(path).mode & 0o777).toBe(0o600);
156 174
  });
157 175
158 176
  it("writes nothing when the phrase is not a valid mnemonic", () => {
159 177
    isolatedIdentityDirectory();
160
    expect(() => writeSeedPhrase("not a seed phrase at all", keys)).toThrow(/valid English BIP-39/);
178
    expect(() => storeSeedPhrase("not a seed phrase at all", keys)).toThrow(/valid English BIP-39/);
161 179
    expect(seedPresent()).toBe(false);
162 180
  });
163 181
164 182
  it("reports no seed for an absent or empty file, and forgets idempotently", () => {
165 183
    const directory = isolatedIdentityDirectory();
166
    expect(readSeedPhrase(keys)).toBeUndefined();
184
    expect(loadSeed(keys)?.phrase).toBeUndefined();
167 185
    expect(forgetSeedPhrase(keys)).toBe(false);
168 186
    writeFileSync(join(directory, "seed"), "   \n", { mode: 0o600 });
169
    expect(readSeedPhrase(keys)).toBeUndefined();
170
    writeSeedPhrase(TEST_PHRASE, keys);
187
    expect(loadSeed(keys)?.phrase).toBeUndefined();
188
    storeSeedPhrase(TEST_PHRASE, keys);
171 189
    expect(keys.get()).toBeDefined();
172 190
    expect(forgetSeedPhrase(keys)).toBe(true);
173 191
    expect(seedPresent()).toBe(false);

@@ -179,7 +197,7 @@ describe("seed storage", () => {

179 197
180 198
  it("keeps the seed out of the derived identity", () => {
181 199
    isolatedIdentityDirectory();
182
    writeSeedPhrase(TEST_PHRASE, keys);
200
    storeSeedPhrase(TEST_PHRASE, keys);
183 201
    const identity = deriveSeedIdentity(TEST_PHRASE);
184 202
    expect(JSON.stringify(identity)).not.toContain("abandon");
185 203
    expect(JSON.stringify(identity)).not.toContain("nsec");

@@ -213,7 +231,7 @@ describe("seed protection at rest", () => {

213 231
    // The wrapping key is not in the identity directory. If it were, the file
214 232
    // and the key would travel together and the encryption would be theatre.
215 233
    expect(readdirSync(directory)).toEqual(["seed"]);
216
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
234
    expect(loadSeed(keys)?.phrase).toBe(TEST_PHRASE);
217 235
  });
218 236
219 237
  it("uses a fresh nonce for every seal", () => {

@@ -225,7 +243,7 @@ describe("seed protection at rest", () => {

225 243
    const second = readFileSync(path, "utf8");
226 244
227 245
    expect(second).not.toBe(first);
228
    expect(readSeedPhrase(keys)).toBe(TEST_PHRASE);
246
    expect(loadSeed(keys)?.phrase).toBe(TEST_PHRASE);
229 247
  });
230 248
231 249
  /**

@@ -239,9 +257,9 @@ describe("seed protection at rest", () => {

239 257
    keys.delete();
240 258
241 259
    expect(seedPresent()).toBe(true);
242
    expect(() => readSeedPhrase(keys)).toThrow(/encrypted/);
260
    expect(() => loadSeed(keys)).toThrow(/encrypted/);
243 261
    try {
244
      readSeedPhrase(keys);
262
      loadSeed(keys);
245 263
    } catch (cause) {
246 264
      expect(String(cause)).not.toContain("abandon");
247 265
    }

@@ -253,7 +271,7 @@ describe("seed protection at rest", () => {

253 271
    storeSeedPhrase(TEST_PHRASE, keys);
254 272
    keys.put(new Uint8Array(32).fill(7));
255 273
256
    expect(() => readSeedPhrase(keys)).toThrow(/does not open it/);
274
    expect(() => loadSeed(keys)).toThrow(/does not open it/);
257 275
  });
258 276
259 277
  /**

@@ -268,7 +286,7 @@ describe("seed protection at rest", () => {

268 286
    expect(protection).toBe("plaintext_file");
269 287
    expect(seedEncryptedAtRest(protection)).toBe(false);
270 288
    expect(readFileSync(path, "utf8")).toContain(TEST_PHRASE);
271
    expect(readSeedPhrase(noKeyStore)).toBe(TEST_PHRASE);
289
    expect(loadSeed(noKeyStore)?.phrase).toBe(TEST_PHRASE);
272 290
    expect(seedProtectionOnDisk()).toBe("plaintext_file");
273 291
274 292
    // The sentence a person sees must name the file and say what is not covered.

@@ -303,7 +321,7 @@ describe("seed protection at rest", () => {

303 321
    expect(protectSeed(keys)).toBe("os_keychain");
304 322
305 323
    // The identity did not move.
306
    const after = deriveSeedIdentity(readSeedPhrase(keys) ?? "");
324
    const after = deriveSeedIdentity(loadSeed(keys)?.phrase ?? "");
307 325
    expect(after).toEqual(before);
308 326
    expect(after.npub).toBe(FROZEN.npub);
309 327

@@ -328,7 +346,7 @@ describe("seed protection at rest", () => {

328 346
329 347
    expect(protectSeed(noKeyStore)).toBe("plaintext_file");
330 348
    expect(readFileSync(path, "utf8")).toContain(TEST_PHRASE);
331
    expect(readSeedPhrase(noKeyStore)).toBe(TEST_PHRASE);
349
    expect(loadSeed(noKeyStore)?.phrase).toBe(TEST_PHRASE);
332 350
  });
333 351
334 352
  /**

@@ -347,8 +365,8 @@ describe("seed protection at rest", () => {

347 365
348 366
    const reader = inMemoryKeyStore();
349 367
    reader.put(key);
350
    expect(readSeedPhrase(reader)).toBe(TEST_PHRASE);
351
    expect(deriveSeedIdentity(readSeedPhrase(reader) ?? "").npub).toBe(FROZEN.npub);
368
    expect(loadSeed(reader)?.phrase).toBe(TEST_PHRASE);
369
    expect(deriveSeedIdentity(loadSeed(reader)?.phrase ?? "").npub).toBe(FROZEN.npub);
352 370
  });
353 371
354 372
  /**

This page updates live while a promote is in flight · changelog