Files
SeedPass/js/packages/cli/test/nostr.test.ts
T
thePR0M3TH3ANandClaude Fable 5 55879eaa1b security: durable vault writes, path validation, and safe restore semantics
Continues the security-review fixes.

Durability and concurrency:
- Vault, config, fingerprint-registry and parent-seed writes are now atomic:
  temp file in the same directory, fsync, rename, then an fsync of the
  directory. Previously the target was truncated and rewritten in place, so
  a crash could destroy the vault.
- The lock now spans the whole read-modify-write cycle, not just the write.
  Serializing writes alone still lost changes -- two commands each read the
  same index, mutated their copy, and wrote in turn. Every mutating command
  goes through mutateVault(), which holds the lock from open to save. A test
  runs five concurrent writers and asserts all five entries survive.
- 0600 is now enforced on existing files too: writeFile's mode argument only
  applies on creation, so a loosened vault stayed loosened.
- The index is schema-validated before it can reach disk.

Path handling:
- Profile fingerprints are validated (16 uppercase hex) before being used in
  any path, and removeProfile additionally checks the resolved directory
  still sits directly under the app directory. A tampered registry could
  otherwise have steered a recursive delete elsewhere.
- Malformed entries in fingerprints.json are dropped on read.

Restore semantics:
- `nostr restore` merges remote state into the local vault instead of
  replacing it. Replacing silently discarded entries created since the last
  sync; that now requires --replace --yes, and any overwritten vault is
  copied aside first.
- Deltas are bound to the manifest they extend (filtered on the e tag, and
  re-checked locally), so a relay cannot replay a validly-signed delta from
  another snapshot lineage into a restore.
- Manifest selection breaks created_at ties by relay order, so a same-second
  republish cannot leave a restore on a stale snapshot.
- mergeIndexPayloads gained an index0 option. The default still refuses to
  merge populated Python atlas state rather than drop it; restore uses
  preserve-current, keeping the local block verbatim, because Python
  recomputes canonical views on load and the default would block every
  restore into a Python-created profile.

193 core + 69 CLI tests green; 36/36 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 23:10:20 -04:00

130 lines
5.2 KiB
TypeScript

/**
* End-to-end CLI relay sync: create a profile, add entries, sync to an
* in-process relay, destroy the local vault, restore it from the relay.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { mnemonics, nostrKeyCases } from "@seedpass/test-vectors";
import { generateFingerprint } from "@seedpass/core";
import { buildProgram, type ProgramIo } from "../src/index.js";
import { MockRelay } from "../../core/test/mockRelay.js";
const MNEMONIC = mnemonics["abandon12"]!;
const FINGERPRINT = generateFingerprint(MNEMONIC);
let appDir: string;
let relay: MockRelay;
async function run(...argv: string[]): Promise<{ stdout: string; error?: unknown }> {
const out: string[] = [];
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
let error: unknown;
try {
await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]);
} catch (e) {
error = e;
}
return { stdout: out.join("\n"), error };
}
beforeAll(async () => {
appDir = await mkdtemp(join(tmpdir(), "seedpass-nostr-"));
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
process.env["SEEDPASS_PASSWORD"] = "nostr-test-pw";
relay = new MockRelay();
await relay.start();
await run("fingerprint", "add", "--name", "sync-test");
});
afterAll(async () => {
await relay.stop();
delete process.env["SEEDPASS_PASSWORD"];
});
describe("nostr CLI", () => {
it("get-pubkey matches the app-1237 fixture npub", async () => {
const fixture = nostrKeyCases.find(
(c) => c.mnemonic_id === "abandon12" && c.account_index === 0,
)!;
const r = await run("nostr", "get-pubkey");
expect(r.stdout).toBe(fixture.npub);
});
it("manages the relay list in config", async () => {
await run("nostr", "add-relay", relay.url);
const list = JSON.parse((await run("nostr", "list-relays")).stdout) as string[];
expect(list).toContain(relay.url);
// Keep only the mock relay so sync never touches the network
while (true) {
const current = JSON.parse((await run("nostr", "list-relays")).stdout) as string[];
const removable = current.findIndex((u) => u !== relay.url);
if (removable === -1) break;
await run("nostr", "remove-relay", String(removable + 1));
}
const final = JSON.parse((await run("nostr", "list-relays")).stdout) as string[];
expect(final).toEqual([relay.url]);
const bad = await run("nostr", "add-relay", "https://not-a-relay");
expect(String((bad.error as Error).message)).toContain("ws://");
});
it("merges rather than discarding unsynced local entries", async () => {
// Publish a snapshot, then create a local entry that the snapshot does
// not contain. A restore must not silently throw that entry away.
await run("entry", "add", "key-value", "pre-sync", "k", "in-snapshot");
await run("nostr", "sync", "--chunk-limit", "400");
await run("entry", "add", "key-value", "local-only", "k", "not-in-snapshot");
const restored = JSON.parse((await run("nostr", "restore")).stdout);
expect(restored.mode).toBe("merged");
expect(restored.local_backup).toBeTruthy();
const labels = (JSON.parse((await run("entry", "list")).stdout) as { label: string }[]).map(
(r) => r.label,
);
expect(labels).toContain("local-only");
expect(labels).toContain("pre-sync");
expect((await run("entry", "reveal", "local-only")).stdout).toBe("not-in-snapshot");
});
it("requires explicit confirmation to discard local state", async () => {
const denied = await run("nostr", "restore", "--replace");
expect(String((denied.error as Error).message)).toContain("--yes");
});
it("syncs the vault to the relay and restores after local destruction", async () => {
await run("entry", "add", "key-value", "synced-secret", "k", "value-roundtrip");
await run("entry", "add", "password", "synced-site", "--length", "20");
const sync = await run("nostr", "sync", "--chunk-limit", "300");
const syncRow = JSON.parse(sync.stdout);
expect(syncRow.manifest_id).toMatch(/^[0-9a-f]{64}$/);
expect(syncRow.chunk_event_ids.length).toBeGreaterThan(0);
expect(relay.events.some((e) => e.kind === 30070)).toBe(true);
// Destroy the local vault
const vaultPath = join(appDir, FINGERPRINT, "seedpass_entries_db.json.enc");
await writeFile(vaultPath, "garbage");
const broken = await run("entry", "list");
expect(broken.error).toBeTruthy();
// Restore from the relay alone
const restore = await run("nostr", "restore");
const restored = JSON.parse(restore.stdout);
// The local vault was unreadable, so this is a recovery, not a merge
expect(restored.mode).toBe("restored");
expect(restored.deltas_applied).toBe(0);
const list = JSON.parse((await run("entry", "list")).stdout) as { label: string }[];
expect(list.map((e) => e.label)).toContain("synced-secret");
expect(list.map((e) => e.label)).toContain("synced-site");
const reveal = await run("entry", "reveal", "synced-secret");
expect(reveal.stdout).toBe("value-roundtrip");
});
});