From 2345f614310a701a147872e81c53ccd22f8948d3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:33:42 -0400 Subject: [PATCH] =?UTF-8?q?TS=20port=20slice=203:=20Nostr=20sync=20model?= =?UTF-8?q?=20=E2=80=94=20snapshots,=20manifests,=20conflict=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/typescript_port_compatibility_matrix.md | 10 +- js/packages/core/src/index.ts | 26 + js/packages/core/src/sync/canonical.ts | 83 +++ js/packages/core/src/sync/merge.ts | 385 +++++++++++ js/packages/core/src/sync/snapshot.ts | 152 ++++ js/packages/core/test/sync.test.ts | 123 ++++ .../test-vectors/fixtures/delta_replay.json | 121 ++++ .../test-vectors/fixtures/manifest.json | 5 +- .../test-vectors/fixtures/nostr_snapshot.json | 64 ++ .../test-vectors/fixtures/sync_merge.json | 653 ++++++++++++++++++ js/packages/test-vectors/src/index.ts | 35 + scripts/generate_ts_port_fixtures.py | 272 ++++++++ 12 files changed, 1924 insertions(+), 5 deletions(-) create mode 100644 js/packages/core/src/sync/canonical.ts create mode 100644 js/packages/core/src/sync/merge.ts create mode 100644 js/packages/core/src/sync/snapshot.ts create mode 100644 js/packages/core/test/sync.test.ts create mode 100644 js/packages/test-vectors/fixtures/delta_replay.json create mode 100644 js/packages/test-vectors/fixtures/nostr_snapshot.json create mode 100644 js/packages/test-vectors/fixtures/sync_merge.json diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 48053231..7d662923 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -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) diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 078a89aa..0742c553 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -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"; diff --git a/js/packages/core/src/sync/canonical.ts b/js/packages/core/src/sync/canonical.ts new file mode 100644 index 00000000..b80a5072 --- /dev/null +++ b/js/packages/core/src/sync/canonical.ts @@ -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 = { + '"': '\\"', + "\\": "\\\\", + "\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).sort(); + const parts = keys.map( + (k) => encodeString(k) + ":" + canonicalJson((value as Record)[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)))); +} diff --git a/js/packages/core/src/sync/merge.ts b/js/packages/core/src/sync/merge.ts new file mode 100644 index 00000000..4b3fd0cc --- /dev/null +++ b/js/packages/core/src/sync/merge.ts @@ -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; + +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 { + const out: Record = {}; + 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, + incoming: Record, +): Record { + const merged: Record = { ...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): 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 = { + 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(); + 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; +} diff --git a/js/packages/core/src/sync/snapshot.ts b/js/packages/core/src/sync/snapshot.ts new file mode 100644 index 00000000..80c88b96 --- /dev/null +++ b/js/packages/core/src/sync/snapshot.ts @@ -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; +export type Manifest = z.infer; + +export function parseManifest(json: string): Manifest { + return manifestSchema.parse(JSON.parse(json)); +} + +async function pipeThrough( + data: Uint8Array, + stream: { readable: ReadableStream; writable: WritableStream }, +): Promise { + 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 { + return pipeThrough(data, new CompressionStream("gzip")); +} + +export async function gzipDecompress(data: Uint8Array): Promise { + 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 { + 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 }; +} diff --git a/js/packages/core/test/sync.test.ts b/js/packages/core/test/sync.test.ts new file mode 100644 index 00000000..83bb75a2 --- /dev/null +++ b/js/packages/core/test/sync.test.ts @@ -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; + }; + const backward = mergeIndexPayloads(a.incoming, a.current, "t") as { + entries: Record; + }; + 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); + }); +}); diff --git a/js/packages/test-vectors/fixtures/delta_replay.json b/js/packages/test-vectors/fixtures/delta_replay.json new file mode 100644 index 00000000..2a16005e --- /dev/null +++ b/js/packages/test-vectors/fixtures/delta_replay.json @@ -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 + } +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index 2435fe1b..78cc032e 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -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." } diff --git a/js/packages/test-vectors/fixtures/nostr_snapshot.json b/js/packages/test-vectors/fixtures/nostr_snapshot.json new file mode 100644 index 00000000..b4e41d8e --- /dev/null +++ b/js/packages/test-vectors/fixtures/nostr_snapshot.json @@ -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": "H4sIAAAAAAACAwHjDxzwVjN8yABUBOsovylao1dfY22Y+jyucdrP9HwxZksfJP00vjARSES7X9CGz/sCp7ZcIm3Ok3+n478l25p90hmLwZTTO02lv3C3leTqTt680X7TtqbHdEB1RAxkan+TkA18n4UCOw7z3C17AT0qoV3Drvgz5qLhyvTrrMOPxqa32mIUgkVpYAu75KBhS4PMR0FNE/KdEpTvR/De1B+Xk3gb66IfpEsqq5TArksXd7955thFWJVvm1m1amJyUEwk95JSThwVq5SENnISGG7RH7s3z/Ah4hoQCzM/IrN1+tt2Sqg5BHSpemsg2vMSwYlobqggf8P95yYfiIBZ6mkWtA8OfMtm7dYkQDvxOpciy4XDX1BtV9rkFgLnSmptePwMhuHoCVsnpkq0PKttBHFA0eieup6wvI9AODZ60Vi/Saol1oyjNQwmvqiM8JqPwkbr2bEZLfOIMddPAVtktQJV0BtBRAdmU4UwkfufUOSHYYK8fxvJefJU/sq5H/AN2iUsUXaSnJaYnj+Dr5pV/ouYeq0tUCt2p/Shrf3+YZQlovnTkGCLGcLaBqXTPW2uAbf+G5HAgpr1MZwEFhIOWjt8lGQPIHFqYCtC6IcFiOn95oZYnqofGrUpvERLXjLO+8KFd902Kl2ymC6MWmTAR68ab5WR1TzrhFBBp1lOc1sOuTLiXb9wnS0T/JquX1QRplZLnxHP2gzDZsRMVzTY5WgSNvjye5c3qQWvftGs7iIjjTdUt20zSwonFErRCl7AXVkJmiUmTwt4por69NYBLDFMUZAFv82n+jC1Mk5Ixh23SYMXn9ptXM7PdHKDr1r7UNrnRBM33Zzh752A3QzfAlYIorD4usWFgz+lG8Xw97Ef/QVCaRsge3TUlat/MARldvbCCWSB7RiFmWbzyQVIP8MHXoTX0fSdvtSRBRbQUFv2ImotG3E8vgPObiv65LtmPJf6HkhosGX0VIKLgGJj4Mzi63BN0Fu/+/FEHIomX+tlQAuU/HNsWmGIogvqylKT/Bd7AyYaUw9TDg6hlbnoZDpALziNL4m3m74b5867RBPqLfpR8nh9rsPEBWMqgw4x7y7x21q3VPcNn/IXprDPbaAXsxPYG8SJDbXej0cY3MnAfKigVVSSiSgeCjuTmb7uc1JO6pR9/QQYMqFD4uPJKsD7xAn//6P399EttsAKLAEn4KYNfafIsXnZaGvCiXO2Y5sqrR1Sb+OwZmi69ZnxXd86fu06BfqxzAyppwSe8wyCdA9BUs6AOsoVGr7eh3i4of1k6h7YcRGwXnKUaA+I4ofG3aMKvjluaTlPK7b5NZ0Tay4Bi2oGLTZDeUYQrG2LRa7RhsCkDLFsXXV6W+R02GANX+PWtKNvQPMfjbAlGOwrz/PHjxI4N6831Vv+UPN1KiJVmOLA7/xPRMSC+jEe6+TNmk5dQXg7J8B6cXNec1Q8c5GTXJTqjHSVnD6J1vGGBWD+OdanxrgAN+lMCGVEPhhcvm64UkVxaKQLGwpBxcyAIQvdj7vOZuDM7mVfQ69l0FqrQVebx2BQgyDDUTNctA2OrX0J3RhPQ0X88DNvdieZ6uu+r10U4x0H7E9XUOGLmf3TOKQtIkb23oyVA03pZlA1M5fTGM55wBmtspHFRz+hzuv+bjw1oF+tzww3SvWBFFHYayzcGGj+mjaZ/+uuln/qW+TpWYGhtFmuFIl046ZdGpwA8k7XIyWhcz+pWaFR8dJ9JGWf2qDik3BOl0MvXY0+/9FFq/NY4wqopIPCG8CnsKhv3oE7IOEWls44xAClHV2tMD24SApQ638LCWTvyT9IUdDpKsueg4V1oP73TE5FIAA3al5Hpmb/mBVUjE+7EiywmdY/1OSkg99sQ+KZRgDHicmwpupxWG0cDE/rK052giUqdIe71iR89ijZm/ywsSVvDarNtWuPdTfrXtpXlyEfosPaTJ+ToRr3yOr6R3w4p7AxaXvibRvoUIeQCHfVVXZ32209EA7BKKiuIQugi8dPO3cVsC7xqTjm0WC2B7tCcD03OwHQ9465p3KM1SvwVofcvqxQn1XuhUsoRAIw5NkLBvHTdSiibqct2N63wuAOohvxlIljcWKSkxWNi60mT/sZlws/jcmHQLxhvm7yaS1yOwj46LiE/Zqov4DbEKcZ/UKH0vx8sJ8yfKGAfuRDuZ10LjKuUPfOo6QwtV+qDGwKaYq2C1p8T/QlAQLKy2YxaQLdc9j9r1uzMuvxwDAb5VshJYGUvDG2E2h34tSo3I+KEHsz6prpClCONSAStCFQnEJbXG6M9Nl/KptbIvkPPsb0kxv8hLP66u1e2HmyHGlmMl72t62sScgGRs7dsxgOda9N0ZMas50MsUiG6APGt9VAg1zkt29G+izPrDB6Ca1GzxImifYAKlOjj+CZ6VCo9NjM/3oGXetM7QqZGtDSjUaSeNgvp9m/WMUuwfXZTOd8Y8scRXtVBQ80jr6R9HBA6jcyusNGQDVsAaGWJOVl/+gKk33qjm7lAM4zuPjPQoYSGr14GqQ6dygT4z5qmeCDarY8lLZabbREoez7NO3FAxAi45x6PII/UzpvluEUgdRLv6WDYwyITaida+P9s9uF3AQodQ2z95rdlmpjIh1hls3F/Phl6VKdNNjY4elo4i2MVgaeI6uKb8iK9VnGoQwWIEV9u+8XEKgKg3mOsTF6f83MVB+F8AwdQAPlmvD/cU1cI5ww3sBlocJkhZvoYpChjMFMgZWb5U46ML6lJbw3ODpsjatNM9v4ZxuWLV7ffJ+aHCRFVRn/I+AVbKB0gZ5X5qtK+5VomvQL7y1Zjr5ehYWQf6VFHIVY7HOv874ghGi6G00Scb2316xIImT0hxAzzwryZTBAwkiuQGfpJyAvmsotcVobg2Rd52EH3Okc0eZXIZ45B5r3pKhLxYisCsUNujGDNC2bQAmkyXEJW5QJ2+VHPOaJMUhkb28OvVcqVzUwMHkrWWZw3zBM71YaOezdHzZSu4urdazodtEgvFP32TSx2UhhFUInwGYnXkHZ75gSwekWMaBcKgzC8lJehTWtIlpKp1miZhEw2w91fIFwuZ7a/H0Tfe8hK2ionMFCNlRSorKKeD33Z8aDR0tWogJ1qZ8mgx0PwbdAtWhWpVBfN0rgGTb2IQDndW7kAe2bM8N8O4Sqx48J14HHjsodpD60b9rCiZGkI7TucMq1G6uJMCTKx+5VC0VuKypbkNf8KfFFGEI7ns47wo+5e554b8glcU3We6DB6h79SBorGUjxNsD1/5dOKcuuELpVIXi+SbEwgdgZFmfg0IQusASBxRcXGEIq4mlfHGW9y6rNp2v0COlaM+/Ls4Uf5aYZULiG4GEJ5k8wBsUtCh7GaGrlmXZG7E5oLUWjv7Gj4s+nuSz2/E9vsXpUKfME8TNYrdX2n6x9Ko4LvQECzPF2wWEtA1x5OiWiLAo5y4vifZyL+nFRArW5AwML8RMHdTeKBUdZO24HN9VWeVgVyXKwwo6P0KpEwr2zt75SSDd5l1Wvu3FuyheEdkoAJRzpchl6QIN4EFztpzJYrvITVzkQZIsnZnfrp/3twAYagluvQGHQqYE4sxLslB4O+aVNBTA6UaPQGsWlf2g2iarM+IyClnbrvCc0w6GtHqWarb68/28jPMgi5QwmI+ukLNuvNWL4vkrCwwpA+r2vJu0ywW60TBsvALzgubgiFBjaZ8KrI9qNfoGUamCwdBKJhzZd84O8k8k7VUGnt/cdoNYAjZo5n/zjDRU6pBnHt8Ow5GSFZkVp03UYyWuCXP4KK95U/xmeyIv9Vp5Kd8A/lMoDX2XiaggmVIzz20KAqWDIJBxGgYr6waBgvDoUBE0n/lfvgxAcdDUu/cUV4Ol8wEcC7yVTitai+9Hi0zay4MKIclLPI3eE1kCpnEOeBNoBMTVxHb4ee2sP3QOBWFGzhXSEunXkW3Y/FbBhZTzHWAYzO6mW/pEMAmhkNIEwjLGcq1gx5ymV7EJ4w09iLXRWKSDl9Wqbvsmf32C9rA0vkO4JOQGHrV7zyf1FdaYxqcYB4IN+JOY7hRzr14EVjMzIpwX8FPEQyNSreqhBo2UrHNV1hbLvLn3Xkjyxq9fb83cc9l8lXJkmcdBChhezE3rhFemwmkPLI0ftCf26sLWB9ieHP1dnGpak8PBeEi69tfDXbsX+r+hTS46O4NNraTdh0ojYso/ZtLot3pGMkjDdTBmChwmg945sJl8oSJLA70oXVDDoRVQOlqqukfc5WuDuniwIyv86Y6lZ6EQ7AyLDrHAfUV27cpyar3wdO1IM5M3Cg1snpsJ3k/saeplkBkPz1yRWfHtFsGQiRMU5AzfD4f5XOeg/xtZsWsIHylVUBizBO2LLy9UQ5e5xYbCXqptbCGJUXeUeP6D1LkQzQdmmL6gBB+ipvENccIuJ62QqCfKvA5Chh+EaccaDJPmcZuVTz0UPubrnHDV4DMdYZY49lHNdAG2UtXty8d3/TztRJ3pLDV7MX9+Xs3PEzPoJ8pv0XmaXe2zI4wV9DD9pSMAUQPhKwwZpyKAQXfrUl8ztAKNdwzR83DiEcsqSYlOwZWJr4HnF8VOvYfjbo4jxE0akHUx+ESCPiDIcYLNHmxwoxrTqqWxDIuU0cdQwLyJXJPHiWi2ofvllgCygQo6lSGD1jjowS5wRE8O/Jfn3HPe4FJcrq4DGe43fzSgCNGfCamL2JXw/DJ7f59KchtoATFC2K6MalOhHJNP82yLtWrzvjd4Xrk6lmI3ri9xOEvBHM2NiDoYU8hgVm1hjuOOQT4I2sSLWL1f96fmn70e98pMWvG9ABSeklp6Z05uP46lvEFL2vd+3PaoQZvdRXFMj1jMOS0eBSEObRDmvqfFslL2ReVSpevTNVOL/kK6kNl5SHjN1MOjPabSg/FHOtvZ1IYx6uGSwPh1uTnTbnprEXlRoAp474ZBMy2WCm8yNNe/vMxKG+OFPOnOl0xBkxNgLLh2pv7XCFeIf4pMjgOs9heOiTOloXyTDfMUv9PcRuU7WhH6C6iOFRi4DlZ6pYK0nE776netMiAtWSIfys+wlI2BtoMv6p/3IDM47k5oSopNuz6dwHtEO3Zsa4OiT1Nwy2ToPYGIvR7rf/g9F1fdqqJnc7x3ZFWJGiLoh4Yb+cZJCsMb9KLk8JRnClcwxaj0Zug1KKvJ8iRDb1CKsZjLPNLtRWiE655JZXJUb2tWNCu8KFaj64qPFkUsjI5mXiEDYcPxxQS1dcWrnH+yNfSz6SK7Upt8L0C5ui/rO8qVRetcv3zxA+269xTLG7mGU/4xspMOEsdZzCulTokfQhtsNKoNq0XY62keF89SSkcEYdb/0gy+GeJUlMXZpMHB4Ep3ckgXxKqdCFzfhw2i17g2ZZI57TVZeJFYfp5wXQt7aaFYFPIRCCrkhHLEfsVdhp/1mspxfM1Orq3peJPpG4w8AAA==", + "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": "VjN8yABUBOsovylao1dfY22Y+jyucdrP9HwxZksfJP00vjARSES7X9CGz/sCp7ZcIm3Ok3+n478l25p90hmLwZTTO02lv3C3leTqTt680X7TtqbHdEB1RAxkan+TkA18n4UCOw7z3C17AT0qoV3Drvgz5qLhyvTrrMOPxqa32mIUgkVpYAu75KBhS4PMR0FNE/KdEpTvR/De1B+Xk3gb66IfpEsqq5TArksXd7955thFWJVvm1m1amJyUEwk95JSThwVq5SENnISGG7RH7s3z/Ah4hoQCzM/IrN1+tt2Sqg5BHSpemsg2vMSwYlobqggf8P95yYfiIBZ6mkWtA8OfMtm7dYkQDvxOpciy4XDX1BtV9rkFgLnSmptePwMhuHoCVsnpkq0PKttBHFA0eieup6wvI9AODZ60Vi/Saol1oyjNQwmvqiM8JqPwkbr2bEZLfOIMddPAVtktQJV0BtBRAdmU4UwkfufUOSHYYK8fxvJefJU/sq5H/AN2iUsUXaSnJaYnj+Dr5pV/ouYeq0tUCt2p/Shrf3+YZQlovnTkGCLGcLaBqXTPW2uAbf+G5HAgpr1MZwEFhIOWjt8lGQPIHFqYCtC6IcFiOn95oZYnqofGrUpvERLXjLO+8KFd902Kl2ymC6MWmTAR68ab5WR1TzrhFBBp1lOc1sOuTLiXb9wnS0T/JquX1QRplZLnxHP2gzDZsRMVzTY5WgSNvjye5c3qQWvftGs7iIjjTdUt20zSwonFErRCl7AXVkJmiUmTwt4por69NYBLDFMUZAFv82n+jC1Mk5Ixh23SYMXn9ptXM7PdHKDr1r7UNrnRBM33Zzh752A3QzfAlYIorD4usWFgz+lG8Xw97Ef/QVCaRsge3TUlat/MARldvbCCWSB7RiFmWbzyQVIP8MHXoTX0fSdvtSRBRbQUFv2ImotG3E8vgPObiv65LtmPJf6HkhosGX0VIKLgGJj4Mzi63BN0Fu/+/FEHIomX+tlQAuU/HNsWmGIogvqylKT/Bd7AyYaUw9TDg6hlbnoZDpALziNL4m3m74b5867RBPqLfpR8nh9rsPEBWMqgw4x7y7x21q3VPcNn/IXprDPbaAXsxPYG8SJDbXej0cY3MnAfKigVVSSiSgeCjuTmb7uc1JO6pR9/QQYMqFD4uPJKsD7xAn//6P399EttsAKLAEn4KYNfafIsXnZaGvCiXO2Y5sqrR1Sb+OwZmi69ZnxXd86fu06BfqxzAyppwSe8wyCdA9BUs6AOsoVGr7eh3i4of1k6h7YcRGwXnKUaA+I4ofG3aMKvjluaTlPK7b5NZ0Tay4Bi2oGLTZDeUYQrG2LRa7RhsCkDLFsXXV6W+R02GANX+PWtKNvQPMfjbAlGOwrz/PHjxI4N6831Vv+UPN1KiJVmOLA7/xPRMSC+jEe6+TNmk5dQXg7J8B6cXNec1Q8c5GTXJTqjHSVnD6J1vGGBWD+OdanxrgAN+lMCGVEPhhcvm64UkVxaKQLGwpBxcyAIQvdj7vOZuDM7mVfQ69l0FqrQVebx2BQgyDDUTNctA2OrX0J3RhPQ0X88DNvdieZ6uu+r10U4x0H7E9XUOGLmf3TOKQtIkb23oyVA03pZlA1M5fTGM55wBmtspHFRz+hzuv+bjw1oF+tzww3SvWBFFHYayzcGGj+mjaZ/+uuln/qW+TpWYGhtFmuFIl046ZdGpwA8k7XIyWhcz+pWaFR8dJ9JGWf2qDik3BOl0MvXY0+/9FFq/NY4wqopIPCG8CnsKhv3oE7IOEWls44xAClHV2tMD24SApQ638LCWTvyT9IUdDpKsueg4V1oP73TE5FIAA3al5Hpmb/mBVUjE+7EiywmdY/1OSkg99sQ+KZRgDHicmwpupxWG0cDE/rK052giUqdIe71iR89ijZm/ywsSVvDarNtWuPdTfrXtpXlyEfosPaTJ+ToRr3yOr6R3w4p7AxaXvibRvoUIeQCHfVVXZ32209EA7BKKiuIQugi8dPO3cVsC7xqTjm0WC2B7tCcD03OwHQ9465p3KM1SvwVofcvqxQn1XuhUsoRAIw5NkLBvHTdSiibqct2N63wuAOohvxlIljcWKSkxWNi60mT/sZlws/jcmHQLxhvm7yaS1yOwj46LiE/Zqov4DbEKcZ/UKH0vx8sJ8yfKGAfuRDuZ10LjKuUPfOo6QwtV+qDGwKaYq2C1p8T/QlAQLKy2YxaQLdc9j9r1uzMuvxwDAb5VshJYGUvDG2E2h34tSo3I+KEHsz6prpClCONSAStCFQnEJbXG6M9Nl/KptbIvkPPsb0kxv8hLP66u1e2HmyHGlmMl72t62sScgGRs7dsxgOda9N0ZMas50MsUiG6APGt9VAg1zkt29G+izPrDB6Ca1GzxImifYAKlOjj+CZ6VCo9NjM/3oGXetM7QqZGtDSjUaSeNgvp9m/WMUuwfXZTOd8Y8scRXtVBQ80jr6R9HBA6jcyusNGQDVsAaGWJOVl/+gKk33qjm7lAM4zuPjPQoYSGr14GqQ6dygT4z5qmeCDarY8lLZabbREoez7NO3FAxAi45x6PII/UzpvluEUgdRLv6WDYwyITaida+P9s9uF3AQodQ2z95rdlmpjIh1hls3F/Phl6VKdNNjY4elo4i2MVgaeI6uKb8iK9VnGoQwWIEV9u+8XEKgKg3mOsTF6f83MVB+F8AwdQAPlmvD/cU1cI5ww3sBlocJkhZvoYpChjMFMgZWb5U46ML6lJbw3ODpsjatNM9v4ZxuWLV7ffJ+aHCRFVRn/I+AVbKB0gZ5X5qtK+5VomvQL7y1Zjr5ehYWQf6VFHIVY7HOv874ghGi6G00Scb2316xIImT0hxAzzwryZTBAwkiuQGfpJyAvmsotcVobg2Rd52EH3Okc0eZXIZ45B5r3pKhLxYisCsUNujGDNC2bQAmkyXEJW5QJ2+VHPOaJMUhkb28OvVcqVzUwMHkrWWZw3zBM71YaOezdHzZSu4urdazodtEgvFP32TSx2UhhFUInwGYnXkHZ75gSwekWMaBcKgzC8lJehTWtIlpKp1miZhEw2w91fIFwuZ7a/H0Tfe8hK2ionMFCNlRSorKKeD33Z8aDR0tWogJ1qZ8mgx0PwbdAtWhWpVBfN0rgGTb2IQDndW7kAe2bM8N8O4Sqx48J14HHjsodpD60b9rCiZGkI7TucMq1G6uJMCTKx+5VC0VuKypbkNf8KfFFGEI7ns47wo+5e554b8glcU3We6DB6h79SBorGUjxNsD1/5dOKcuuELpVIXi+SbEwgdgZFmfg0IQusASBxRcXGEIq4mlfHGW9y6rNp2v0COlaM+/Ls4Uf5aYZULiG4GEJ5k8wBsUtCh7GaGrlmXZG7E5oLUWjv7Gj4s+nuSz2/E9vsXpUKfME8TNYrdX2n6x9Ko4LvQECzPF2wWEtA1x5OiWiLAo5y4vifZyL+nFRArW5AwML8RMHdTeKBUdZO24HN9VWeVgVyXKwwo6P0KpEwr2zt75SSDd5l1Wvu3FuyheEdkoAJRzpchl6QIN4EFztpzJYrvITVzkQZIsnZnfrp/3twAYagluvQGHQqYE4sxLslB4O+aVNBTA6UaPQGsWlf2g2iarM+IyClnbrvCc0w6GtHqWarb68/28jPMgi5QwmI+ukLNuvNWL4vkrCwwpA+r2vJu0ywW60TBsvALzgubgiFBjaZ8KrI9qNfoGUamCwdBKJhzZd84O8k8k7VUGnt/cdoNYAjZo5n/zjDRU6pBnHt8Ow5GSFZkVp03UYyWuCXP4KK95U/xmeyIv9Vp5Kd8A/lMoDX2XiaggmVIzz20KAqWDIJBxGgYr6waBgvDoUBE0n/lfvgxAcdDUu/cUV4Ol8wEcC7yVTitai+9Hi0zay4MKIclLPI3eE1kCpnEOeBNoBMTVxHb4ee2sP3QOBWFGzhXSEunXkW3Y/FbBhZTzHWAYzO6mW/pEMAmhkNIEwjLGcq1gx5ymV7EJ4w09iLXRWKSDl9Wqbvsmf32C9rA0vkO4JOQGHrV7zyf1FdaYxqcYB4IN+JOY7hRzr14EVjMzIpwX8FPEQyNSreqhBo2UrHNV1hbLvLn3Xkjyxq9fb83cc9l8lXJkmcdBChhezE3rhFemwmkPLI0ftCf26sLWB9ieHP1dnGpak8PBeEi69tfDXbsX+r+hTS46O4NNraTdh0ojYso/ZtLot3pGMkjDdTBmChwmg945sJl8oSJLA70oXVDDoRVQOlqqukfc5WuDuniwIyv86Y6lZ6EQ7AyLDrHAfUV27cpyar3wdO1IM5M3Cg1snpsJ3k/saeplkBkPz1yRWfHtFsGQiRMU5AzfD4f5XOeg/xtZsWsIHylVUBizBO2LLy9UQ5e5xYbCXqptbCGJUXeUeP6D1LkQzQdmmL6gBB+ipvENccIuJ62QqCfKvA5Chh+EaccaDJPmcZuVTz0UPubrnHDV4DMdYZY49lHNdAG2UtXty8d3/TztRJ3pLDV7MX9+Xs3PEzPoJ8pv0XmaXe2zI4wV9DD9pSMAUQPhKwwZpyKAQXfrUl8ztAKNdwzR83DiEcsqSYlOwZWJr4HnF8VOvYfjbo4jxE0akHUx+ESCPiDIcYLNHmxwoxrTqqWxDIuU0cdQwLyJXJPHiWi2ofvllgCygQo6lSGD1jjowS5wRE8O/Jfn3HPe4FJcrq4DGe43fzSgCNGfCamL2JXw/DJ7f59KchtoATFC2K6MalOhHJNP82yLtWrzvjd4Xrk6lmI3ri9xOEvBHM2NiDoYU8hgVm1hjuOOQT4I2sSLWL1f96fmn70e98pMWvG9ABSeklp6Z05uP46lvEFL2vd+3PaoQZvdRXFMj1jMOS0eBSEObRDmvqfFslL2ReVSpevTNVOL/kK6kNl5SHjN1MOjPabSg/FHOtvZ1IYx6uGSwPh1uTnTbnprEXlRoAp474ZBMy2WCm8yNNe/vMxKG+OFPOnOl0xBkxNgLLh2pv7XCFeIf4pMjgOs9heOiTOloXyTDfMUv9PcRuU7WhH6C6iOFRi4DlZ6pYK0nE776netMiAtWSIfys+wlI2BtoMv6p/3IDM47k5oSopNuz6dwHtEO3Zsa4OiT1Nwy2ToPYGIvR7rf/g9F1fdqqJnc7x3ZFWJGiLoh4Yb+cZJCsMb9KLk8JRnClcwxaj0Zug1KKvJ8iRDb1CKsZjLPNLtRWiE655JZXJUb2tWNCu8KFaj64qPFkUsjI5mXiEDYcPxxQS1dcWrnH+yNfSz6SK7Upt8L0C5ui/rO8qVRetcv3zxA+269xTLG7mGU/4xspMOEsdZzCulTokfQhtsNKoNq0XY62keF89SSkcEYdb/0gy+GeJUlMXZpMHB4Ep3ckgXxKqdCFzfhw2i17g2ZZI57TVZeJFYfp5wXQt7aaFYFPIRCCrkhHLEfsVdhp/1mspxfM1Orq3o=", + "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 +} diff --git a/js/packages/test-vectors/fixtures/sync_merge.json b/js/packages/test-vectors/fixtures/sync_merge.json new file mode 100644 index 00000000..c1dd94ca --- /dev/null +++ b/js/packages/test-vectors/fixtures/sync_merge.json @@ -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 +} diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index a904babc..874bb5b6 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -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; + incoming: Record; + source_tag: string; + merged: Record; +} + +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; + delta_payloads_b64: string[]; + delta_plaintexts: Record[]; + final_state: Record; +}; + export const legacyPayloads = legacyPayloadsJson as { mnemonic_id: string; plaintext_utf8: string; diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index 5c80cfc4..33061039 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -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