mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-08 22:02:24 +00:00
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:
1 parent
6d6ad5e2c5
commit
2345f61431
12 files changed
+1924
-5
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user