mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 02:42:04 +00:00
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:
1 parent
9926b48e16
commit
55879eaa1b
9 files changed
+519
-115
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user