Files
SeedPass/js/packages/cli/src/vaultFile.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

148 lines
4.9 KiB
TypeScript

/**
* Vault file access for the CLI: an encrypted index file (V3 payload, with
* or without the JSON kdf/ct wrapper) unlocked by the parent seed mnemonic.
*/
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,
deriveIndexKeyBytes,
encryptV3,
parseVaultIndex,
utf8,
type VaultIndex,
} from "@seedpass/core";
export interface OpenedVault {
index: VaultIndex;
mnemonic: string;
path: string;
}
export async function openVault(path: string, mnemonic: string): Promise<OpenedVault> {
const blob = new Uint8Array(await readFile(path));
const { ciphertext } = parseEncryptedFile(blob);
const key = deriveIndexKeyBytes(mnemonic);
let plaintext: Uint8Array;
try {
plaintext = await decryptPayload(key, ciphertext);
} catch (cause) {
// Raw decrypt errors ("invalid token encoding") tell the user nothing
// about which of the two likely causes they are looking at.
throw new Error(
`could not decrypt vault at ${path}: the file is corrupt, is not a ` +
`SeedPass vault, or belongs to a different seed ` +
`(restore with 'nostr restore' or 'vault import')`,
{ cause },
);
}
const index = parseVaultIndex(JSON.parse(new TextDecoder().decode(plaintext)));
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 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 });
}
}