From 774c992153af57e30601a5aa067a2fa7ba81a3af Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:34:40 -0400 Subject: [PATCH] TS port slice 6: Nostr event protocol layer with rust-nostr cross-fixtures - NIP-01 event serialization/id computation, BIP-340 signing and verification via @noble/curves, SeedPass event builders (manifest 30070, chunk 30071, delta 30072 with d/e tags), and REQ/EVENT/EOSE/OK/NOTICE/ CLOSED client-relay framing. Transport-agnostic; the WebSocket adapter lands with the interface milestones. - Cross-implementation fixtures generated through the Python side's nostr_sdk (rust-nostr) with pinned created_at: TS ids match theirs byte-for-byte, their signatures verify in TS, and TS-signed events reproduce identical ids. The generator caches signatures across runs (BIP-340 aux randomness) so fixture output stays byte-deterministic and the CI drift job holds. - 143 core parity tests (Node + jsdom) + 16 CLI tests green. Co-Authored-By: Claude Fable 5 --- docs/typescript_port_compatibility_matrix.md | 5 +- js/packages/core/src/index.ts | 18 ++ js/packages/core/src/sync/events.ts | 207 ++++++++++++++++++ js/packages/core/test/events.test.ts | 135 ++++++++++++ .../test-vectors/fixtures/manifest.json | 3 +- .../test-vectors/fixtures/nostr_events.json | 60 +++++ js/packages/test-vectors/src/index.ts | 18 ++ scripts/generate_ts_port_fixtures.py | 75 +++++++ 8 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 js/packages/core/src/sync/events.ts create mode 100644 js/packages/core/test/events.test.ts create mode 100644 js/packages/test-vectors/fixtures/nostr_events.json diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index b1cd1d9f..0215ee34 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -35,6 +35,7 @@ until a later milestone. | 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. | +| Nostr event layer (NIP-01 ids, BIP-340 sign/verify, framing) | P0 | green | `nostr_events.json` | Event ids match rust-nostr (Python nostr_sdk) fixtures byte-for-byte; fixture signatures verify in TS; TS-signed events reproduce the same ids and verify. REQ/EVENT/EOSE/OK/NOTICE/CLOSED framing round-trips. Fixture signatures are cached across generator runs (BIP-340 aux randomness). WebSocket relay transport: todo (interface milestones). | | Portable backup import/export | P0 | green | `portable_backup.json` | format_version 1, seed-only and plaintext modes; TS imports the Python export, re-exports byte-identically under pinned nonce/timestamp, verifies canonical checksum, rejects tampering/unknown versions. | | Entry creation (provision) | P0 | green | `entries_index.json` | TS `add*Entry` ops rebuild the Python `EntryManager` fixture index byte-for-byte (pinned clock): field shapes, id allocation, independent TOTP derivation-index allocation, ISO timestamp format. Not ported: index0 event emission, modify/archive ops. | | Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. | @@ -44,8 +45,8 @@ until a later milestone. | Environment | Status | |---|---| -| Node 22 (vitest) | green — 129/129 core + 16 CLI | -| jsdom browser-like env | green — 129/129 core | +| Node 22 (vitest) | green — 143/143 core + 16 CLI | +| jsdom browser-like env | green — 143/143 core | | 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 e873fc03..f4115ca6 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -106,4 +106,22 @@ export { type ChunkMeta, type Manifest, } from "./sync/snapshot.js"; +export { + computeEventId, + serializeEventForId, + signEvent, + verifyEvent, + signerPublicKeyHex, + buildChunkEvent, + buildManifestEvent, + buildDeltaEvent, + reqMessage, + eventMessage, + closeMessage, + parseRelayMessage, + type NostrEvent, + type UnsignedEvent, + type Filter, + type RelayMessage, +} from "./sync/events.js"; export * from "./util/bytes.js"; diff --git a/js/packages/core/src/sync/events.ts b/js/packages/core/src/sync/events.ts new file mode 100644 index 00000000..e1a6970b --- /dev/null +++ b/js/packages/core/src/sync/events.ts @@ -0,0 +1,207 @@ +/** + * Nostr event protocol layer: NIP-01 event ids, BIP-340 signing/verify, and + * client-relay message framing. Transport-agnostic — the WebSocket relay + * adapter builds on this but lives with the interface milestones. + * + * Cross-implementation fixtures come from the Python side's rust-nostr + * (nostr_sdk); TS-computed ids must match theirs exactly. + */ + +import { schnorr } from "@noble/curves/secp256k1.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes, utf8 } from "../util/bytes.js"; +import { KIND_DELTA, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK } from "./snapshot.js"; + +export interface NostrEvent { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +} + +export type UnsignedEvent = Omit; + +/** + * NIP-01 string escaping for the id serialization: exactly \n \" \\ \r \t + * \b \f are escaped; every other character (including other control chars + * and non-ASCII) is included verbatim as UTF-8. + */ +function nip01EscapeString(s: string): string { + let out = '"'; + for (const ch of s) { + switch (ch) { + case "\n": out += "\\n"; break; + case '"': out += '\\"'; break; + case "\\": out += "\\\\"; break; + case "\r": out += "\\r"; break; + case "\t": out += "\\t"; break; + case "\b": out += "\\b"; break; + case "\f": out += "\\f"; break; + default: out += ch; + } + } + return out + '"'; +} + +/** The exact byte sequence NIP-01 hashes: [0,pubkey,created_at,kind,tags,content]. */ +export function serializeEventForId( + pubkey: string, + createdAt: number, + kind: number, + tags: string[][], + content: string, +): string { + const tagsJson = + "[" + + tags.map((tag) => "[" + tag.map(nip01EscapeString).join(",") + "]").join(",") + + "]"; + return `[0,${nip01EscapeString(pubkey)},${createdAt},${kind},${tagsJson},${nip01EscapeString(content)}]`; +} + +export function computeEventId( + pubkey: string, + createdAt: number, + kind: number, + tags: string[][], + content: string, +): string { + return bytesToHex(sha256(utf8(serializeEventForId(pubkey, createdAt, kind, tags, content)))); +} + +export function signerPublicKeyHex(privateKeyHex: string): string { + return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex))); +} + +/** Sign an event with a secp256k1 private key (x-only pubkey, BIP-340 sig). */ +export function signEvent(privateKeyHex: string, unsigned: UnsignedEvent): NostrEvent { + const priv = hexToBytes(privateKeyHex); + const pubkey = bytesToHex(schnorr.getPublicKey(priv)); + const id = computeEventId( + pubkey, + unsigned.created_at, + unsigned.kind, + unsigned.tags, + unsigned.content, + ); + const sig = bytesToHex(schnorr.sign(hexToBytes(id), priv)); + return { id, pubkey, ...unsigned, sig }; +} + +/** Verify an event's id derivation and BIP-340 signature. */ +export function verifyEvent(event: NostrEvent): boolean { + const expectedId = computeEventId( + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content, + ); + if (expectedId !== event.id) return false; + try { + return schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey)); + } catch { + return false; + } +} + +// --- SeedPass event builders (parity: nostr/snapshot.py EventBuilder use) --- + +export function buildChunkEvent( + chunkId: string, + contentB64: string, + createdAt: number, +): UnsignedEvent { + return { + kind: KIND_SNAPSHOT_CHUNK, + tags: [["d", chunkId]], + content: contentB64, + created_at: createdAt, + }; +} + +export function buildManifestEvent( + manifestId: string, + manifestJson: string, + createdAt: number, +): UnsignedEvent { + return { + kind: KIND_MANIFEST, + tags: [["d", manifestId]], + content: manifestJson, + created_at: createdAt, + }; +} + +export function buildDeltaEvent( + manifestEventId: string, + contentB64: string, + createdAt: number, +): UnsignedEvent { + return { + kind: KIND_DELTA, + tags: [["e", manifestEventId]], + content: contentB64, + created_at: createdAt, + }; +} + +// --- NIP-01 client/relay message framing --- + +export interface Filter { + ids?: string[]; + authors?: string[]; + kinds?: number[]; + "#d"?: string[]; + "#e"?: string[]; + since?: number; + until?: number; + limit?: number; +} + +export function reqMessage(subscriptionId: string, ...filters: Filter[]): string { + return JSON.stringify(["REQ", subscriptionId, ...filters]); +} + +export function eventMessage(event: NostrEvent): string { + return JSON.stringify(["EVENT", event]); +} + +export function closeMessage(subscriptionId: string): string { + return JSON.stringify(["CLOSE", subscriptionId]); +} + +export type RelayMessage = + | { type: "EVENT"; subscriptionId: string; event: NostrEvent } + | { type: "EOSE"; subscriptionId: string } + | { type: "OK"; eventId: string; accepted: boolean; message: string } + | { type: "NOTICE"; message: string } + | { type: "CLOSED"; subscriptionId: string; message: string }; + +export function parseRelayMessage(raw: string): RelayMessage { + const arr = JSON.parse(raw) as unknown[]; + if (!Array.isArray(arr) || typeof arr[0] !== "string") { + throw new Error("malformed relay message"); + } + switch (arr[0]) { + case "EVENT": + return { type: "EVENT", subscriptionId: String(arr[1]), event: arr[2] as NostrEvent }; + case "EOSE": + return { type: "EOSE", subscriptionId: String(arr[1]) }; + case "OK": + return { + type: "OK", + eventId: String(arr[1]), + accepted: Boolean(arr[2]), + message: String(arr[3] ?? ""), + }; + case "NOTICE": + return { type: "NOTICE", message: String(arr[1] ?? "") }; + case "CLOSED": + return { type: "CLOSED", subscriptionId: String(arr[1]), message: String(arr[2] ?? "") }; + default: + throw new Error(`unknown relay message type: ${arr[0]}`); + } +} diff --git a/js/packages/core/test/events.test.ts b/js/packages/core/test/events.test.ts new file mode 100644 index 00000000..b8d65ab5 --- /dev/null +++ b/js/packages/core/test/events.test.ts @@ -0,0 +1,135 @@ +/** + * Nostr event layer parity: NIP-01 id computation must match rust-nostr + * (via the Python-side nostr_sdk fixtures), signatures must verify, and + * TS-built/signed events must be accepted by TS verification. + */ + +import { describe, expect, it } from "vitest"; +import { nostrEvents, nostrSnapshot, deltaReplay, nostrKeyCases } from "@seedpass/test-vectors"; +import { + computeEventId, + signEvent, + verifyEvent, + signerPublicKeyHex, + buildChunkEvent, + buildManifestEvent, + buildDeltaEvent, + reqMessage, + parseRelayMessage, + eventMessage, + type NostrEvent, +} from "@seedpass/core"; + +describe("NIP-01 event ids (cross-implementation)", () => { + it.each(nostrEvents.events)("$name id matches rust-nostr", ({ event }) => { + expect( + computeEventId(event.pubkey, event.created_at, event.kind, event.tags, event.content), + ).toBe(event.id); + }); + + it("signer identity matches the app-1237 fixture key", () => { + const fixtureKey = nostrKeyCases.find( + (c) => c.mnemonic_id === "abandon12" && c.account_index === 0, + )!; + expect(nostrEvents.signer_private_key_hex).toBe(fixtureKey.private_key_hex); + expect(signerPublicKeyHex(fixtureKey.private_key_hex)).toBe( + nostrEvents.signer_public_key_hex, + ); + }); +}); + +describe("BIP-340 signature verification", () => { + it.each(nostrEvents.events)("$name verifies", ({ event }) => { + expect(verifyEvent(event as NostrEvent)).toBe(true); + }); + + it("rejects a tampered event", () => { + const event = { ...(nostrEvents.events[0]!.event as NostrEvent) }; + event.content = event.content + "x"; + expect(verifyEvent(event)).toBe(false); + }); +}); + +describe("event builders match the Python event shapes", () => { + const byName = (name: string) => + nostrEvents.events.find((e) => e.name === name)!.event as NostrEvent; + + it("chunk event", () => { + const py = byName("chunk-0"); + const unsigned = buildChunkEvent( + nostrSnapshot.chunk_metas[0]!.id, + nostrSnapshot.chunks_b64[0]!, + py.created_at, + ); + expect(computeEventId(py.pubkey, unsigned.created_at, unsigned.kind, unsigned.tags, unsigned.content)).toBe(py.id); + }); + + it("manifest event", () => { + const py = byName("manifest"); + const unsigned = buildManifestEvent( + nostrSnapshot.manifest_id, + nostrSnapshot.manifest_json, + py.created_at, + ); + expect(computeEventId(py.pubkey, unsigned.created_at, unsigned.kind, unsigned.tags, unsigned.content)).toBe(py.id); + }); + + it("delta event", () => { + const py = byName("delta-1"); + const unsigned = buildDeltaEvent( + nostrSnapshot.manifest_id, + deltaReplay.delta_payloads_b64[0]!, + py.created_at, + ); + expect(computeEventId(py.pubkey, unsigned.created_at, unsigned.kind, unsigned.tags, unsigned.content)).toBe(py.id); + }); + + it("TS signing produces the same ids and a valid signature", () => { + const py = byName("manifest"); + const signed = signEvent( + nostrEvents.signer_private_key_hex, + buildManifestEvent(nostrSnapshot.manifest_id, nostrSnapshot.manifest_json, py.created_at), + ); + expect(signed.id).toBe(py.id); + expect(signed.pubkey).toBe(py.pubkey); + expect(verifyEvent(signed)).toBe(true); + }); +}); + +describe("NIP-01 message framing", () => { + it("REQ round-trips filters", () => { + const raw = reqMessage("sub1", { + authors: [nostrEvents.signer_public_key_hex], + kinds: [30070], + "#d": [nostrSnapshot.manifest_id], + limit: 1, + }); + const arr = JSON.parse(raw); + expect(arr[0]).toBe("REQ"); + expect(arr[2].kinds).toEqual([30070]); + }); + + it("parses EVENT/EOSE/OK/NOTICE/CLOSED", () => { + const ev = nostrEvents.events[0]!.event; + expect(parseRelayMessage(JSON.stringify(["EVENT", "sub1", ev]))).toEqual({ + type: "EVENT", + subscriptionId: "sub1", + event: ev, + }); + expect(parseRelayMessage('["EOSE","sub1"]')).toEqual({ type: "EOSE", subscriptionId: "sub1" }); + expect(parseRelayMessage(`["OK","${ev.id}",true,""]`)).toEqual({ + type: "OK", + eventId: ev.id, + accepted: true, + message: "", + }); + expect(parseRelayMessage('["NOTICE","hi"]')).toEqual({ type: "NOTICE", message: "hi" }); + expect(parseRelayMessage('["CLOSED","sub1","reason"]')).toEqual({ + type: "CLOSED", + subscriptionId: "sub1", + message: "reason", + }); + const msg = eventMessage(ev as NostrEvent); + expect(JSON.parse(msg)[1].id).toBe(ev.id); + }); +}); diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index aec25cf5..1743eabd 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -11,6 +11,7 @@ "kdf_metadata.json", "legacy_payloads.json", "managed_seeds.json", + "nostr_events.json", "nostr_keys.json", "nostr_snapshot.json", "password_kdf.json", @@ -23,6 +24,6 @@ ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "ab569fe900213937a21f0616057d3d5c46015e3c", + "python_commit": "e91d5d0be2355c4be78b8c455ddf87b66d795036", "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_events.json b/js/packages/test-vectors/fixtures/nostr_events.json new file mode 100644 index 00000000..58a825b3 --- /dev/null +++ b/js/packages/test-vectors/fixtures/nostr_events.json @@ -0,0 +1,60 @@ +{ + "description": "Signed Nostr events from the Python-side nostr_sdk (rust-nostr) with pinned created_at. Ids are NIP-01-deterministic; signatures are cached across generator runs (BIP-340 aux randomness).", + "events": [ + { + "event": { + "content": "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/Bd7AyYaUw9TDg6hlbnoZDpALziNL4k=", + "created_at": 1700000000, + "id": "28e4ad167400ee19929818b50d2d72cce084613b08e5761e6af1d0e1c3af95aa", + "kind": 30071, + "pubkey": "257f22c3049cf52100bdf17d6bce85b7c9d648850e4e3c1965733dd176b646e0", + "sig": "58c7b0a53c12f5912a879289fb4244d72956522e037d3e31820fa2aecc8e64e9992f05653e4cc30f59a491b26b553ba82ff82443d1c2d5531a7ee77ce29f5447", + "tags": [ + [ + "d", + "seedpass-chunk-0000" + ] + ] + }, + "name": "chunk-0" + }, + { + "event": { + "content": "{\"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}", + "created_at": 1700000000, + "id": "f6b1362c95101cf8ec58bce9af8c7ae67f41ebf29a52a8c5239953f0b550cf78", + "kind": 30070, + "pubkey": "257f22c3049cf52100bdf17d6bce85b7c9d648850e4e3c1965733dd176b646e0", + "sig": "ee8def6729fbd1619f19091fea9d653c5b7539f9a7c2662a9678b8c66903ab0c1ed9af6ba3593858162aa24f8db25ab78118159ab113ac1f183ecbe85d7e10e0", + "tags": [ + [ + "d", + "168836eb089b4d0b57f92b3b14eaeb9e875ee9dcd8f59c80f6d027e08f273c12" + ] + ] + }, + "name": "manifest" + }, + { + "event": { + "content": "VjN8dk5XXH5X36Xdmu5olZDxZJTpOTZDjskABP6osJd8Rftxc7YoBfXa/Yh4XHpySBsf70Vnm0Q0m1xQBOoeShxXtCgUbQ48x8pY3NnY3suMDjlqou0ti5vuMVknTRPwgyBnFAo1DUeGmZSidFK0/z8EfxqanEklB9e9yUtqOiN04UmwK8tnr0u0n2pJd82oppv3PNPZs4FFFFmiD1d+ZVlOBEyuziEmTjuhP6axqflqEv4Y2nox82w+XvBbFhkCO2lVO9Q++81rhE8OKJ9q9iuUURD+U83UjhLTdNavcu+jk2r4AyUMQh9rK43IabeEK5cFJmFpLWNOinzouOvQJQ9yGPvccYmarBIza8BsiJLINnX1oNNRk8TG3NQ4i8bKdeXBaATuBeLPDVhF2GKICGzy3sQN7CbvB5BjPBKD4+om2eVU8MJW2AKCz5WTMADF", + "created_at": 1700000000, + "id": "640d785f97902b72f152294b5497054bb159ff2ba66b7b4b89d0d8946596b286", + "kind": 30072, + "pubkey": "257f22c3049cf52100bdf17d6bce85b7c9d648850e4e3c1965733dd176b646e0", + "sig": "871a1b745e2e0ce7f7fd341666543f7961a1029ddfa8f45f46ddc78fe26011f312b6235dee97890c0d9bc20b9fe33cb4fd6391376c17e353bb2aca1d89b8c4f4", + "tags": [ + [ + "e", + "168836eb089b4d0b57f92b3b14eaeb9e875ee9dcd8f59c80f6d027e08f273c12" + ] + ] + }, + "name": "delta-1" + } + ], + "fixture_version": 1, + "mnemonic_id": "abandon12", + "signer_private_key_hex": "0ac7be6c7b08bf16ead8778494cf7256c566fb68bb09c0285b056d8a568516fc", + "signer_public_key_hex": "257f22c3049cf52100bdf17d6bce85b7c9d648850e4e3c1965733dd176b646e0" +} diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 0f0dc72c..2cf1b88c 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -27,6 +27,7 @@ import syncMergeJson from "../fixtures/sync_merge.json"; import deltaReplayJson from "../fixtures/delta_replay.json"; import portableBackupJson from "../fixtures/portable_backup.json"; import entrySecretsJson from "../fixtures/entry_secrets.json"; +import nostrEventsJson from "../fixtures/nostr_events.json"; export interface Bip39Case { id: string; @@ -213,6 +214,23 @@ export const entrySecrets = entrySecretsJson as { managed_entry_8_mnemonic: string; }; +export interface SignedNostrEvent { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +} + +export const nostrEvents = nostrEventsJson as { + mnemonic_id: string; + signer_private_key_hex: string; + signer_public_key_hex: string; + events: { name: string; event: SignedNostrEvent }[]; +}; + 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 50ae64e7..0b9a4a67 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -906,6 +906,78 @@ def gen_entry_secrets() -> dict: } +def gen_nostr_events(snapshot: dict, delta_replay: dict) -> dict: + """Signed Nostr events for the snapshot/chunk/delta model. + + Event ids are deterministic (NIP-01 serialization); BIP-340 signatures + use random aux data and are NOT. To keep the generator byte-deterministic + we reuse the previously committed signature whenever the event id is + unchanged — a fresh signature is only produced for new/changed events. + """ + import json as json_mod + + from nostr_sdk import EventBuilder, EventId, Keys, Kind, Tag, Timestamp + + # Sync client identity: BIP-85 app 1237, account index 0 (nostr_keys.json) + bip85 = _bip85(MNEMONICS[PRIMARY]) + entropy = bip85.derive_entropy(index=0, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID) + keys = Keys.parse(entropy.hex()) + + existing_sigs: dict[str, str] = {} + existing_path = FIXTURES_DIR / "nostr_events.json" + if existing_path.exists(): + try: + for ev in json_mod.loads(existing_path.read_text())["events"]: + existing_sigs[ev["event"]["id"]] = ev["event"]["sig"] + except Exception: + pass + + def build(name: str, kind: int, content: str, tags: list) -> dict: + builder = ( + EventBuilder(Kind(kind), content) + .tags(tags) + .custom_created_at(Timestamp.from_secs(FIXED_UNIX)) + ) + event = json_mod.loads(builder.sign_with_keys(keys).as_json()) + cached_sig = existing_sigs.get(event["id"]) + if cached_sig: + event["sig"] = cached_sig + return {"name": name, "event": event} + + events = [ + build( + "chunk-0", + 30071, + snapshot["chunks_b64"][0], + [Tag.identifier(snapshot["chunk_metas"][0]["id"])], + ), + build( + "manifest", + 30070, + snapshot["manifest_json"], + [Tag.identifier(snapshot["manifest_id"])], + ), + build( + "delta-1", + 30072, + delta_replay["delta_payloads_b64"][0], + [Tag.event(EventId.parse(snapshot["manifest_id"]))], + ), + ] + + return { + "description": ( + "Signed Nostr events from the Python-side nostr_sdk (rust-nostr) " + "with pinned created_at. Ids are NIP-01-deterministic; signatures " + "are cached across generator runs (BIP-340 aux randomness)." + ), + "mnemonic_id": PRIMARY, + "signer_private_key_hex": entropy.hex(), + "signer_public_key_hex": keys.public_key().to_hex(), + "events": events, + } + + def gen_kdf_metadata() -> dict: return { "description": ( @@ -959,6 +1031,9 @@ def main() -> None: files["entry_secrets.json"] = gen_entry_secrets() files["sync_merge.json"] = gen_sync_merge() files["delta_replay.json"] = gen_delta_replay() + files["nostr_events.json"] = gen_nostr_events( + files["nostr_snapshot.json"], files["delta_replay.json"] + ) for name, data in files.items(): data["fixture_version"] = FIXTURE_VERSION