mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 00:42:08 +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
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Durability and input-validation guarantees for vault writes.
|
||||
*
|
||||
* From a security review: a bad numeric option once persisted NaN as JSON
|
||||
* null and made every later open fail; writes truncated the target in place
|
||||
* with no lock, so a crash or a concurrent command could lose the vault.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, writeFile, stat, chmod } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import { mnemonics, entriesIndex } from "@seedpass/test-vectors";
|
||||
import { deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core";
|
||||
import { buildProgram, AppDir, type ProgramIo } from "../src/index.js";
|
||||
|
||||
const MNEMONIC = mnemonics["abandon12"]!;
|
||||
let vaultPath: string;
|
||||
let dir: string;
|
||||
|
||||
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;
|
||||
const saved = process.env["SEEDPASS_MNEMONIC"];
|
||||
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
||||
try {
|
||||
await buildProgram(io).parseAsync(["node", "seedpass-js", ...argv]);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
|
||||
else process.env["SEEDPASS_MNEMONIC"] = saved;
|
||||
}
|
||||
return { stdout: out.join("\n"), error };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "seedpass-durability-"));
|
||||
vaultPath = join(dir, "vault.enc");
|
||||
await writeFile(
|
||||
vaultPath,
|
||||
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(entriesIndex.entries))),
|
||||
);
|
||||
});
|
||||
|
||||
describe("numeric input validation", () => {
|
||||
it("rejects a non-numeric length instead of persisting NaN", async () => {
|
||||
const before = await readFile(vaultPath);
|
||||
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", "not-a-number");
|
||||
expect(String((r.error as Error).message)).toContain("whole number");
|
||||
// The vault is untouched and still opens
|
||||
expect(await readFile(vaultPath)).toEqual(before);
|
||||
expect((await run("--vault", vaultPath, "entry", "list")).error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects out-of-range and fractional values", async () => {
|
||||
for (const bad of ["0", "5", "999", "16.5", "1e3", "", " "]) {
|
||||
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", bad);
|
||||
expect(r.error, `--length ${JSON.stringify(bad)} should be refused`).toBeTruthy();
|
||||
}
|
||||
const totp = await run(
|
||||
"--vault", vaultPath, "entry", "add", "totp", "bad", "--period", "abc",
|
||||
);
|
||||
expect(String((totp.error as Error).message)).toContain("whole number");
|
||||
});
|
||||
|
||||
it("still accepts valid values", async () => {
|
||||
const r = await run("--vault", vaultPath, "entry", "add", "password", "good", "--length", "24");
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(JSON.parse(r.stdout).length).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomic, permission-correct writes", () => {
|
||||
it("leaves no temp files and keeps the vault at 0600", async () => {
|
||||
await chmod(vaultPath, 0o644); // simulate a loosened file
|
||||
await run("--vault", vaultPath, "entry", "add", "key-value", "perm", "k", "v");
|
||||
const info = await stat(vaultPath);
|
||||
// writeFile's mode only applies on creation, so this must be repaired
|
||||
expect(info.mode & 0o077).toBe(0);
|
||||
const leftovers = (await import("node:fs/promises")).readdir(dir);
|
||||
expect((await leftovers).filter((f) => f.includes(".tmp"))).toEqual([]);
|
||||
expect(existsSync(`${vaultPath}.lock`)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives concurrent writers without losing entries", async () => {
|
||||
const labels = ["c1", "c2", "c3", "c4", "c5"];
|
||||
await Promise.all(
|
||||
labels.map((label) =>
|
||||
run("--vault", vaultPath, "entry", "add", "key-value", label, "k", "v"),
|
||||
),
|
||||
);
|
||||
const rows = JSON.parse((await run("--vault", vaultPath, "entry", "list")).stdout) as {
|
||||
label: string;
|
||||
}[];
|
||||
const seen = rows.map((r) => r.label);
|
||||
// Without locking, concurrent read-modify-write cycles silently drop
|
||||
// entries; every one of these must be present.
|
||||
for (const label of labels) expect(seen).toContain(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile path validation", () => {
|
||||
it("refuses a fingerprint that is not 16 hex characters", async () => {
|
||||
const app = new AppDir(dir);
|
||||
expect(() => app.profileDir("../../etc")).toThrow(/invalid profile fingerprint/);
|
||||
await expect(app.removeProfile("../../etc")).rejects.toThrow(/invalid profile fingerprint/);
|
||||
await expect(app.switchProfile("nope")).rejects.toThrow(/invalid profile fingerprint/);
|
||||
});
|
||||
|
||||
it("ignores malformed entries in the fingerprint registry", async () => {
|
||||
const appRoot = await mkdtemp(join(tmpdir(), "seedpass-registry-"));
|
||||
await writeFile(
|
||||
join(appRoot, "fingerprints.json"),
|
||||
JSON.stringify({
|
||||
fingerprints: ["../../evil", "0123456789ABCDEF"],
|
||||
last_used: "../../evil",
|
||||
names: {},
|
||||
}),
|
||||
);
|
||||
const app = new AppDir(appRoot);
|
||||
const data = await app.readFingerprints();
|
||||
expect(data.fingerprints).toEqual(["0123456789ABCDEF"]);
|
||||
expect(data.last_used).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,30 @@ describe("nostr CLI", () => {
|
||||
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");
|
||||
@@ -92,11 +116,13 @@ describe("nostr CLI", () => {
|
||||
// Restore from the relay alone
|
||||
const restore = await run("nostr", "restore");
|
||||
const restored = JSON.parse(restore.stdout);
|
||||
expect(restored.entry_count).toBe(2);
|
||||
// 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).sort()).toEqual(["synced-secret", "synced-site"]);
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ export {
|
||||
safeInt,
|
||||
TOMBSTONE_RETENTION_CAP,
|
||||
MERGE_STRATEGY,
|
||||
type MergeOptions,
|
||||
} from "./sync/merge.js";
|
||||
export {
|
||||
KIND_MANIFEST,
|
||||
|
||||
@@ -234,26 +234,44 @@ function assertIndex0Portable(raw: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureIndex0Payload(data: unknown): Dict {
|
||||
function ensureIndex0Payload(data: unknown, options: MergeOptions = {}): Dict {
|
||||
const out: Dict = isDict(data) ? { ...data } : {};
|
||||
const system: Dict = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {};
|
||||
assertIndex0Portable(system["index0"]);
|
||||
system["index0"] = emptyIndex0();
|
||||
if (options.index0 !== "preserve-current") {
|
||||
assertIndex0Portable(system["index0"]);
|
||||
system["index0"] = emptyIndex0();
|
||||
}
|
||||
out["_system"] = system;
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MergeOptions {
|
||||
/**
|
||||
* What to do with `_system.index0`, Python's derived atlas state.
|
||||
*
|
||||
* Default ("reject") refuses to merge populated index0 rather than drop
|
||||
* it silently. "preserve-current" keeps the current side's block verbatim
|
||||
* and ignores the incoming one — correct when merging remote state into a
|
||||
* live profile, since Python recomputes canonical views from entries on
|
||||
* load and would otherwise be blocked entirely.
|
||||
*/
|
||||
index0?: "reject" | "preserve-current";
|
||||
}
|
||||
|
||||
/** Deterministically merge two decrypted index payloads (Python parity). */
|
||||
export function mergeIndexPayloads(
|
||||
currentRaw: unknown,
|
||||
incomingRaw: unknown,
|
||||
sourceTag = "",
|
||||
options: MergeOptions = {},
|
||||
): Dict {
|
||||
const out = ensureIndex0Payload(
|
||||
isDict(currentRaw) ? structuredClone(currentRaw) : {},
|
||||
options,
|
||||
);
|
||||
const incoming = ensureIndex0Payload(
|
||||
isDict(incomingRaw) ? structuredClone(incomingRaw) : {},
|
||||
options,
|
||||
);
|
||||
|
||||
const curEntries: Dict = isDict(out["entries"]) ? (out["entries"] as Dict) : {};
|
||||
@@ -379,7 +397,9 @@ export function mergeIndexPayloads(
|
||||
out["_sync_meta"] = meta;
|
||||
|
||||
const outSystem: Dict = isDict(out["_system"]) ? (out["_system"] as Dict) : {};
|
||||
outSystem["index0"] = emptyIndex0();
|
||||
if (options.index0 !== "preserve-current") {
|
||||
outSystem["index0"] = emptyIndex0();
|
||||
}
|
||||
out["_system"] = outSystem;
|
||||
return out;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
buildManifestEvent,
|
||||
signEvent,
|
||||
signerPublicKeyHex,
|
||||
type Filter,
|
||||
type NostrEvent,
|
||||
} from "./events.js";
|
||||
import {
|
||||
@@ -101,7 +102,14 @@ export async function fetchLatestSnapshot(
|
||||
const pubkey = signerPublicKeyHex(privateKeyHex);
|
||||
const manifests = await pool.fetch({ authors: [pubkey], kinds: [KIND_MANIFEST] });
|
||||
if (manifests.length === 0) return null;
|
||||
manifests.sort((a, b) => b.created_at - a.created_at);
|
||||
// Newest first. Two snapshots published within the same second tie on
|
||||
// created_at, so fall back to the order the relay returned them in
|
||||
// (later = more recently accepted); without this the restore can pick a
|
||||
// stale manifest after a same-second republish.
|
||||
const order = new Map(manifests.map((ev, i) => [ev.id, i]));
|
||||
manifests.sort(
|
||||
(a, b) => b.created_at - a.created_at || (order.get(b.id)! - order.get(a.id)!),
|
||||
);
|
||||
|
||||
for (const manifestEvent of manifests) {
|
||||
let manifest: Manifest;
|
||||
@@ -150,14 +158,29 @@ export async function publishDelta(
|
||||
return event.id;
|
||||
}
|
||||
|
||||
/** Fetch deltas since a timestamp, sorted by created_at (replay order). */
|
||||
/**
|
||||
* Fetch deltas since a timestamp, sorted by created_at (replay order).
|
||||
*
|
||||
* `manifestEventId` binds the result to one snapshot lineage. Deltas carry
|
||||
* an `e` tag naming the manifest they extend; without checking it a relay
|
||||
* could replay a validly-signed delta from a different (older) lineage into
|
||||
* this restore. Callers restoring a specific snapshot must pass it.
|
||||
*/
|
||||
export async function fetchDeltasSince(
|
||||
pool: RelayPool,
|
||||
privateKeyHex: string,
|
||||
since: number,
|
||||
manifestEventId?: string,
|
||||
): Promise<Uint8Array[]> {
|
||||
const pubkey = signerPublicKeyHex(privateKeyHex);
|
||||
const events = await pool.fetch({ authors: [pubkey], kinds: [KIND_DELTA], since });
|
||||
events.sort((a, b) => a.created_at - b.created_at);
|
||||
return events.map((ev) => base64.decode(ev.content));
|
||||
const filter: Filter = { authors: [pubkey], kinds: [KIND_DELTA], since };
|
||||
if (manifestEventId) filter["#e"] = [manifestEventId];
|
||||
const events = await pool.fetch(filter);
|
||||
const bound = manifestEventId
|
||||
? events.filter((ev) =>
|
||||
ev.tags.some((t) => t[0] === "e" && t[1] === manifestEventId),
|
||||
)
|
||||
: events;
|
||||
bound.sort((a, b) => a.created_at - b.created_at);
|
||||
return bound.map((ev) => base64.decode(ev.content));
|
||||
}
|
||||
Reference in new issue
Block a user