TS port slice 3: Nostr sync model — snapshots, manifests, conflict merge

- Snapshot model parity (kinds 30070/30071/30072): gzip chunking via
  platform CompressionStream, chunk-hash verification, reassembly of
  Python-produced chunks, manifest JSON schema, manifest id
  HMAC-SHA256(key_index, 'manifest|'+nonce) with the key_index HKDF chain.
- Full merge_index_payloads port (modified_ts_hash_tombstone_v2):
  Python-compatible canonical JSON (sort_keys + ensure_ascii) for hash
  parity, equal-ts field-level union, tombstone create/prefer/apply/cap,
  _sync_meta bookkeeping. Populated _system.index0 content refuses to merge
  loudly (atlas-milestone work) instead of silently dropping data.
- Delta replay: encrypted V3 deltas decrypt and merge in order with
  source_tag = sha256(payload)[:16]; final state matches Python.
- New fixtures: nostr_snapshot.json, sync_merge.json (8 scenarios),
  delta_replay.json; generator remains byte-deterministic.
- 117 parity tests green in Node and jsdom.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-13 10:33:42 -04:00
1 parent 6d6ad5e2c5
commit 2345f61431
12 files changed
+1924 -5

No files matched your search

+6 -4
View File
@@ -31,16 +31,18 @@ until a later milestone.
| KDF metadata parsing | P0 | green | `kdf_metadata.json`, `legacy_payloads.json` | KdfConfig schema with Python-default fallbacks. |
| SSH key derivation | P1 | todo | — | Ed25519 from BIP-85 app 32; PEM serialization parity risk. |
| PGP key derivation | P1/P2 | todo | — | Highest parity risk (PGPy serialization); import/roundtrip first. |
| Nostr sync events 30070/30071/30072 | P0 | todo | — | Milestone 4. |
| Conflict merge / tombstones | P0 | todo | — | Milestone 4. |
| Nostr snapshot chunk/manifest model | P0 | green | `nostr_snapshot.json` | Kinds 30070/30071/30072; gzip chunking, chunk-hash verify, reassembly of Python-produced chunks, manifest JSON parse, manifest id HMAC (key_index chain). Compressed bytes are deliberately not byte-pinned (deflate encoders differ); cross-decompression verified both ways. Relay WebSocket adapter: todo. |
| Deterministic conflict merge | P0 | green | `sync_merge.json` | Full `merge_index_payloads` port: ts precedence, canonical-hash tie-breaks (Python-compatible ensure_ascii canonical JSON), equal-ts field union incl. the `custom_fields: null` quirk, tombstone lifecycle, retention cap, `_sync_meta`. |
| Tombstones / delta replay | P0 | green | `delta_replay.json` | Encrypted V3 deltas decrypted and merged in order with `source_tag = sha256(payload)[:16]`, final state matches Python. |
| index0 content merge (`_system.index0`) | P2 | partial | — | Empty-skeleton normalization matches Python; merging populated index0 event logs throws loudly instead of silently dropping data. Full port belongs to the atlas milestone. |
| Portable backup import/export | P0 | todo | — | Milestone 3. |
## Environment coverage
| Environment | Status |
|---|---|
| Node 22 (vitest) | green — 100/100 |
| jsdom browser-like env | green — 100/100 |
| Node 22 (vitest) | green — 117/117 |
| jsdom browser-like env | green — 117/117 |
| Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI |
## Dependency notes (to expand into docs/typescript_dependency_review.md)
+26
View File
@@ -55,4 +55,30 @@ export {
type Entry,
type VaultIndex,
} from "./schema/entries.js";
export { canonicalJson, canonicalHash } from "./sync/canonical.js";
export {
mergeIndexPayloads,
emptyIndex0,
safeInt,
TOMBSTONE_RETENTION_CAP,
MERGE_STRATEGY,
} from "./sync/merge.js";
export {
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
chunkMetaSchema,
manifestSchema,
parseManifest,
prepareSnapshot,
reassembleSnapshot,
gzipCompress,
gzipDecompress,
deriveKeyIndex,
manifestIdFromNonce,
newManifestId,
ChunkVerificationError,
type ChunkMeta,
type Manifest,
} from "./sync/snapshot.js";
export * from "./util/bytes.js";
+83
View File
@@ -0,0 +1,83 @@
/**
* Python-compatible canonical JSON:
* json.dumps(value, sort_keys=True, separators=(",", ":"))
*
* Entry and tombstone hashes are SHA-256 over this exact byte sequence, so
* key ordering, ASCII escaping (Python defaults to ensure_ascii=True) and
* number formatting must match Python byte-for-byte for hash parity.
* Values here are JSON-shaped data (from JSON.parse or fixtures), so
* non-finite numbers and non-plain objects are rejected loudly.
*/
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, utf8 } from "../util/bytes.js";
const ESCAPES: Record<string, string> = {
'"': '\\"',
"\\": "\\\\",
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
};
function encodeString(s: string): string {
let out = '"';
for (let i = 0; i < s.length; i++) {
const ch = s[i]!;
const code = s.charCodeAt(i);
const mapped = ESCAPES[ch];
if (mapped !== undefined) {
out += mapped;
} else if (code < 0x20 || code > 0x7e) {
// ensure_ascii: everything outside printable ASCII becomes \uXXXX
// (surrogate pairs escape as two code units, same as CPython)
out += "\\u" + code.toString(16).padStart(4, "0");
} else {
out += ch;
}
}
return out + '"';
}
function encodeNumber(n: number): string {
if (!Number.isFinite(n)) throw new Error("non-finite number in canonical JSON");
if (Number.isInteger(n) && Object.is(n, -0) === false) return n.toString();
// Python repr(float) and JS toString agree on shortest-roundtrip decimals
// for doubles; integral floats (1.0) cannot be distinguished from ints in
// JSON-parsed data, so both sides serialize them identically.
return n.toString();
}
export function canonicalJson(value: unknown): string {
if (value === null) return "null";
switch (typeof value) {
case "boolean":
return value ? "true" : "false";
case "number":
return encodeNumber(value);
case "string":
return encodeString(value);
case "object": {
if (Array.isArray(value)) {
return "[" + value.map(canonicalJson).join(",") + "]";
}
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) {
throw new Error("non-plain object in canonical JSON");
}
const keys = Object.keys(value as Record<string, unknown>).sort();
const parts = keys.map(
(k) => encodeString(k) + ":" + canonicalJson((value as Record<string, unknown>)[k]),
);
return "{" + parts.join(",") + "}";
}
default:
throw new Error(`unsupported type in canonical JSON: ${typeof value}`);
}
}
export function canonicalHash(value: unknown): string {
return bytesToHex(sha256(utf8(canonicalJson(value))));
}
+385
View File
@@ -0,0 +1,385 @@
/**
* Deterministic index merge with tombstones.
*
* Parity target: src/seedpass/core/sync_conflict.py (strategy
* "modified_ts_hash_tombstone_v2"). Every rule here — timestamp precedence,
* canonical-hash tie-breaks, field-level union at equal timestamps, tombstone
* preference and retention — must match Python exactly; cross-client state
* convergence depends on both implementations agreeing.
*
* The _system.index0 block is normalized only for the empty case; merging
* populated index0 event logs is atlas-milestone work. Rather than silently
* mis-merging, mergeIndexPayloads throws on non-empty index0 content.
*/
import { canonicalHash, canonicalJson } from "./canonical.js";
export const TOMBSTONE_RETENTION_CAP = 2048;
export const MERGE_STRATEGY = "modified_ts_hash_tombstone_v2";
type Dict = Record<string, unknown>;
function isDict(v: unknown): v is Dict {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** Python int(raw) with a default on failure: truncates floats, accepts bools
* and integer strings (with surrounding whitespace), rejects the rest. */
export function safeInt(raw: unknown, dflt = 0): number {
if (typeof raw === "boolean") return raw ? 1 : 0;
if (typeof raw === "number") {
if (!Number.isFinite(raw)) return Math.trunc(dflt);
return Math.trunc(raw);
}
if (typeof raw === "string") {
const s = raw.trim();
if (/^[+-]?\d+$/.test(s)) return parseInt(s, 10);
return Math.trunc(dflt);
}
return Math.trunc(dflt);
}
function entryTs(entry: Dict): number {
return safeInt(entry["modified_ts"] ?? 0, 0);
}
function isEmpty(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (typeof value === "string") return value.trim() === "";
return false;
}
function entryKind(entry: Dict): string {
const raw = entry["kind"] ?? entry["type"] ?? "password";
return String(raw).trim().toLowerCase();
}
function isDeletedEntry(entry: Dict): boolean {
return Boolean(entry["_deleted"] ?? false) || Boolean(entry["deleted"] ?? false);
}
function entryHash(entry: Dict): string {
return canonicalHash(entry);
}
function entryEventHash(entry: Dict): string {
return canonicalHash({ entry, kind: "entry" });
}
function tombstoneEventHash(record: Dict, idx: string): string {
const provided = String(record["event_hash"] ?? "").trim().toLowerCase();
if (provided) return provided;
return canonicalHash({
kind: "tombstone",
index: String(idx),
deleted_ts: safeInt(record["deleted_ts"] ?? 0, 0),
entry_hash: String(record["entry_hash"] ?? ""),
});
}
function preferTombstone(current: Dict, incoming: Dict, idx: string): boolean {
const curTs = safeInt(current["deleted_ts"] ?? 0, 0);
const incTs = safeInt(incoming["deleted_ts"] ?? 0, 0);
if (incTs !== curTs) return incTs > curTs;
return tombstoneEventHash(incoming, idx) > tombstoneEventHash(current, idx);
}
function normalizeTombstones(value: unknown): Record<string, Dict> {
const out: Record<string, Dict> = {};
if (!isDict(value)) return out;
for (const [k, v] of Object.entries(value)) {
if (!isDict(v)) continue;
const deletedTs = safeInt(v["deleted_ts"] ?? 0, 0);
if (deletedTs <= 0) continue;
out[String(k)] = {
deleted_ts: deletedTs,
entry_hash: String(v["entry_hash"] ?? ""),
event_hash: String(v["event_hash"] ?? ""),
source: String(v["source"] ?? ""),
};
}
return out;
}
function mergeTombstones(
current: Record<string, Dict>,
incoming: Record<string, Dict>,
): Record<string, Dict> {
const merged: Record<string, Dict> = { ...current };
for (const [idx, rec] of Object.entries(incoming)) {
const cur = merged[idx];
if (cur === undefined || preferTombstone(cur, rec, idx)) {
merged[idx] = { ...rec };
}
}
return merged;
}
function maxEntryTs(entries: Dict): number {
let max = 0;
for (const value of Object.values(entries)) {
if (isDict(value)) {
const ts = entryTs(value);
if (ts > max) max = ts;
}
}
return max;
}
function maxTombstoneTs(tombstones: Record<string, Dict>): number {
let max = 0;
for (const record of Object.values(tombstones)) {
const ts = safeInt(record["deleted_ts"] ?? 0, 0);
if (ts > max) max = ts;
}
return max;
}
/** True when incoming should replace current (higher ts, then larger hash). */
function preferEntry(current: Dict, incoming: Dict): boolean {
const curTs = entryTs(current);
const incTs = entryTs(incoming);
if (incTs !== curTs) return incTs > curTs;
return entryHash(incoming) > entryHash(current);
}
const UNION_FIELD_MATRIX: Record<string, string[]> = {
password: ["username", "url"],
totp: ["issuer"],
key_value: ["key", "value"],
managed_account: ["value", "user_id"],
ssh: ["username", "public_key", "algorithm", "key_type"],
pgp: ["public_key", "key_type"],
nostr: ["npub", "public_key"],
seed: ["path", "network", "coin_type"],
document: ["content", "file_type"],
};
const COMMON_UNION_FIELDS = ["notes", "tags", "custom_fields", "links"];
const LIST_UNION_FIELDS = new Set(["tags", "custom_fields", "links"]);
function unionFieldsForKind(kind: string): string[] {
return [...(UNION_FIELD_MATRIX[kind] ?? []), ...COMMON_UNION_FIELDS];
}
function mergeListUnion(preferred: unknown, other: unknown): unknown {
if (Array.isArray(preferred) && Array.isArray(other)) {
// Python dedupes by canonical JSON and returns items sorted by that key
const byCanonical = new Map<string, unknown>();
for (const item of [...preferred, ...other]) {
byCanonical.set(canonicalJson(item ?? null), item);
}
return [...byCanonical.keys()].sort().map((k) => byCanonical.get(k));
}
return preferred;
}
/** Merge two entries carrying the same timestamp with field-level rules. */
function mergeEqualTsEntries(preferred: Dict, other: Dict, ts: number): Dict {
const merged: Dict = { ...preferred };
for (const field of unionFieldsForKind(entryKind(merged))) {
const pv = merged[field];
const ov = other[field];
if (LIST_UNION_FIELDS.has(field)) {
// Python assigns unconditionally; an absent value becomes JSON null
const result = mergeListUnion(pv, ov);
merged[field] = result === undefined ? null : result;
continue;
}
if (isEmpty(pv) && !isEmpty(ov)) {
merged[field] = ov;
}
}
if ("archived" in preferred || "archived" in other) {
merged["archived"] =
Boolean(preferred["archived"] ?? false) || Boolean(other["archived"] ?? false);
}
merged["modified_ts"] = Math.trunc(ts);
return merged;
}
/** Empty normalized index0 block (index0.py normalize_index0 on no content). */
export function emptyIndex0(): Dict {
return {
schema_version: 1,
events: {},
checkpoints: {},
canonical_views: {},
view_manifest: {
version: 1,
canonical_view_types: [],
local_only_view_types: [],
builder_versions: {},
},
heads: {},
stats: {
event_count: 0,
checkpoint_count: 0,
writer_count: 0,
last_compaction_ts: 0,
last_validation_ts: 0,
},
};
}
function assertIndex0Portable(raw: unknown): void {
if (!isDict(raw)) return;
for (const key of ["events", "checkpoints", "canonical_views"]) {
const section = raw[key];
if (isDict(section) && Object.keys(section).length > 0) {
throw new Error(
`_system.index0.${key} contains data; index0 content merge is not ` +
`ported yet — refusing to merge rather than dropping it silently`,
);
}
}
}
function ensureIndex0Payload(data: unknown): Dict {
const out: Dict = isDict(data) ? { ...data } : {};
const system: Dict = isDict(out["_system"]) ? { ...(out["_system"] as Dict) } : {};
assertIndex0Portable(system["index0"]);
system["index0"] = emptyIndex0();
out["_system"] = system;
return out;
}
/** Deterministically merge two decrypted index payloads (Python parity). */
export function mergeIndexPayloads(
currentRaw: unknown,
incomingRaw: unknown,
sourceTag = "",
): Dict {
const out = ensureIndex0Payload(
isDict(currentRaw) ? structuredClone(currentRaw) : {},
);
const incoming = ensureIndex0Payload(
isDict(incomingRaw) ? structuredClone(incomingRaw) : {},
);
const curEntries: Dict = isDict(out["entries"]) ? (out["entries"] as Dict) : {};
const incEntries: Dict = isDict(incoming["entries"]) ? (incoming["entries"] as Dict) : {};
const curMeta: Dict = isDict(out["_sync_meta"]) ? (out["_sync_meta"] as Dict) : {};
const incMeta: Dict = isDict(incoming["_sync_meta"]) ? (incoming["_sync_meta"] as Dict) : {};
let tombstones = mergeTombstones(
normalizeTombstones(curMeta["tombstones"] ?? {}),
normalizeTombstones(incMeta["tombstones"] ?? {}),
);
for (const [idx, incEntryRaw] of Object.entries(incEntries)) {
if (!isDict(incEntryRaw)) continue;
const key = String(idx);
const incEntry = incEntryRaw;
if (isDeletedEntry(incEntry)) {
let deleteTs = entryTs(incEntry);
if (deleteTs <= 0) {
deleteTs = Math.max(
safeInt(curMeta["last_merge_ts"] ?? 0, 0),
safeInt(incMeta["last_merge_ts"] ?? 0, 0),
maxEntryTs(curEntries),
maxEntryTs(incEntries),
maxTombstoneTs(tombstones),
);
if (deleteTs <= 0) deleteTs = 1;
}
tombstones = mergeTombstones(tombstones, {
[key]: {
deleted_ts: deleteTs,
entry_hash: String(incEntry["entry_hash"] ?? ""),
event_hash: entryEventHash(incEntry),
source: sourceTag,
},
});
delete curEntries[key];
continue;
}
const curEntry = curEntries[key];
if (!isDict(curEntry)) {
curEntries[key] = incEntry;
continue;
}
const curTs = entryTs(curEntry);
const incTs = entryTs(incEntry);
if (curTs === incTs) {
if (preferEntry(curEntry, incEntry)) {
curEntries[key] = mergeEqualTsEntries(incEntry, curEntry, incTs);
} else {
curEntries[key] = mergeEqualTsEntries(curEntry, incEntry, curTs);
}
continue;
}
if (preferEntry(curEntry, incEntry)) {
curEntries[key] = incEntry;
}
}
// Apply tombstones after the entry merge
for (const [idx, rec] of Object.entries({ ...tombstones })) {
const entry = curEntries[idx];
if (!isDict(entry)) continue;
const eTs = entryTs(entry);
const dTs = safeInt(rec["deleted_ts"] ?? 0, 0);
if (eTs > dTs) {
delete tombstones[idx];
continue;
}
if (eTs < dTs) {
delete curEntries[idx];
continue;
}
if (tombstoneEventHash(rec, idx) > entryEventHash(entry)) {
delete curEntries[idx];
} else {
delete tombstones[idx];
}
}
out["entries"] = curEntries;
if ("schema_version" in incoming) {
out["schema_version"] = Math.max(
safeInt(out["schema_version"] ?? 0, 0),
safeInt(incoming["schema_version"] ?? 0, 0),
);
}
const meta: Dict = isDict(out["_sync_meta"]) ? (out["_sync_meta"] as Dict) : {};
let sources: string[] = Array.isArray(meta["sources"])
? (meta["sources"] as unknown[]).map((v) => String(v)).filter((v) => v)
: [];
if (sourceTag && !sources.includes(sourceTag)) sources.push(sourceTag);
sources = [...new Set(sources)].sort();
if (Object.keys(tombstones).length > 0) {
let items = Object.entries(tombstones).sort((a, b) => {
const ta = safeInt(a[1]["deleted_ts"] ?? 0, 0);
const tb = safeInt(b[1]["deleted_ts"] ?? 0, 0);
if (ta !== tb) return ta - tb;
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
});
if (items.length > TOMBSTONE_RETENTION_CAP) {
items = items.slice(-TOMBSTONE_RETENTION_CAP);
}
tombstones = Object.fromEntries(items);
}
const lastMergeTs = Math.max(
safeInt(meta["last_merge_ts"] ?? 0, 0),
safeInt(incMeta["last_merge_ts"] ?? 0, 0),
maxEntryTs(curEntries),
maxTombstoneTs(tombstones),
);
Object.assign(meta, {
strategy: MERGE_STRATEGY,
last_merge_ts: lastMergeTs,
source_count: sources.length,
sources: sources.slice(-32),
tombstones,
});
out["_sync_meta"] = meta;
const outSystem: Dict = isDict(out["_system"]) ? (out["_system"] as Dict) : {};
outSystem["index0"] = emptyIndex0();
out["_system"] = outSystem;
return out;
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Nostr snapshot model: chunking, manifests, and reassembly.
*
* Parity target: src/nostr/snapshot.py and backup_models.py. The encrypted
* vault payload is gzip-compressed, split into <=limit chunks, and published
* as kind-30071 events (base64 content, d-tag = chunk id). The manifest
* (kind 30070) lists chunk ids/sizes/hashes; deltas are kind 30072.
*
* gzip uses the platform CompressionStream/DecompressionStream. Compressed
* bytes are NOT required to match Python byte-for-byte (deflate encoders
* differ); what must match is that either side can decompress the other's
* output and that chunk hashes verify against the manifest that shipped them.
*/
import { hmac } from "@noble/hashes/hmac.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { mnemonicToSeedSync } from "@scure/bip39";
import { z } from "zod";
import { bytesToHex, concatBytes, utf8 } from "../util/bytes.js";
export const KIND_MANIFEST = 30070;
export const KIND_SNAPSHOT_CHUNK = 30071;
export const KIND_DELTA = 30072;
export const chunkMetaSchema = z
.object({
id: z.string(),
size: z.number().int().nonnegative(),
hash: z.string().regex(/^[0-9a-f]{64}$/),
event_id: z.string().nullable().optional().default(null),
})
.loose();
export const manifestSchema = z
.object({
ver: z.number().int(),
algo: z.string(),
chunks: z.array(chunkMetaSchema),
delta_since: z.number().int().nullable().optional().default(null),
nonce: z.string().nullable().optional().default(null),
index0: z.record(z.string(), z.unknown()).nullable().optional().default(null),
})
.loose();
export type ChunkMeta = z.infer<typeof chunkMetaSchema>;
export type Manifest = z.infer<typeof manifestSchema>;
export function parseManifest(json: string): Manifest {
return manifestSchema.parse(JSON.parse(json));
}
async function pipeThrough(
data: Uint8Array,
stream: { readable: ReadableStream; writable: WritableStream },
): Promise<Uint8Array> {
const writer = stream.writable.getWriter();
const writeDone = writer.write(data as BufferSource).then(() => writer.close());
const reader = stream.readable.getReader();
const parts: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parts.push(value as Uint8Array);
}
await writeDone;
return concatBytes(...parts);
}
export async function gzipCompress(data: Uint8Array): Promise<Uint8Array> {
return pipeThrough(data, new CompressionStream("gzip"));
}
export async function gzipDecompress(data: Uint8Array): Promise<Uint8Array> {
return pipeThrough(data, new DecompressionStream("gzip"));
}
/** Compress and split the encrypted vault into chunks (prepare_snapshot). */
export async function prepareSnapshot(
encryptedBytes: Uint8Array,
limit = 50_000,
): Promise<{ manifest: Manifest; chunks: Uint8Array[] }> {
const compressed = await gzipCompress(encryptedBytes);
const chunks: Uint8Array[] = [];
for (let i = 0; i < compressed.length; i += limit) {
chunks.push(compressed.slice(i, i + limit));
}
const metas: ChunkMeta[] = chunks.map((chunk, i) => ({
id: `seedpass-chunk-${String(i).padStart(4, "0")}`,
size: chunk.length,
hash: bytesToHex(sha256(chunk)),
event_id: null,
}));
return {
manifest: { ver: 1, algo: "gzip", chunks: metas, delta_since: null, nonce: null, index0: null },
chunks,
};
}
export class ChunkVerificationError extends Error {
constructor(
message: string,
public readonly chunkId: string,
) {
super(message);
this.name = "ChunkVerificationError";
}
}
/** Verify chunk hashes against the manifest and reassemble the vault bytes. */
export async function reassembleSnapshot(
manifest: Manifest,
chunks: Uint8Array[],
): Promise<Uint8Array> {
if (manifest.algo !== "gzip") {
throw new Error(`unsupported snapshot algo: ${manifest.algo}`);
}
if (chunks.length !== manifest.chunks.length) {
throw new Error(
`chunk count mismatch: manifest lists ${manifest.chunks.length}, got ${chunks.length}`,
);
}
for (let i = 0; i < chunks.length; i++) {
const meta = manifest.chunks[i]!;
const actual = bytesToHex(sha256(chunks[i]!));
if (actual !== meta.hash) {
throw new ChunkVerificationError(
`chunk ${meta.id} hash mismatch (expected ${meta.hash}, got ${actual})`,
meta.id,
);
}
}
return gzipDecompress(concatBytes(...chunks));
}
/** key_index: HKDF chain master -> "seedpass:v1:index" (manager.py KEY_INDEX). */
export function deriveKeyIndex(mnemonic: string): Uint8Array {
const seed = mnemonicToSeedSync(mnemonic);
const master = hkdf(sha256, seed, undefined, utf8("seedpass:v1:master"), 32);
return hkdf(sha256, master, undefined, utf8("seedpass:v1:index"), 32);
}
/** Manifest identifier: HMAC-SHA256(key_index, "manifest|" + nonce), hex. */
export function manifestIdFromNonce(keyIndex: Uint8Array, nonce: Uint8Array): string {
return bytesToHex(hmac(sha256, keyIndex, concatBytes(utf8("manifest|"), nonce)));
}
/** New manifest id with a fresh random nonce (production path). */
export function newManifestId(keyIndex: Uint8Array): { id: string; nonce: Uint8Array } {
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(16));
return { id: manifestIdFromNonce(keyIndex, nonce), nonce };
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Nostr sync parity: snapshot chunking/reassembly, manifest identity,
* deterministic conflict merge, tombstones, and full delta replay.
* Expected values are Python-generated fixtures.
*/
import { describe, expect, it } from "vitest";
import { base64 } from "@scure/base";
import {
nostrSnapshot,
syncMergeCases,
deltaReplay,
mnemonics,
} from "@seedpass/test-vectors";
import {
parseManifest,
prepareSnapshot,
reassembleSnapshot,
gzipDecompress,
deriveKeyIndex,
manifestIdFromNonce,
mergeIndexPayloads,
decryptV3,
deriveIndexKeyBytes,
bytesToHex,
hexToBytes,
ChunkVerificationError,
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
} from "@seedpass/core";
describe("snapshot chunking", () => {
it("uses the agreed event kinds", () => {
expect(KIND_MANIFEST).toBe(nostrSnapshot.event_kinds.manifest);
expect(KIND_SNAPSHOT_CHUNK).toBe(nostrSnapshot.event_kinds.snapshot_chunk);
expect(KIND_DELTA).toBe(nostrSnapshot.event_kinds.delta);
});
it("parses the Python manifest JSON", () => {
const manifest = parseManifest(nostrSnapshot.manifest_json);
expect(manifest.ver).toBe(1);
expect(manifest.algo).toBe("gzip");
expect(manifest.chunks).toEqual(nostrSnapshot.chunk_metas);
expect(manifest.nonce).toBe(nostrSnapshot.manifest_nonce_b64);
});
it("reassembles and decompresses the Python-produced chunks", async () => {
const manifest = parseManifest(nostrSnapshot.manifest_json);
const chunks = nostrSnapshot.chunks_b64.map((c) => base64.decode(c));
const encrypted = await reassembleSnapshot(manifest, chunks);
expect(base64.encode(encrypted)).toBe(nostrSnapshot.encrypted_b64);
});
it("rejects a corrupted chunk", async () => {
const manifest = parseManifest(nostrSnapshot.manifest_json);
const chunks = nostrSnapshot.chunks_b64.map((c) => base64.decode(c));
chunks[0]![0]! ^= 0x01;
await expect(reassembleSnapshot(manifest, chunks)).rejects.toThrow(
ChunkVerificationError,
);
});
it("roundtrips its own compression and hashes chunks like Python", async () => {
const encrypted = base64.decode(nostrSnapshot.encrypted_b64);
const { manifest, chunks } = await prepareSnapshot(encrypted, nostrSnapshot.chunk_limit);
expect(manifest.chunks.length).toBe(chunks.length);
expect(manifest.chunks[0]!.id).toBe("seedpass-chunk-0000");
const restored = await reassembleSnapshot(manifest, chunks);
expect(base64.encode(restored)).toBe(nostrSnapshot.encrypted_b64);
});
it("decompresses Python gzip output directly", async () => {
const compressed = base64.decode(nostrSnapshot.compressed_b64);
const out = await gzipDecompress(compressed);
expect(base64.encode(out)).toBe(nostrSnapshot.encrypted_b64);
});
it("derives the manifest id from key_index and nonce", () => {
const keyIndex = deriveKeyIndex(mnemonics[nostrSnapshot.mnemonic_id]!);
expect(bytesToHex(keyIndex)).toBe(nostrSnapshot.key_index_hex);
const nonce = base64.decode(nostrSnapshot.manifest_nonce_b64);
expect(manifestIdFromNonce(keyIndex, nonce)).toBe(nostrSnapshot.manifest_id);
expect(manifestIdFromNonce(hexToBytes(nostrSnapshot.key_index_hex), nonce)).toBe(
nostrSnapshot.manifest_id,
);
});
});
describe("deterministic conflict merge", () => {
it.each(syncMergeCases)("$name", (c) => {
const merged = mergeIndexPayloads(c.current, c.incoming, c.source_tag);
expect(merged).toEqual(c.merged);
});
it("is order-insensitive for the newer-wins rule", () => {
const a = syncMergeCases.find((c) => c.name === "newer-incoming-wins")!;
const forward = mergeIndexPayloads(a.current, a.incoming, "t") as {
entries: Record<string, { label: string }>;
};
const backward = mergeIndexPayloads(a.incoming, a.current, "t") as {
entries: Record<string, { label: string }>;
};
expect(forward.entries["0"]!.label).toBe(backward.entries["0"]!.label);
});
});
describe("delta replay", () => {
it("replays encrypted deltas onto a snapshot to the Python final state", async () => {
const key = deriveIndexKeyBytes(mnemonics[deltaReplay.mnemonic_id]!);
let state: unknown = deltaReplay.snapshot_index;
for (const b64 of deltaReplay.delta_payloads_b64) {
const encrypted = base64.decode(b64);
const plaintext = await decryptV3(key, encrypted);
const incoming = JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
const digest = bytesToHex(
(await import("@noble/hashes/sha2.js")).sha256(encrypted),
);
state = mergeIndexPayloads(state, incoming, digest.slice(0, 16));
}
expect(state).toEqual(deltaReplay.final_state);
});
});
@@ -0,0 +1,121 @@
{
"delta_payloads_b64": [
"VjN8dk5XXH5X36Xdmu5olZDxZJTpOTZDjskABP6osJd8Rftxc7YoBfXa/Yh4XHpySBsf70Vnm0Q0m1xQBOoeShxXtCgUbQ48x8pY3NnY3suMDjlqou0ti5vuMVknTRPwgyBnFAo1DUeGmZSidFK0/z8EfxqanEklB9e9yUtqOiN04UmwK8tnr0u0n2pJd82oppv3PNPZs4FFFFmiD1d+ZVlOBEyuziEmTjuhP6axqflqEv4Y2nox82w+XvBbFhkCO2lVO9Q++81rhE8OKJ9q9iuUURD+U83UjhLTdNavcu+jk2r4AyUMQh9rK43IabeEK5cFJmFpLWNOinzouOvQJQ9yGPvccYmarBIza8BsiJLINnX1oNNRk8TG3NQ4i8bKdeXBaATuBeLPDVhF2GKICGzy3sQN7CbvB5BjPBKD4+om2eVU8MJW2AKCz5WTMADF",
"VjN8NqWfd9x4AJPrDKZmrh+2VbGRWp63a4/trecKc9txplwSHL5q3zjS8XP2hkWsf3C6J6enIw071qnnVgLvjvamksxvz/8R6+g6WeeFYxl1rUxUPeR2Fh4nNRyrc8Z9uHEiXpKzX6dtVvoDUNCrEav69R5rSW61pCBF1xPu2rRPoxkJgolrMHHi/4iod76LpXsMDx3ldrZDL26u5GXaaXRNEmzvz0GWgnFBJrRRVoV+1Ch7tefExQKLLoKi4xHFvV4A0Gs4JX3Gho3GksdyMre3YxQ="
],
"delta_plaintexts": [
{
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "base-renamed",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
},
"1": {
"archived": false,
"kind": "password",
"label": "added",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
{
"entries": {
"1": {
"_deleted": true,
"archived": false,
"kind": "password",
"label": "added",
"length": 16,
"modified_ts": 1700000020,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
}
],
"description": "Delta replay: start from a snapshot index, decrypt each V3 delta payload, merge in order with source_tag = sha256(encrypted_delta)[:16]. Nonces pinned (fixture-only).",
"final_state": {
"_sync_meta": {
"last_merge_ts": 1700000020,
"source_count": 2,
"sources": [
"815ddfa9958c34a9",
"b9bd18009e005131"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {
"1": {
"deleted_ts": 1700000020,
"entry_hash": "",
"event_hash": "b19e885c9e23b3e99881c4700c2df22fe19fa400e3649dca819bf2712c7ad74c",
"source": "b9bd18009e005131"
}
}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "base-renamed",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"fixture_version": 1,
"mnemonic_id": "abandon12",
"snapshot_index": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "base",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
}
}
@@ -3,6 +3,7 @@
"files": [
"bip39_seeds.json",
"bip85_entropy.json",
"delta_replay.json",
"entries_index.json",
"fingerprints.json",
"index_keys.json",
@@ -10,14 +11,16 @@
"legacy_payloads.json",
"managed_seeds.json",
"nostr_keys.json",
"nostr_snapshot.json",
"password_kdf.json",
"passwords_v1.json",
"passwords_v2.json",
"sync_merge.json",
"totp.json",
"vault_v3_payload.json"
],
"fixture_version": 1,
"generator": "scripts/generate_ts_port_fixtures.py",
"python_commit": "f720d16208074003b10719a7e180c6fec4251c73",
"python_commit": "6d6ad5e2c538a0357e368d29a42e723b5d665c7a",
"redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile."
}
@@ -0,0 +1,64 @@
{
"chunk_limit": 800,
"chunk_metas": [
{
"event_id": null,
"hash": "c57f7c84461a7db48c478ce8f62a4a19be933a592436f2bedeb85bd54ca3ed48",
"id": "seedpass-chunk-0000",
"size": 800
},
{
"event_id": null,
"hash": "a8dbca0d027ec5af9f517bb543ce3e7f2e511b76ca8fd6656d0054fde796815b",
"id": "seedpass-chunk-0001",
"size": 800
},
{
"event_id": null,
"hash": "7672fd2923ba8ba868282176385d45400d4787fae28a9430df3c3df9253b24ef",
"id": "seedpass-chunk-0002",
"size": 800
},
{
"event_id": null,
"hash": "9fdc9b622219d3e2874e4eee96ba134d682a7f720478ba770462c5ade273510c",
"id": "seedpass-chunk-0003",
"size": 800
},
{
"event_id": null,
"hash": "dbf3068bf08dec2be41992e3163a5ded6bd7b10dd7f6dbc6ffd9fd0bc992f70a",
"id": "seedpass-chunk-0004",
"size": 800
},
{
"event_id": null,
"hash": "76fd3739a3e002e1921a47e3c0e3b947c2c3f45304deda86745279a5ef24d6c2",
"id": "seedpass-chunk-0005",
"size": 90
}
],
"chunks_b64": [
"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"
],
"compressed_b64": "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 truncated
"description": "Nostr snapshot model: gzip(encrypted index) split into <=limit chunks; chunk id 'seedpass-chunk-%04d', sha256 hash; manifest kind 30070 (d-tag = manifest id), chunks kind 30071 (d-tag = chunk id, content = b64), deltas kind 30072. Manifest id = HMAC-SHA256(key_index, b'manifest|' + nonce) hex, key_index = HKDF chain master->'seedpass:v1:index'. gzip mtime pinned to 0 for the fixture.",
"encrypted_b64": "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 truncated
"event_kinds": {
"delta": 30072,
"manifest": 30070,
"snapshot_chunk": 30071
},
"fixture_version": 1,
"key_index_hex": "96cef1333dc7ea0ea01ca46e5495e530c14313173c93506382d3c444b5a62743",
"manifest_id": "168836eb089b4d0b57f92b3b14eaeb9e875ee9dcd8f59c80f6d027e08f273c12",
"manifest_json": "{\"ver\": 1, \"algo\": \"gzip\", \"chunks\": [{\"id\": \"seedpass-chunk-0000\", \"size\": 800, \"hash\": \"c57f7c84461a7db48c478ce8f62a4a19be933a592436f2bedeb85bd54ca3ed48\", \"event_id\": null}, {\"id\": \"seedpass-chunk-0001\", \"size\": 800, \"hash\": \"a8dbca0d027ec5af9f517bb543ce3e7f2e511b76ca8fd6656d0054fde796815b\", \"event_id\": null}, {\"id\": \"seedpass-chunk-0002\", \"size\": 800, \"hash\": \"7672fd2923ba8ba868282176385d45400d4787fae28a9430df3c3df9253b24ef\", \"event_id\": null}, {\"id\": \"seedpass-chunk-0003\", \"size\": 800, \"hash\": \"9fdc9b622219d3e2874e4eee96ba134d682a7f720478ba770462c5ade273510c\", \"event_id\": null}, {\"id\": \"seedpass-chunk-0004\", \"size\": 800, \"hash\": \"dbf3068bf08dec2be41992e3163a5ded6bd7b10dd7f6dbc6ffd9fd0bc992f70a\", \"event_id\": null}, {\"id\": \"seedpass-chunk-0005\", \"size\": 90, \"hash\": \"76fd3739a3e002e1921a47e3c0e3b947c2c3f45304deda86745279a5ef24d6c2\", \"event_id\": null}], \"delta_since\": 1700000000, \"nonce\": \"PAqvfNIUFpEog1t93a3M4A==\", \"index0\": null}",
"manifest_nonce_b64": "PAqvfNIUFpEog1t93a3M4A==",
"mnemonic_id": "abandon12",
"unpinned_chunk_count": 6
}
@@ -0,0 +1,653 @@
{
"cases": [
{
"current": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site-renamed",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000010,
"source_count": 1,
"sources": [
"tag-newer"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site-renamed",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"name": "newer-incoming-wins",
"source_tag": "tag-newer"
},
{
"current": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site-old",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000010,
"source_count": 1,
"sources": [
"tag-older"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000010,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"name": "older-incoming-loses",
"source_tag": "tag-older"
},
{
"current": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [
"a",
"c"
],
"type": "password",
"username": ""
}
},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"archived": true,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000000,
"notes": "from-incoming",
"tags": [
"b",
"a"
],
"type": "password",
"username": "alice"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000000,
"source_count": 1,
"sources": [
"tag-equal"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"0": {
"archived": true,
"custom_fields": null,
"kind": "password",
"label": "site",
"length": 16,
"links": null,
"modified_ts": 1700000000,
"notes": "from-incoming",
"tags": [
"a",
"b",
"c"
],
"type": "password",
"username": "alice"
}
},
"schema_version": 4
},
"name": "equal-ts-field-union",
"source_tag": "tag-equal"
},
{
"current": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
},
"1": {
"archived": false,
"kind": "password",
"label": "keep",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"_deleted": true,
"archived": false,
"kind": "password",
"label": "site",
"length": 16,
"modified_ts": 1700000005,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000005,
"source_count": 1,
"sources": [
"tag-delete"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {
"0": {
"deleted_ts": 1700000005,
"entry_hash": "",
"event_hash": "54256f38e047c9c99df60917aa2ad798ac232df7c56247e6b1681875f9ec5e97",
"source": "tag-delete"
}
}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"1": {
"archived": false,
"kind": "password",
"label": "keep",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"name": "incoming-delete-creates-tombstone",
"source_tag": "tag-delete"
},
{
"current": {
"_sync_meta": {
"tombstones": {
"0": {
"deleted_ts": 1700000000,
"entry_hash": "",
"event_hash": "",
"source": "x"
}
}
},
"entries": {},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "revived",
"length": 16,
"modified_ts": 1700000020,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000020,
"source_count": 1,
"sources": [
"tag-revive"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "revived",
"length": 16,
"modified_ts": 1700000020,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"name": "entry-newer-than-tombstone-survives",
"source_tag": "tag-revive"
},
{
"current": {
"_sync_meta": {
"tombstones": {
"0": {
"deleted_ts": 1700000020,
"entry_hash": "",
"event_hash": "",
"source": "x"
}
}
},
"entries": {},
"schema_version": 4
},
"incoming": {
"entries": {
"0": {
"archived": false,
"kind": "password",
"label": "stale",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000020,
"source_count": 1,
"sources": [
"tag-stale"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {
"0": {
"deleted_ts": 1700000020,
"entry_hash": "",
"event_hash": "",
"source": "x"
}
}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {},
"schema_version": 4
},
"name": "tombstone-beats-older-entry",
"source_tag": "tag-stale"
},
{
"current": {
"_sync_meta": {
"tombstones": {
"0": {
"deleted_ts": 1700000001,
"entry_hash": "aa",
"event_hash": "",
"source": "one"
}
}
},
"entries": {},
"schema_version": 4
},
"incoming": {
"_sync_meta": {
"tombstones": {
"0": {
"deleted_ts": 1700000009,
"entry_hash": "bb",
"event_hash": "",
"source": "two"
}
}
},
"entries": {},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000009,
"source_count": 1,
"sources": [
"tag-tt"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {
"0": {
"deleted_ts": 1700000009,
"entry_hash": "bb",
"event_hash": "",
"source": "two"
}
}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {},
"schema_version": 4
},
"name": "tombstone-vs-tombstone-newer-wins",
"source_tag": "tag-tt"
},
{
"current": {},
"incoming": {
"entries": {
"3": {
"archived": false,
"kind": "password",
"label": "new",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"merged": {
"_sync_meta": {
"last_merge_ts": 1700000000,
"source_count": 1,
"sources": [
"tag-adopt"
],
"strategy": "modified_ts_hash_tombstone_v2",
"tombstones": {}
},
"_system": {
"index0": {
"canonical_views": {},
"checkpoints": {},
"events": {},
"heads": {},
"schema_version": 1,
"stats": {
"checkpoint_count": 0,
"event_count": 0,
"last_compaction_ts": 0,
"last_validation_ts": 0,
"writer_count": 0
},
"view_manifest": {
"builder_versions": {},
"canonical_view_types": [],
"local_only_view_types": [],
"version": 1
}
}
},
"entries": {
"3": {
"archived": false,
"kind": "password",
"label": "new",
"length": 16,
"modified_ts": 1700000000,
"notes": "",
"tags": [],
"type": "password"
}
},
"schema_version": 4
},
"name": "empty-current-adopts-incoming",
"source_tag": "tag-adopt"
}
],
"description": "merge_index_payloads parity cases. merged output includes the normalized empty _system.index0 skeleton and _sync_meta with strategy modified_ts_hash_tombstone_v2.",
"fixture_version": 1
}
+35
View File
@@ -22,6 +22,9 @@ import entriesIndexJson from "../fixtures/entries_index.json";
import vaultV3Json from "../fixtures/vault_v3_payload.json";
import passwordKdfJson from "../fixtures/password_kdf.json";
import legacyPayloadsJson from "../fixtures/legacy_payloads.json";
import nostrSnapshotJson from "../fixtures/nostr_snapshot.json";
import syncMergeJson from "../fixtures/sync_merge.json";
import deltaReplayJson from "../fixtures/delta_replay.json";
export interface Bip39Case {
id: string;
@@ -149,6 +152,38 @@ export interface Argon2Case {
export const pbkdf2Cases = passwordKdfJson.pbkdf2_cases as Pbkdf2Case[];
export const argon2idCases = passwordKdfJson.argon2id_cases as Argon2Case[];
export interface SyncMergeCase {
name: string;
current: Record<string, unknown>;
incoming: Record<string, unknown>;
source_tag: string;
merged: Record<string, unknown>;
}
export const nostrSnapshot = nostrSnapshotJson as {
mnemonic_id: string;
event_kinds: { manifest: number; snapshot_chunk: number; delta: number };
chunk_limit: number;
encrypted_b64: string;
compressed_b64: string;
chunks_b64: string[];
chunk_metas: { id: string; size: number; hash: string; event_id: string | null }[];
key_index_hex: string;
manifest_nonce_b64: string;
manifest_id: string;
manifest_json: string;
};
export const syncMergeCases = (syncMergeJson as { cases: SyncMergeCase[] }).cases;
export const deltaReplay = deltaReplayJson as {
mnemonic_id: string;
snapshot_index: Record<string, unknown>;
delta_payloads_b64: string[];
delta_plaintexts: Record<string, unknown>[];
final_state: Record<string, unknown>;
};
export const legacyPayloads = legacyPayloadsJson as {
mnemonic_id: string;
plaintext_utf8: string;
+272
View File
@@ -543,6 +543,275 @@ def gen_legacy_payloads() -> dict:
}
def _derive_key_index(mnemonic: str) -> bytes:
from utils.key_hierarchy import kd
seed = Bip39SeedGenerator(mnemonic).Generate()
master = kd(seed, b"seedpass:v1:master")
return kd(master, b"seedpass:v1:index")
def gen_nostr_snapshot(vault_payload_b64: str) -> dict:
import gzip as gzip_mod
import hmac as hmac_mod
from nostr.snapshot import prepare_snapshot
mnemonic = MNEMONICS[PRIMARY]
encrypted = base64.b64decode(vault_payload_b64)
# mtime=0 pins the gzip header; production uses current time, which only
# affects the compressed bytes, not decompression.
compressed = gzip_mod.compress(encrypted, mtime=0)
limit = 800
manifest, chunks = prepare_snapshot(encrypted, limit)
# Re-chunk the pinned compression so chunk bytes/hashes are deterministic
pinned_chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas = [
{
"id": f"seedpass-chunk-{i:04d}",
"size": len(c),
"hash": hashlib.sha256(c).hexdigest(),
"event_id": None,
}
for i, c in enumerate(pinned_chunks)
]
nonce = hashlib.sha256(b"seedpass-ts-fixture-manifest-nonce").digest()[:16]
key_index = _derive_key_index(mnemonic)
manifest_id = hmac_mod.new(
key_index, b"manifest|" + nonce, hashlib.sha256
).hexdigest()
manifest_json = json.dumps(
{
"ver": 1,
"algo": "gzip",
"chunks": metas,
"delta_since": FIXED_UNIX,
"nonce": base64.b64encode(nonce).decode(),
"index0": None,
}
)
return {
"description": (
"Nostr snapshot model: gzip(encrypted index) split into <=limit "
"chunks; chunk id 'seedpass-chunk-%04d', sha256 hash; manifest "
"kind 30070 (d-tag = manifest id), chunks kind 30071 (d-tag = "
"chunk id, content = b64), deltas kind 30072. Manifest id = "
"HMAC-SHA256(key_index, b'manifest|' + nonce) hex, key_index = "
"HKDF chain master->'seedpass:v1:index'. gzip mtime pinned to 0 "
"for the fixture."
),
"mnemonic_id": PRIMARY,
"event_kinds": {"manifest": 30070, "snapshot_chunk": 30071, "delta": 30072},
"chunk_limit": limit,
"encrypted_b64": vault_payload_b64,
"compressed_b64": base64.b64encode(compressed).decode(),
"chunks_b64": [base64.b64encode(c).decode() for c in pinned_chunks],
"chunk_metas": metas,
"key_index_hex": key_index.hex(),
"manifest_nonce_b64": base64.b64encode(nonce).decode(),
"manifest_id": manifest_id,
"manifest_json": manifest_json,
"unpinned_chunk_count": len(chunks),
}
def _merge_case(name: str, current: dict, incoming: dict, source_tag: str) -> dict:
import copy
from seedpass.core.sync_conflict import merge_index_payloads
merged = merge_index_payloads(
copy.deepcopy(current), copy.deepcopy(incoming), source_tag=source_tag
)
return {
"name": name,
"current": current,
"incoming": incoming,
"source_tag": source_tag,
"merged": merged,
}
def gen_sync_merge() -> dict:
t = FIXED_UNIX
def entry(label: str, ts: int, **kw) -> dict:
base = {
"type": "password",
"kind": "password",
"label": label,
"length": 16,
"archived": False,
"notes": "",
"tags": [],
"modified_ts": ts,
}
base.update(kw)
return base
cases = [
_merge_case(
"newer-incoming-wins",
{"schema_version": 4, "entries": {"0": entry("site", t)}},
{"schema_version": 4, "entries": {"0": entry("site-renamed", t + 10)}},
"tag-newer",
),
_merge_case(
"older-incoming-loses",
{"schema_version": 4, "entries": {"0": entry("site", t + 10)}},
{"schema_version": 4, "entries": {"0": entry("site-old", t)}},
"tag-older",
),
_merge_case(
"equal-ts-field-union",
{
"schema_version": 4,
"entries": {
"0": entry("site", t, notes="", tags=["a", "c"], username="")
},
},
{
"schema_version": 4,
"entries": {
"0": entry(
"site", t, notes="from-incoming", tags=["b", "a"],
username="alice", archived=True,
)
},
},
"tag-equal",
),
_merge_case(
"incoming-delete-creates-tombstone",
{"schema_version": 4, "entries": {"0": entry("site", t), "1": entry("keep", t)}},
{
"schema_version": 4,
"entries": {"0": {**entry("site", t + 5), "_deleted": True}},
},
"tag-delete",
),
_merge_case(
"entry-newer-than-tombstone-survives",
{
"schema_version": 4,
"entries": {},
"_sync_meta": {
"tombstones": {
"0": {"deleted_ts": t, "entry_hash": "", "event_hash": "", "source": "x"}
}
},
},
{"schema_version": 4, "entries": {"0": entry("revived", t + 20)}},
"tag-revive",
),
_merge_case(
"tombstone-beats-older-entry",
{
"schema_version": 4,
"entries": {},
"_sync_meta": {
"tombstones": {
"0": {"deleted_ts": t + 20, "entry_hash": "", "event_hash": "", "source": "x"}
}
},
},
{"schema_version": 4, "entries": {"0": entry("stale", t)}},
"tag-stale",
),
_merge_case(
"tombstone-vs-tombstone-newer-wins",
{
"schema_version": 4,
"entries": {},
"_sync_meta": {
"tombstones": {
"0": {"deleted_ts": t + 1, "entry_hash": "aa", "event_hash": "", "source": "one"}
}
},
},
{
"schema_version": 4,
"entries": {},
"_sync_meta": {
"tombstones": {
"0": {"deleted_ts": t + 9, "entry_hash": "bb", "event_hash": "", "source": "two"}
}
},
},
"tag-tt",
),
_merge_case(
"empty-current-adopts-incoming",
{},
{"schema_version": 4, "entries": {"3": entry("new", t)}},
"tag-adopt",
),
]
return {
"description": (
"merge_index_payloads parity cases. merged output includes the "
"normalized empty _system.index0 skeleton and _sync_meta with "
"strategy modified_ts_hash_tombstone_v2."
),
"cases": cases,
}
def gen_delta_replay() -> dict:
import copy
from seedpass.core.index0 import ensure_index0_payload
from seedpass.core.sync_conflict import merge_index_payloads
key = base64.urlsafe_b64decode(derive_index_key(MNEMONICS[PRIMARY]))
t = FIXED_UNIX
def entry(label: str, ts: int) -> dict:
return {
"type": "password", "kind": "password", "label": label,
"length": 16, "archived": False, "notes": "", "tags": [],
"modified_ts": ts,
}
snapshot_index = {"schema_version": 4, "entries": {"0": entry("base", t)}}
delta1 = {"schema_version": 4, "entries": {"0": entry("base-renamed", t + 10), "1": entry("added", t + 10)}}
delta2 = {"schema_version": 4, "entries": {"1": {**entry("added", t + 20), "_deleted": True}}}
def encrypt(payload: dict, nonce_seed: bytes) -> bytes:
nonce = hashlib.sha256(nonce_seed).digest()[:12]
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return b"V3|" + nonce + AESGCM(key).encrypt(nonce, plaintext, None)
enc_deltas = [
encrypt(delta1, b"seedpass-ts-fixture-delta-1"),
encrypt(delta2, b"seedpass-ts-fixture-delta-2"),
]
# Reproduce decrypt_and_save_index_from_nostr(merge=True) semantics
state = ensure_index0_payload(copy.deepcopy(snapshot_index))
for enc, payload in zip(enc_deltas, [delta1, delta2]):
source_tag = hashlib.sha256(enc).hexdigest()[:16]
incoming = ensure_index0_payload(copy.deepcopy(payload))
state = merge_index_payloads(state, incoming, source_tag=source_tag)
return {
"description": (
"Delta replay: start from a snapshot index, decrypt each V3 delta "
"payload, merge in order with source_tag = "
"sha256(encrypted_delta)[:16]. Nonces pinned (fixture-only)."
),
"mnemonic_id": PRIMARY,
"snapshot_index": snapshot_index,
"delta_payloads_b64": [base64.b64encode(e).decode() for e in enc_deltas],
"delta_plaintexts": [delta1, delta2],
"final_state": state,
}
def gen_kdf_metadata() -> dict:
return {
"description": (
@@ -591,6 +860,9 @@ def main() -> None:
entries_fixture, vault_fixture = gen_entries_and_vault()
files["entries_index.json"] = entries_fixture
files["vault_v3_payload.json"] = vault_fixture
files["nostr_snapshot.json"] = gen_nostr_snapshot(vault_fixture["payload_b64"])
files["sync_merge.json"] = gen_sync_merge()
files["delta_replay.json"] = gen_delta_replay()
for name, data in files.items():
data["fixture_version"] = FIXTURE_VERSION