mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
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]>
130 lines
5.2 KiB
TypeScript
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");
|
|
});
|
|
});
|