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]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 23:10:20 -04:00
1 parent 9926b48e16
commit 55879eaa1b
9 files changed
+519 -115

No files matched your search

+43 -13
View File
@@ -8,10 +8,11 @@
* ~/.seedpass/<FP>/seedpass_config.json.enc index key
*/
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
import { mkdir, readFile, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { dirname, join, resolve } from "node:path";
import { atomicWrite } from "./vaultFile.js";
import process from "node:process";
import { base64url } from "@scure/base";
import {
@@ -55,6 +56,25 @@ export function resolveAppDir(override?: string): string {
return override ?? process.env["SEEDPASS_APP_DIR"] ?? join(homedir(), ".seedpass");
}
/**
* Fingerprints are 16 uppercase hex characters (SHA-256 of the seed,
* truncated). Validate before any fingerprint reaches a filesystem path:
* these values come from a JSON registry on disk, and a tampered entry
* containing `..` would otherwise steer a recursive delete out of the
* profile directory.
*/
const FINGERPRINT_RE = /^[0-9A-F]{16}$/;
export function assertValidFingerprint(fingerprint: string): string {
if (!FINGERPRINT_RE.test(fingerprint)) {
throw new Error(
`invalid profile fingerprint ${JSON.stringify(fingerprint)} ` +
`(expected 16 uppercase hex characters)`,
);
}
return fingerprint;
}
export class AppDir {
constructor(public readonly root: string) {}
@@ -63,17 +83,20 @@ export class AppDir {
}
profileDir(fingerprint: string): string {
return join(this.root, fingerprint);
return join(this.root, assertValidFingerprint(fingerprint));
}
async readFingerprints(): Promise<FingerprintsFile> {
try {
const raw = JSON.parse(await readFile(this.fingerprintsPath, "utf8")) as Partial<FingerprintsFile>;
return {
fingerprints: raw.fingerprints ?? [],
last_used: raw.last_used ?? null,
names: raw.names ?? {},
};
const fingerprints = (raw.fingerprints ?? []).filter((f) =>
FINGERPRINT_RE.test(String(f)),
);
const lastUsed =
raw.last_used && FINGERPRINT_RE.test(String(raw.last_used))
? String(raw.last_used)
: null;
return { fingerprints, last_used: lastUsed, names: raw.names ?? {} };
} catch {
return { fingerprints: [], last_used: null, names: {} };
}
@@ -82,7 +105,7 @@ export class AppDir {
async writeFingerprints(data: FingerprintsFile): Promise<void> {
await mkdir(this.root, { recursive: true });
// indent=4 matches the Python writer
await writeFile(this.fingerprintsPath, JSON.stringify(data, null, 4), { mode: 0o600 });
await atomicWrite(this.fingerprintsPath, utf8(JSON.stringify(data, null, 4)));
}
/** Create a profile from a mnemonic; returns its fingerprint. */
@@ -118,15 +141,14 @@ export class AppDir {
kdf,
ct: Buffer.from(ct).toString("base64"),
});
await writeFile(join(dir, PARENT_SEED_FILENAME), wrapper, { mode: 0o600 });
await atomicWrite(join(dir, PARENT_SEED_FILENAME), utf8(wrapper));
// empty entries index under the index key
const indexKey = deriveIndexKeyBytes(mnemonic);
const emptyIndex = { schema_version: 4, entries: {} };
await writeFile(
await atomicWrite(
join(dir, INDEX_FILENAME),
await encryptV3(indexKey, utf8(JSON.stringify(emptyIndex))),
{ mode: 0o600 },
);
data.fingerprints.push(fingerprint);
@@ -137,11 +159,18 @@ export class AppDir {
}
async removeProfile(fingerprint: string): Promise<void> {
assertValidFingerprint(fingerprint);
const data = await this.readFingerprints();
if (!data.fingerprints.includes(fingerprint)) {
throw new Error(`no profile ${fingerprint}`);
}
await rm(this.profileDir(fingerprint), { recursive: true, force: true });
const dir = this.profileDir(fingerprint);
// Belt and braces: the resolved path must still sit directly under the
// app directory before anything is deleted recursively.
if (dirname(resolve(dir)) !== resolve(this.root)) {
throw new Error(`refusing to delete ${dir}: outside ${this.root}`);
}
await rm(dir, { recursive: true, force: true });
data.fingerprints = data.fingerprints.filter((f) => f !== fingerprint);
delete data.names[fingerprint];
if (data.last_used === fingerprint) {
@@ -151,6 +180,7 @@ export class AppDir {
}
async switchProfile(fingerprint: string): Promise<void> {
assertValidFingerprint(fingerprint);
const data = await this.readFingerprints();
if (!data.fingerprints.includes(fingerprint)) {
throw new Error(`no profile ${fingerprint}`);
+3 -2
View File
@@ -3,7 +3,7 @@
* (seedpass_config.json.enc, encrypted under the index key).
*/
import { readFile, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
@@ -14,6 +14,7 @@ import {
utf8,
} from "@seedpass/core";
import { CONFIG_FILENAME } from "./appDir.js";
import { atomicWrite } from "./vaultFile.js";
export const DEFAULT_RELAYS = [
"wss://relay.snort.social",
@@ -69,5 +70,5 @@ export async function saveConfig(
): Promise<void> {
const key = deriveIndexKeyBytes(mnemonic);
const payload = await encryptV3(key, utf8(JSON.stringify(config)));
await writeFile(join(profileDir, CONFIG_FILENAME), payload, { mode: 0o600 });
await atomicWrite(join(profileDir, CONFIG_FILENAME), payload);
}
+162 -87
View File
@@ -13,7 +13,14 @@ import { entryMetadata, parseRef, resolveEntry } from "./refs.js";
import { materializeSecret } from "./secrets.js";
import { clipboardSink, execSink, parseCommandSpec, stdinSink } from "./sinks.js";
import { capabilities } from "./capabilities.js";
import { openVault, saveVault, type OpenedVault } from "./vaultFile.js";
import {
openVault,
saveVault,
saveVaultHoldingLock,
withVaultLock,
atomicWrite,
type OpenedVault,
} from "./vaultFile.js";
import {
importBackup,
exportBackup,
@@ -176,6 +183,30 @@ async function openFromOptions(opts: GlobalOpts): Promise<OpenedVault> {
return openVault(path, await resolveMnemonic(app, opts));
}
/**
* Run a mutation under the vault lock, holding it for the whole
* read-modify-write cycle.
*
* Locking only the write still loses changes: two commands can each read the
* same index, mutate their copy, and write in turn. Everything that changes
* the vault must go through here.
*/
async function mutateVault<T>(
opts: GlobalOpts,
fn: (vault: OpenedVault) => Promise<T> | T,
): Promise<T> {
const app = new AppDir(resolveAppDir(opts.appDir));
const path =
opts.vault ?? join(app.profileDir(await currentFingerprint(app, opts)), INDEX_FILENAME);
const mnemonic = await resolveMnemonic(app, opts);
return withVaultLock(path, async () => {
const vault = await openVault(path, mnemonic);
const result = await fn(vault);
await saveVaultHoldingLock(vault);
return result;
});
}
/**
* Read/secret access that works in two modes:
* - owner mode (mnemonic via env or agent): full local access
@@ -366,10 +397,15 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
return cmd;
};
async function finishAdd(vault: OpenedVault, id: string): Promise<void> {
await saveVault(vault);
const e = vault.index.entries[id]!;
io.out(JSON.stringify(entryMetadata(id, e), null, 2));
/** Create an entry under the lock and print its reference + metadata. */
async function addEntry(
create: (vault: OpenedVault) => string,
): Promise<void> {
const row = await mutateVault(program.opts(), (vault) => {
const id = create(vault);
return entryMetadata(id, vault.index.entries[id]!);
});
io.out(JSON.stringify(row, null, 2));
}
const commonOpts = (o: { notes?: string; tags?: string[]; archived?: boolean }) => ({
@@ -390,13 +426,11 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
label: string,
o: { length: string; username?: string; url?: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const id = addPasswordEntry(vault.index, label, parseIntOption(o.length, "--length", { min: 8, max: 128 }), {
await addEntry((vault) => addPasswordEntry(vault.index, label, parseIntOption(o.length, "--length", { min: 8, max: 128 }), {
...commonOpts(o),
...(o.username !== undefined && { username: o.username }),
...(o.url !== undefined && { url: o.url }),
});
await finishAdd(vault, id);
}));
},
);
@@ -412,16 +446,16 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
label: string,
o: { secret?: string; period: string; digits: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const opts = {
...commonOpts(o),
period: parseIntOption(o.period, "--period", { min: 1 }),
digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }),
};
const id = o.secret
? addTotpImported(vault.index, label, o.secret, opts)
: addTotpDeterministic(vault.index, label, vault.mnemonic, opts);
await finishAdd(vault, id);
await addEntry((vault) =>
o.secret
? addTotpImported(vault.index, label, o.secret, opts)
: addTotpDeterministic(vault.index, label, vault.mnemonic, opts),
);
},
);
@@ -436,9 +470,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
value: string,
o: { notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const id = addKeyValueEntry(vault.index, label, key, value, commonOpts(o));
await finishAdd(vault, id);
await addEntry((vault) => addKeyValueEntry(vault.index, label, key, value, commonOpts(o)));
},
);
@@ -453,12 +485,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
content: string,
o: { fileType: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const id = addDocumentEntry(vault.index, label, content, {
await addEntry((vault) => addDocumentEntry(vault.index, label, content, {
...commonOpts(o),
fileType: o.fileType,
});
await finishAdd(vault, id);
}));
},
);
@@ -469,41 +499,37 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.option("--words <n>", "word count", "24"),
).action(
async (label: string, o: { words: string; notes?: string; tags?: string[]; archived?: boolean }) => {
const vault = await openFromOptions(program.opts());
const words = parseIntOption(o.words, "--words");
if (words !== 12 && words !== 18 && words !== 24) {
throw new Error("--words must be 12, 18 or 24");
}
const id = addSeedEntry(vault.index, label, {
...commonOpts(o),
wordCount: words as 12 | 18 | 24,
});
await finishAdd(vault, id);
await addEntry((vault) =>
addSeedEntry(vault.index, label, {
...commonOpts(o),
wordCount: words as 12 | 18 | 24,
}),
);
},
);
commonAddOptions(
add.command("managed-account <label>").description("BIP-85 managed account (12-word child seed)"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
const vault = await openFromOptions(program.opts());
const id = addManagedAccountEntry(vault.index, label, vault.mnemonic, commonOpts(o));
await finishAdd(vault, id);
await addEntry((vault) =>
addManagedAccountEntry(vault.index, label, vault.mnemonic, commonOpts(o)),
);
});
commonAddOptions(
add.command("nostr <label>").description("derived Nostr key entry"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
const vault = await openFromOptions(program.opts());
const id = addNostrKeyEntry(vault.index, label, commonOpts(o));
await finishAdd(vault, id);
await addEntry((vault) => addNostrKeyEntry(vault.index, label, commonOpts(o)));
});
commonAddOptions(
add.command("ssh <label>").description("derived Ed25519 SSH key entry"),
).action(async (label: string, o: { notes?: string; tags?: string[]; archived?: boolean }) => {
const vault = await openFromOptions(program.opts());
const id = addSshKeyEntry(vault.index, label, commonOpts(o));
await finishAdd(vault, id);
await addEntry((vault) => addSshKeyEntry(vault.index, label, commonOpts(o)));
});
commonAddOptions(
@@ -516,12 +542,10 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
label: string,
o: { userId: string; notes?: string; tags?: string[]; archived?: boolean },
) => {
const vault = await openFromOptions(program.opts());
const id = addPgpKeyEntry(vault.index, label, {
await addEntry((vault) => addPgpKeyEntry(vault.index, label, {
...commonOpts(o),
userId: o.userId,
});
await finishAdd(vault, id);
}));
},
);
@@ -536,18 +560,18 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
file: string,
o: { label?: string; notes: string; tags?: string[] },
) => {
const vault = await openFromOptions(program.opts());
const path = resolveHome(file);
// Documents are stored as text, matching Python's read_text.
const content = await readFile(path, "utf8");
const base = basename(path);
const ext = extname(base).replace(/^\./, "").toLowerCase();
const id = addDocumentEntry(vault.index, o.label || base.replace(/\.[^.]*$/, ""), content, {
fileType: ext || "txt",
notes: o.notes,
...(o.tags !== undefined && { tags: o.tags }),
});
await finishAdd(vault, id);
await addEntry((vault) =>
addDocumentEntry(vault.index, o.label || base.replace(/\.[^.]*$/, ""), content, {
fileType: ext || "txt",
notes: o.notes,
...(o.tags !== undefined && { tags: o.tags }),
}),
);
},
);
@@ -637,8 +661,6 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
value?: string; content?: string; fileType?: string;
},
) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const changes: ModifyChanges = {
...(o.label !== undefined && { label: o.label }),
...(o.username !== undefined && { username: o.username }),
@@ -653,9 +675,12 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
...(o.fileType !== undefined && { file_type: o.fileType }),
};
if (Object.keys(changes).length === 0) throw new Error("no changes given");
modifyEntry(vault.index, hit.id, changes);
await saveVault(vault);
io.out(JSON.stringify(entryMetadata(hit.id, vault.index.entries[hit.id]!), null, 2));
const row = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
modifyEntry(vault.index, hit.id, changes);
return entryMetadata(hit.id, vault.index.entries[hit.id]!);
});
io.out(JSON.stringify(row, null, 2));
},
);
@@ -663,22 +688,24 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.command("archive <refOrQuery>")
.description("archive an entry")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
archiveEntry(vault.index, hit.id);
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, archived: true }));
const ref = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
archiveEntry(vault.index, hit.id);
return hit.ref;
});
io.out(JSON.stringify({ ref, archived: true }));
});
entry
.command("unarchive <refOrQuery>")
.description("restore an archived entry")
.action(async (refOrQuery: string) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
restoreEntry(vault.index, hit.id);
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, archived: false }));
const ref = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
restoreEntry(vault.index, hit.id);
return hit.ref;
});
io.out(JSON.stringify({ ref, archived: false }));
});
entry
@@ -696,15 +723,16 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.option("--relation <name>", "relation type", "related_to")
.option("--note <text>", "link note", "")
.action(async (refOrQuery: string, target: string, o: { relation: string; note: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = addLink(vault.index, hit.id, Number(targetHit.id), {
relation: o.relation,
note: o.note,
const result = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = addLink(vault.index, hit.id, Number(targetHit.id), {
relation: o.relation,
note: o.note,
});
return { ref: hit.ref, links };
});
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
io.out(JSON.stringify(result, null, 2));
});
entry
@@ -712,14 +740,15 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
.description("remove links to a target entry")
.option("--relation <name>", "only remove this relation")
.action(async (refOrQuery: string, target: string, o: { relation?: string }) => {
const vault = await openFromOptions(program.opts());
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = removeLink(vault.index, hit.id, Number(targetHit.id), {
...(o.relation !== undefined && { relation: o.relation }),
const result = await mutateVault(program.opts(), (vault) => {
const hit = resolveEntry(vault.index, refOrQuery);
const targetHit = resolveEntry(vault.index, target);
const links = removeLink(vault.index, hit.id, Number(targetHit.id), {
...(o.relation !== undefined && { relation: o.relation }),
});
return { ref: hit.ref, links };
});
await saveVault(vault);
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
io.out(JSON.stringify(result, null, 2));
});
entry
@@ -991,30 +1020,37 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
nostr
.command("restore")
.description("fetch the latest snapshot + deltas from relays and merge into the local vault")
.action(async () => {
.description("fetch the latest snapshot + deltas and merge them into the local vault")
.option(
"--replace",
"DESTRUCTIVE: discard local state instead of merging remote into it",
)
.option("--yes", "confirm --replace")
.action(async (o: { replace?: boolean; yes?: boolean }) => {
const opts = program.opts() as GlobalOpts;
const { app, fp, mnemonic, relays, keys } = await nostrContext(opts);
if (o.replace && !o.yes) {
throw new Error("--replace discards local entries; pass --yes to confirm");
}
const pool = new RelayPool(relays);
try {
const fetched = await fetchLatestSnapshot(pool, keys.privateKeyHex);
if (!fetched) throw new Error("no snapshot found on the configured relays");
const indexKey = deriveIndexKeyBytes(mnemonic);
// A published snapshot carries whatever the local index file held.
// Python's index files wrap the ciphertext in a kdf/ct JSON envelope
// (TS writes bare ciphertext), so parse the wrapper first — exactly
// as Python's decrypt_and_save_index_from_nostr does.
const snapshotPayload = parseEncryptedFile(fetched.encrypted).ciphertext;
let state = JSON.parse(
let remote = JSON.parse(
new TextDecoder().decode(await decryptPayload(indexKey, snapshotPayload)),
) as Record<string, unknown>;
let deltaCount = 0;
if (fetched.manifest.delta_since) {
// Bind deltas to this manifest so a relay cannot replay one from
// another snapshot lineage into the restore.
const deltas = await fetchDeltasSince(
pool,
keys.privateKeyHex,
fetched.manifest.delta_since,
fetched.manifestEvent.id,
);
for (const delta of deltas) {
const incoming = JSON.parse(
@@ -1022,19 +1058,58 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
await decryptPayload(indexKey, parseEncryptedFile(delta).ciphertext),
),
);
state = mergeIndexPayloads(state, incoming, sha256Hex(delta).slice(0, 16));
remote = mergeIndexPayloads(remote, incoming, sha256Hex(delta).slice(0, 16));
deltaCount++;
}
}
const index = parseVaultIndex(state);
const vaultPath = opts.vault ?? join(app.profileDir(fp), INDEX_FILENAME);
const localExists = existsSync(vaultPath);
let local: Record<string, unknown> | null = null;
if (localExists) {
try {
local = (await openVault(vaultPath, mnemonic)).index as unknown as Record<
string,
unknown
>;
} catch {
local = null; // unreadable local vault: treat the restore as recovery
}
}
// Default is a merge, not a replacement: a restore must not silently
// discard entries created locally since the snapshot was published.
let final: Record<string, unknown>;
let mode: string;
if (o.replace || local === null) {
final = remote;
mode = local === null ? "restored" : "replaced";
} else {
// Keep the local _system.index0 verbatim: it is Python-derived
// atlas state recomputed on load, and refusing to merge it would
// block every restore into a Python-created profile.
final = mergeIndexPayloads(local, remote, "nostr-restore", {
index0: "preserve-current",
});
mode = "merged";
}
// Keep a copy of whatever we are about to overwrite.
let backupPath: string | null = null;
if (localExists) {
backupPath = `${vaultPath}.pre-restore-${Math.floor(Date.now() / 1000)}`;
await atomicWrite(backupPath, new Uint8Array(await readFile(vaultPath)));
}
const index = parseVaultIndex(final);
await saveVault({ index, mnemonic, path: vaultPath });
io.out(
JSON.stringify({
restored: vaultPath,
mode,
vault: vaultPath,
entry_count: Object.keys(index.entries).length,
deltas_applied: deltaCount,
local_backup: backupPath,
}),
);
} finally {
+101 -2
View File
@@ -3,7 +3,10 @@
* or without the JSON kdf/ct wrapper) unlocked by the parent seed mnemonic.
*/
import { readFile, writeFile } from "node:fs/promises";
import { readFile, open, rename, rm, stat, chmod } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { randomBytes } from "node:crypto";
import process from "node:process";
import {
decryptPayload,
parseEncryptedFile,
@@ -41,8 +44,104 @@ export async function openVault(path: string, mnemonic: string): Promise<OpenedV
return { index, mnemonic, path };
}
/**
* Persist the vault.
*
* Writes must survive a crash and must not let two concurrent commands
* silently discard each other's changes, so this:
* 1. validates the index before anything touches disk,
* 2. takes an exclusive lock for the read-modify-write window,
* 3. writes a temp file in the same directory, fsyncs it, and renames
* over the target (atomic within a filesystem),
* 4. fsyncs the directory so the rename itself is durable.
*/
export async function saveVault(vault: OpenedVault): Promise<void> {
await withVaultLock(vault.path, () => saveVaultHoldingLock(vault));
}
/**
* Save without taking the lock — for callers already inside `withVaultLock`.
*
* Serializing only the write is not enough: two commands that each read,
* mutate and write will still lose one set of changes. The lock has to span
* the whole read-modify-write cycle, which is what `mutateVault` does.
*/
export async function saveVaultHoldingLock(vault: OpenedVault): Promise<void> {
// A malformed index must never reach disk: a bad write here is what makes
// a vault unopenable afterwards.
parseVaultIndex(JSON.parse(JSON.stringify(vault.index)), { migrate: false });
const key = deriveIndexKeyBytes(vault.mnemonic);
const payload = await encryptV3(key, utf8(JSON.stringify(vault.index)));
await writeFile(vault.path, payload, { mode: 0o600 });
await atomicWrite(vault.path, payload);
}
/** Write `data` to `path` atomically, with 0600 and durability. */
export async function atomicWrite(path: string, data: Uint8Array): Promise<void> {
const dir = dirname(path);
const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
const handle = await open(tmp, "wx", 0o600);
try {
await handle.write(data);
await handle.sync();
} finally {
await handle.close();
}
await rename(tmp, path);
// Repair permissions if the target already existed with looser modes:
// writeFile's mode argument applies only on creation.
await chmod(path, 0o600);
const dirHandle = await open(dir, "r");
try {
await dirHandle.sync();
} catch {
// Directory fsync is not supported everywhere; the rename still lands.
} finally {
await dirHandle.close();
}
}
/**
* Hold an exclusive lock for the duration of `fn`.
*
* `wx` on a sidecar file is the portable primitive here: creation is atomic,
* so exactly one holder wins. A stale lock (crashed holder) is broken after
* LOCK_STALE_MS rather than wedging the CLI forever.
*/
const LOCK_STALE_MS = 30_000;
export async function withVaultLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const lockPath = `${path}.lock`;
const deadline = Date.now() + LOCK_STALE_MS;
for (;;) {
try {
const handle = await open(lockPath, "wx", 0o600);
await handle.write(String(process.pid));
await handle.close();
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
let age = 0;
try {
age = Date.now() - (await stat(lockPath)).mtimeMs;
} catch {
continue; // holder released it between our attempts
}
if (age > LOCK_STALE_MS) {
await rm(lockPath, { force: true });
continue;
}
if (Date.now() > deadline) {
throw new Error(
`another SeedPass process is holding ${lockPath}; retry, or remove it if no process is running`,
);
}
await new Promise((r) => setTimeout(r, 25));
}
}
try {
return await fn();
} finally {
await rm(lockPath, { force: true });
}
}