mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 19:02:06 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
e91d5d0be2
commit
774c992153
8 files changed
+518
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
@@ -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<NostrEvent, "id" | "sig" | "pubkey">;
|
||||
|
||||
/**
|
||||
* 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]}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user