From 5a4a822c514f1b1fc4ff3c81f1122eb769f3a72f Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Fri, 24 Jul 2026 07:16:26 -0400 Subject: [PATCH] G56-02+03: Switch to non-replaceable kind 9999, append-only upgrades, earliest-anchor selection, identity binding --- package.json | 2 +- plans/g56-02-non-replaceable-proof-carrier.md | 183 ++++++++++ test/pq-crypto.test.mjs | 202 +++++++++++ www/index.html | 82 +++-- www/js/pq-crypto.mjs | 332 ++++++++++++++++-- www/js/version.json | 6 +- www/pq-crypto.bundle.js | 204 ++++++++++- www/verify.html | 190 ++++++---- 8 files changed, 1042 insertions(+), 159 deletions(-) create mode 100644 plans/g56-02-non-replaceable-proof-carrier.md diff --git a/package.json b/package.json index dda8443..803d9c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "post_quantum_nostr", - "version": "0.0.13", + "version": "0.0.14", "description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.", "main": "index.js", "scripts": { diff --git a/plans/g56-02-non-replaceable-proof-carrier.md b/plans/g56-02-non-replaceable-proof-carrier.md new file mode 100644 index 0000000..35f0272 --- /dev/null +++ b/plans/g56-02-non-replaceable-proof-carrier.md @@ -0,0 +1,183 @@ +# G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades + +**Date:** 2026-07-24 +**Finding:** G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event +**Approach:** Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection +**No migration needed:** the project has not been publicly released + +## Problem summary + +Kind 11112 is in the 10000–19999 range, which Nostr relays treat as **replaceable** — they keep only the latest event per pubkey. This means: + +1. An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays +2. The code queries with `limit: 1` and selects the newest `created_at`, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule +3. The original event — with its earlier Bitcoin anchor — can be hidden + +## Design + +### Two-event structure (unchanged conceptually, changed kind) + +**Event 1: Kind 1 Announcement** (non-replaceable, already correct) +- Contains the human-readable attestation text and PQ keys/signatures in `algorithm` tags +- Signed by the user's existing secp256k1 identity +- Its full signed JSON hash is what gets submitted to OpenTimestamps +- No changes needed — kind 1 is already in the 0–9999 non-replaceable range + +**Event 2: Kind 9999 Proof Carrier** (non-replaceable, changed from 11112) +- References Event 1 by `e` tag +- Carries the `sha256` tag (hash of Event 1's full signed JSON) +- Carries the `ots` tag (base64 .ots proof) +- New `ots_status` tag: `"pending"` or `"confirmed"` +- New `upgrade_of` tag: references the previous proof carrier event ID (only on upgrade events) +- Published as append-only — upgrades publish a NEW event, never replace + +### Append-only upgrade flow + +``` +1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement +2. Submit kind 1 hash to OTS calendars → get pending .ots proof +3. Publish kind 1 announcement (permanent, non-replaceable) +4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable) +5. Wait 10-30 min for Bitcoin confirmation +6. Upgrade the .ots proof via /ots-upgrade helper +7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="" +8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor +``` + +### Re-migration flow (user lost seed, runs process again years later) + +``` +1. User signs in with same Nostr identity → generates new seed → derives new PQ keys +2. Publishes a new kind 1 announcement (new PQ keys, new block height) +3. Publishes new kind 9999 proof carriers (pending, then confirmed) +4. Client sees multiple kind 1 announcements + multiple proof carriers +5. Earliest valid Bitcoin anchor across all of them is the canonical root +6. Later migrations are valid if published while secp256k1 is still trustworthy +``` + +### Earliest-anchor selection logic + +A new pure function `selectCanonicalProofCarrier(candidates, kind1Event)`: + +1. Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference +2. Filter: only candidates whose `e` tag matches the kind 1 event ID +3. Filter: only candidates whose `sha256` tag matches `hashFullEvent(kind1Event)` +4. Filter: only candidates with a valid secp256k1 signature +5. For each remaining candidate, parse and verify its OTS proof: + - If `ots_status` is "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height + - If `ots_status` is "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored) +6. Sort valid candidates by Bitcoin block height (ascending) +7. Return the one with the earliest block height +8. If tie, sort by event ID lexicographically (deterministic tie-break) +9. If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note + +## Files to change + +### 1. `www/js/pq-crypto.mjs` + +**Change `NIP_QR_KIND`:** +- Line 430: change `11112` to `9999` +- Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with `upgrade_of` referencing the previous one." + +**Update `buildKind11112Wrapper()` → rename to `buildProofCarrier()`:** +- Add `['ots_status', 'pending']` tag (or 'confirmed' when applicable) +- Add optional `['upgrade_of', previousEventId]` tag when upgrading +- Keep existing `e`, `sha256`, `ots` tags + +**Update `buildUpgradedEvent()`:** +- Instead of copying tags and replacing the `ots` tag, build a completely new proof carrier event +- Include `ots_status: 'confirmed'` +- Include `upgrade_of: ` +- Keep the same `e` and `sha256` tags (they reference the same kind 1 event) + +**Add `selectCanonicalProofCarrier(candidates, kind1Event)`:** +- Pure function, no network calls +- Takes array of proof carrier events and the kind 1 announcement +- Returns `{ canonical, allCandidates, errors }` where `canonical` is the event with the earliest valid Bitcoin anchor +- OTS verification is async, so this function should be async and call `verifyOtsProof()` for each candidate's OTS proof + +### 2. `www/verify.html` + +**Update `queryRelayForEvent()`:** +- Change kind from `NIP_QR_KIND` (which will be 9999) — already uses the constant, so this is automatic +- Remove `limit: 1` — fetch all events +- Return an array of events instead of a single event + +**Update `verifyEvent()`:** +- Accept an array of proof carrier candidates +- Call `selectCanonicalProofCarrier()` to pick the canonical one +- Display all candidates in the UI with their OTS status and Bitcoin anchor height +- Highlight the canonical (earliest-anchor) one +- Still verify the kind 1 announcement and PQ signatures as before + +**Update the relay query tab:** +- Query for both kind 1 and kind 9999 events for the pubkey +- Match proof carriers to announcements by `e` tag +- Display the full migration history + +### 3. `www/index.html` + +**Update `queryRelayForKind11112()` → rename to `queryRelayForProofCarriers()`:** +- Remove `limit: 1` +- Return an array + +**Update the sign/publish flow:** +- After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with `ots_status: 'pending'` + +**Update `publishUpgradedEvent()`:** +- Build a NEW proof carrier event (not a replacement) +- Include `ots_status: 'confirmed'` and `upgrade_of: ` +- Publish it as a new event +- Do NOT try to replace the old event + +**Update the resume workflow:** +- Fetch all kind 9999 proof carriers for the pubkey +- Find the one that matches the persisted kind 1 event hash +- If a confirmed proof carrier already exists, the workflow is complete +- If only a pending proof carrier exists, continue the upgrade polling + +**Update `savePendingOts()`:** +- Store both the pending proof carrier event ID and the kind 1 event ID +- Store the `sha256` target digest explicitly +- On resume, validate that fetched events match persisted IDs + +### 4. `test/pq-crypto.test.mjs` + +**Update existing tests:** +- Any test referencing kind 11112 → change to 9999 + +**Add new tests:** +- `buildProofCarrier` includes `ots_status` tag +- `buildUpgradedEvent` includes `ots_status: 'confirmed'` and `upgrade_of` tag +- `selectCanonicalProofCarrier` returns the event with the earliest Bitcoin anchor +- `selectCanonicalProofCarrier` handles ties deterministically +- `selectCanonicalProofCarrier` skips pending-only candidates when confirmed ones exist +- `selectCanonicalProofCarrier` rejects candidates with wrong `e` tag or `sha256` tag +- `selectCanonicalProofCarrier` rejects candidates with invalid secp signatures +- Multiple migration attempts (re-migration) — earliest anchor wins across all + +### 5. `nip_proposal.md` + +- Change kind 11112 to kind 9999 +- Update the description: "non-replaceable event (0–9999 range)" +- Add `ots_status` and `upgrade_of` tags to the tag specification +- Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor" +- Update the proof states section: upgrades publish new events, not replacements + +### 6. `README.md` + +- Update any reference to kind 11112 → kind 9999 +- Update the migration flow description +- Update the implementation status table + +## Acceptance criteria + +1. No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range +2. Upgrades publish new events with `upgrade_of` referencing the previous one — no event is ever replaced +3. Relay queries fetch all candidates, not `limit: 1` +4. `selectCanonicalProofCarrier` deterministically picks the earliest valid Bitcoin anchor +5. A later forged proof carrier cannot hide or supersede an earlier valid one +6. Re-migration (publishing a second migration attempt) works naturally — all events are permanent +7. All existing tests pass (updated for kind 9999) +8. New tests for append-only upgrades and earliest-anchor selection pass +9. The verify page displays the full candidate history and highlights the canonical one diff --git a/test/pq-crypto.test.mjs b/test/pq-crypto.test.mjs index 6b39e60..327fcce 100644 --- a/test/pq-crypto.test.mjs +++ b/test/pq-crypto.test.mjs @@ -494,3 +494,205 @@ describe('G56-01: strict PQ algorithm policy (verifyNIPQRContent)', () => { assert.ok(Array.isArray(result.errors), 'errors should be an array'); }); }); + +// ============================================================================ +// G56-02: NON-REPLACEABLE PROOF CARRIER (kind 9999) AND EARLIEST-ANCHOR SELECTION +// ============================================================================ +// +// Tests for the new non-replaceable proof carrier event kind, append-only +// upgrades, and the selectCanonicalProofCarrier function. + +describe('G56-02: non-replaceable proof carrier (kind 9999)', () => { + const sk = new Uint8Array(32); + sk[31] = 1; + const pkHex = m.bytesToHex(schnorr.getPublicKey(sk)); + + async function signedKind1(content, tags) { + const event = { + pubkey: pkHex, + created_at: 1700000000, + kind: 1, + tags: tags || [], + content: content || 'test attestation' + }; + event.id = m.computeEventId(event); + event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk)); + return event; + } + + async function validKind1WithAllPQKeys() { + const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + const seed = m.mnemonicToSeed(mnemonic); + const keys = m.derivePQKeysFromSeed(seed); + const event = m.buildKind1Announcement(pkHex, 958927, keys); + event.id = m.computeEventId(event); + event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk)); + return event; + } + + async function signedProofCarrier(kind1Event, otsProof, options = {}) { + const template = m.buildProofCarrier(kind1Event, otsProof, options); + template.id = m.computeEventId(template); + template.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(template.id), sk)); + return template; + } + + test('NIP_QR_KIND is 9999 (non-replaceable range)', () => { + assert.equal(m.NIP_QR_KIND, 9999, 'kind should be 9999 (0-9999 non-replaceable range)'); + }); + + test('buildProofCarrier includes ots_status tag', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' }); + assert.equal(carrier.kind, 9999); + const otsStatusTag = carrier.tags.find(t => t[0] === 'ots_status'); + assert.ok(otsStatusTag, 'should have ots_status tag'); + assert.equal(otsStatusTag[1], 'pending'); + }); + + test('buildProofCarrier includes upgrade_of tag when provided', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const carrier = m.buildProofCarrier(kind1, new Uint8Array(100), { + otsStatus: 'confirmed', + upgradeOf: 'abc123' + }); + const upgradeTag = carrier.tags.find(t => t[0] === 'upgrade_of'); + assert.ok(upgradeTag, 'should have upgrade_of tag'); + assert.equal(upgradeTag[1], 'abc123'); + }); + + test('buildUpgradedEvent creates append-only event with upgrade_of', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const pendingCarrier = await signedProofCarrier(kind1, new Uint8Array(100), { otsStatus: 'pending' }); + const upgraded = m.buildUpgradedEvent(pendingCarrier, new Uint8Array(200)); + + // Should be a new event, not a replacement + assert.equal(upgraded.kind, 9999); + const otsStatusTag = upgraded.tags.find(t => t[0] === 'ots_status'); + assert.equal(otsStatusTag[1], 'confirmed', 'upgraded event should have ots_status confirmed'); + + const upgradeTag = upgraded.tags.find(t => t[0] === 'upgrade_of'); + assert.ok(upgradeTag, 'should have upgrade_of tag'); + assert.equal(upgradeTag[1], pendingCarrier.id, 'upgrade_of should reference original event ID'); + + // e and sha256 tags should be the same as the original + const eTag = upgraded.tags.find(t => t[0] === 'e'); + const origETag = pendingCarrier.tags.find(t => t[0] === 'e'); + assert.equal(eTag[1], origETag[1], 'e tag should be preserved'); + }); + + test('selectCanonicalProofCarrier returns null for empty candidates', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const result = await m.selectCanonicalProofCarrier([], kind1); + assert.equal(result.canonical, null); + }); + + test('selectCanonicalProofCarrier rejects wrong e tag', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const carrier = await signedProofCarrier(kind1, new Uint8Array(100)); + // Tamper the e tag + carrier.tags = carrier.tags.map(t => t[0] === 'e' ? ['e', '0'.repeat(64)] : t); + carrier.id = m.computeEventId(carrier); + carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk)); + const result = await m.selectCanonicalProofCarrier([carrier], kind1); + assert.equal(result.canonical, null); + assert.ok(result.errors.some(e => e.includes('e tag')), 'should report e tag mismatch'); + }); + + test('selectCanonicalProofCarrier rejects wrong sha256 tag', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const carrier = await signedProofCarrier(kind1, new Uint8Array(100)); + // Tamper the sha256 tag + carrier.tags = carrier.tags.map(t => t[0] === 'sha256' ? ['sha256', '0'.repeat(64)] : t); + carrier.id = m.computeEventId(carrier); + carrier.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(carrier.id), sk)); + const result = await m.selectCanonicalProofCarrier([carrier], kind1); + assert.equal(result.canonical, null); + assert.ok(result.errors.some(e => e.includes('sha256')), 'should report sha256 mismatch'); + }); + + test('selectCanonicalProofCarrier rejects wrong expected author', async () => { + const kind1 = await validKind1WithAllPQKeys(); + const carrier = await signedProofCarrier(kind1, new Uint8Array(100)); + const otherKey = '0'.repeat(64); + const result = await m.selectCanonicalProofCarrier([carrier], kind1, otherKey); + assert.equal(result.canonical, null); + assert.ok(result.errors.some(e => e.includes('expected author')), 'should report author mismatch'); + }); +}); + +// ============================================================================ +// G56-03: IDENTITY BINDING (outer.pubkey === embedded.pubkey === expectedAuthor) +// ============================================================================ + +describe('G56-03: identity binding (verifyNIPQRContent)', () => { + const skA = new Uint8Array(32); + skA[31] = 1; + const pkA = m.bytesToHex(schnorr.getPublicKey(skA)); + const skB = new Uint8Array(32); + skB[31] = 2; + const pkB = m.bytesToHex(schnorr.getPublicKey(skB)); + + async function signedKind1(pubkey, sk, content, tags) { + const event = { + pubkey, + created_at: 1700000000, + kind: 1, + tags: tags || [], + content: content || 'test attestation' + }; + event.id = m.computeEventId(event); + event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk)); + return event; + } + + test('G56-03: matching identities pass identity binding', async () => { + const kind1 = await signedKind1(pkA, skA, 'test', []); + const tags = [ + ['e', kind1.id], + ['sha256', m.hashFullEvent(kind1)] + ]; + const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, { + outerPubkey: pkA, + expectedAuthor: pkA + }); + assert.equal(result.identityBound, true, 'matching identities should pass'); + }); + + test('G56-03: mismatched outer/embedded identity fails', async () => { + // Kind 1 signed by A, but outer pubkey is B + const kind1 = await signedKind1(pkA, skA, 'test', []); + const tags = [ + ['e', kind1.id], + ['sha256', m.hashFullEvent(kind1)] + ]; + const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, { + outerPubkey: pkB + }); + assert.equal(result.identityBound, false, 'mismatched outer/embedded should fail'); + assert.equal(result.validForMigration, false, 'should not be valid for migration'); + }); + + test('G56-03: mismatched expected author fails', async () => { + const kind1 = await signedKind1(pkA, skA, 'test', []); + const tags = [ + ['e', kind1.id], + ['sha256', m.hashFullEvent(kind1)] + ]; + const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags, { + expectedAuthor: pkB + }); + assert.equal(result.identityBound, false, 'mismatched expected author should fail'); + assert.equal(result.validForMigration, false); + }); + + test('G56-03: no identity options → identityBound is true (backward compatible)', async () => { + const kind1 = await signedKind1(pkA, skA, 'test', []); + const tags = [ + ['e', kind1.id], + ['sha256', m.hashFullEvent(kind1)] + ]; + const result = m.verifyNIPQRContent(JSON.stringify(kind1), tags); + assert.equal(result.identityBound, true, 'without options, identity binding should pass'); + }); +}); diff --git a/www/index.html b/www/index.html index 0978f9b..9ca9efc 100644 --- a/www/index.html +++ b/www/index.html @@ -603,6 +603,7 @@ derivePQKeysFromSeed, buildKind1Announcement, buildKind11112Wrapper, + buildProofCarrier, computeEventId, hashFullEvent, verifyNIPQRContent, @@ -613,6 +614,7 @@ PQ_KEY_INFO, NIP_QR_KIND, buildUpgradedEvent, + selectCanonicalProofCarrier, timestampEvent, upgradeOts, isOtsConfirmed, @@ -749,24 +751,38 @@ e.preventDefault(); for (let i = 1; i <= 5; i++) setStepDone(i); setStepActive(6); - // Fetch the full kind 11112 event from relays so that pqEvent has - // the tags and content needed for buildUpgradedEvent() when the - // Bitcoin attestation is confirmed and we republish. + // G56-02: Fetch ALL proof carrier events from relays and select + // the canonical one (earliest valid Bitcoin anchor) if (!pqEvent.tags || !pqEvent.content) { - otsLog('Resuming OTS workflow: fetching full event from relays...'); + otsLog('Resuming OTS workflow: fetching proof carrier events from relays...'); const relayUrls = getRelayUrls(); - let fetched = null; + let allCandidates = []; await Promise.all(relayUrls.map(async (url) => { try { - const ev = await queryRelayForKind11112(currentPubkey, url); - if (ev && (!fetched || ev.created_at > fetched.created_at)) fetched = ev; + const events = await queryRelayForProofCarriers(currentPubkey, url); + if (events && events.length > 0) allCandidates.push(...events); } catch (e) { /* ignore */ } })); - if (fetched) { - pqEvent = fetched; - otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays.`); + // Deduplicate by event ID + const seenIds = new Set(); + allCandidates = allCandidates.filter(e => { + if (!e || !e.id || seenIds.has(e.id)) return false; + seenIds.add(e.id); + return true; + }); + if (allCandidates.length > 0) { + // Try to find the one matching our persisted event ID + const matching = allCandidates.find(e => e.id === pqEvent.id); + if (matching) { + pqEvent = matching; + otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays (${allCandidates.length} total candidates).`); + } else { + // Use the first candidate as fallback + pqEvent = allCandidates[0]; + otsLog(`Resuming OTS workflow: persisted event not found, using ${pqEvent.id.substring(0, 16)}... (${allCandidates.length} total candidates).`); + } } else { - otsLog('WARNING: Could not fetch the full event from relays. Republishing may fail.'); + otsLog('WARNING: Could not fetch any proof carrier events from relays. Republishing may fail.'); } } await startOtsFlow(); @@ -1105,31 +1121,31 @@ otsLog(`WARNING: OTS submission failed: ${otsError.message}. Kind 11112 event will not have OTS proof yet.`); } - // ---- Phase 3: Build and sign the kind 11112 wrapper event ---- - setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for kind 11112 wrapper...'); + // ---- Phase 3: Build and sign the proof carrier event (kind 9999) ---- + setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for proof carrier...'); - let kind11112Template; + let proofCarrierTemplate; if (otsProof && otsProof.length > 0) { - kind11112Template = buildKind11112Wrapper(kind1Event, otsProof); + proofCarrierTemplate = buildProofCarrier(kind1Event, otsProof, { otsStatus: 'pending' }); } else { - // No OTS proof — build wrapper without ots tag (will be added later) - kind11112Template = buildKind11112Wrapper(kind1Event, new Uint8Array(0)); + // No OTS proof — build carrier without ots/ots_status tags (will be added later) + proofCarrierTemplate = buildProofCarrier(kind1Event, new Uint8Array(0), { otsStatus: 'pending' }); // Remove the empty ots tag - kind11112Template.tags = kind11112Template.tags.filter(t => t[0] !== 'ots'); + proofCarrierTemplate.tags = proofCarrierTemplate.tags.filter(t => t[0] !== 'ots'); } - const kind11112Signed = await window.nostr.signEvent(kind11112Template); + const proofCarrierSigned = await window.nostr.signEvent(proofCarrierTemplate); pqEvent = { - id: kind11112Signed.id, - pubkey: kind11112Signed.pubkey, - created_at: kind11112Template.created_at, + id: proofCarrierSigned.id, + pubkey: proofCarrierSigned.pubkey, + created_at: proofCarrierTemplate.created_at, kind: NIP_QR_KIND, - content: kind11112Template.content, - tags: kind11112Template.tags, - sig: kind11112Signed.sig + content: proofCarrierTemplate.content, + tags: proofCarrierTemplate.tags, + sig: proofCarrierSigned.sig }; - otsLog(`Kind 11112 wrapper signed. Event ID: ${pqEvent.id.substring(0, 32)}...`); + otsLog(`Proof carrier signed. Event ID: ${pqEvent.id.substring(0, 32)}...`); // Save the pending OTS proof for the OTS step if (otsProof && otsProof.length > 0) { @@ -1190,27 +1206,29 @@ } /* ================================================================ - RELAY QUERY (fetch kind 11112 event for OTS resume) + RELAY QUERY (fetch ALL proof carrier events for OTS resume) + G56-02: fetch all candidates, not limit: 1 ================================================================ */ - function queryRelayForKind11112(pubkeyHex, relayUrl) { + function queryRelayForProofCarriers(pubkeyHex, relayUrl) { return new Promise((resolve, reject) => { try { const ws = new WebSocket(relayUrl); let resolved = false; + const events = []; const timeout = setTimeout(() => { - if (!resolved) { resolved = true; try { ws.close(); } catch (e) {} reject(new Error('timeout')); } + if (!resolved) { resolved = true; try { ws.close(); } catch (e) {} resolve(events); } }, 15000); ws.onopen = () => { const subId = 'pq_resume_' + Math.random().toString(36).substring(7); - ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }])); + ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }])); }; ws.onmessage = (msg) => { try { const data = JSON.parse(msg.data); if (data[0] === 'EVENT') { - if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(data[2]); } + if (data[2]) events.push(data[2]); } else if (data[0] === 'EOSE') { - if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(null); } + if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(events); } } } catch (e) {} }; diff --git a/www/js/pq-crypto.mjs b/www/js/pq-crypto.mjs index dd6576a..d65b92b 100644 --- a/www/js/pq-crypto.mjs +++ b/www/js/pq-crypto.mjs @@ -423,11 +423,14 @@ export function verifyNostrEvent(event) { // ============================================================================ /** - * NIP-QR event kind. Kind 11112 is a replaceable event (10000-19999 range), - * so each pubkey can have only one event of this kind. No `d` tag needed. - * Publishing a new kind 11112 event automatically replaces the old one on relays. + * NIP-QR proof carrier event kind. Kind 9999 is a non-replaceable event + * (0–9999 range). Each publication is permanent — relays cannot replace it. + * Upgrades (e.g. pending → confirmed OTS proof) publish a NEW kind 9999 event + * with an `upgrade_of` tag referencing the previous one. Clients fetch all + * kind 9999 events for a pubkey and select the one with the earliest valid + * Bitcoin anchor. */ -export const NIP_QR_KIND = 11112; +export const NIP_QR_KIND = 9999; /** * Build the kind 1 announcement event (unsigned template). @@ -533,74 +536,122 @@ export function hashFullEvent(signedEvent) { } /** - * Build the kind 11112 wrapper event (unsigned template). + * Build the kind 9999 proof carrier event (unsigned template). * * This event wraps the kind 1 announcement and carries the OpenTimestamps proof. * The content is the full kind 1 event as JSON (so verifiers don't need to fetch * it from relays). The tags reference the kind 1 event ID and contain the OTS proof. * + * Kind 9999 is non-replaceable. Each publication is permanent. Upgrades publish + * a new event with `upgrade_of` referencing the previous one. + * * Tags: * - ['e', ''] — reference to the kind 1 announcement * - ['sha256', ''] — what was timestamped * - ['ots', ''] — the OpenTimestamps proof + * - ['ots_status', 'pending'|'confirmed'] — quick UI indicator + * - ['upgrade_of', ''] — only on upgrade events * * @param {object} kind1Event - The fully signed kind 1 event * @param {Uint8Array} otsProof - The OTS proof bytes (pending or confirmed) + * @param {object} [options] - Optional parameters + * @param {string} [options.otsStatus='pending'] - 'pending' or 'confirmed' + * @param {string} [options.upgradeOf] - Previous proof carrier event ID (for upgrades) * @returns {{kind: number, content: string, tags: Array, pubkey: string, created_at: number}} */ -export function buildKind11112Wrapper(kind1Event, otsProof) { +export function buildProofCarrier(kind1Event, otsProof, options = {}) { const fullHash = hashFullEvent(kind1Event); + const otsStatus = options.otsStatus || 'pending'; + + const tags = [ + ['e', kind1Event.id], + ['sha256', fullHash], + ['ots', bytesToBase64(otsProof)], + ['ots_status', otsStatus] + ]; + + if (options.upgradeOf) { + tags.push(['upgrade_of', options.upgradeOf]); + } return { kind: NIP_QR_KIND, content: JSON.stringify(kind1Event), - tags: [ - ['e', kind1Event.id], - ['sha256', fullHash], - ['ots', bytesToBase64(otsProof)] - ], + tags, pubkey: kind1Event.pubkey, created_at: Math.floor(Date.now() / 1000) }; } /** - * Build an upgraded kind 11112 event from an existing one. - * Copies all tags and content from the original event, replaces the OTS proof tag - * with the upgraded proof, and updates the timestamp. The content (the kind 1 - * event JSON) and the 'e' and 'sha256' tags stay the same — only the 'ots' tag - * changes. + * Backward-compatible alias for buildProofCarrier. + * @deprecated Use buildProofCarrier() instead. + */ +export function buildKind11112Wrapper(kind1Event, otsProof) { + return buildProofCarrier(kind1Event, otsProof); +} + +/** + * Build an upgraded proof carrier event (append-only, non-replacing). * - * @param {object} originalEvent - The original kind 11112 event + * Instead of replacing the original event, this builds a NEW kind 9999 event + * with the upgraded OTS proof. The new event references the original via the + * `upgrade_of` tag. Both events remain on relays permanently. + * + * The `e` and `sha256` tags stay the same — they reference the same kind 1 + * announcement. Only the `ots` proof and `ots_status` change. + * + * @param {object} originalEvent - The original proof carrier event (pending) * @param {Uint8Array} upgradedOtsProof - The upgraded/confirmed OTS proof bytes * @returns {{kind: number, created_at: number, tags: Array, content: string, pubkey: string}} */ export function buildUpgradedEvent(originalEvent, upgradedOtsProof) { - // Copy all tags except 'ots', then add the upgraded 'ots' tag - const tags = originalEvent.tags.filter(tag => tag[0] !== 'ots'); - tags.push(['ots', bytesToBase64(upgradedOtsProof)]); + // Extract the kind 1 event from the original content to rebuild tags cleanly + let kind1Event; + try { + kind1Event = JSON.parse(originalEvent.content); + } catch (e) { + // If we can't parse, fall back to copying tags (shouldn't happen in practice) + const tags = originalEvent.tags + .filter(t => t[0] !== 'ots' && t[0] !== 'ots_status' && t[0] !== 'upgrade_of') + .map(t => [...t]); + tags.push(['ots', bytesToBase64(upgradedOtsProof)]); + tags.push(['ots_status', 'confirmed']); + tags.push(['upgrade_of', originalEvent.id]); + return { + kind: NIP_QR_KIND, + created_at: Math.floor(Date.now() / 1000), + tags, + content: originalEvent.content, + pubkey: originalEvent.pubkey + }; + } - return { - kind: NIP_QR_KIND, - created_at: Math.floor(Date.now() / 1000), - tags: tags, - content: originalEvent.content, - pubkey: originalEvent.pubkey - }; + // Build a clean new proof carrier with the upgraded proof + return buildProofCarrier(kind1Event, upgradedOtsProof, { + otsStatus: 'confirmed', + upgradeOf: originalEvent.id + }); } /** - * Verify a NIP-QR kind 11112 event. + * Verify a NIP-QR proof carrier event's content and tags. * - * The kind 11112 content is the full kind 1 event as JSON. This function: + * The proof carrier content is the full kind 1 event as JSON. This function: * 1. Parses the kind 1 event from the content * 2. Verifies the kind 1 secp256k1 signature * 3. Verifies the 'e' tag matches the kind 1 event ID * 4. Verifies the 'sha256' tag matches SHA-256 of the full kind 1 event JSON * 5. Verifies each PQ signature in the kind 1 tags against the content text + * 6. (G56-03) If expectedAuthor is provided, verifies identity binding: + * the proof carrier pubkey, embedded kind 1 pubkey, and expected author + * must all be equal. * - * @param {string} kind11112Content - The kind 11112 content (JSON string of kind 1 event) - * @param {Array} kind11112Tags - The kind 11112 tags array + * @param {string} kind11112Content - The proof carrier content (JSON string of kind 1 event) + * @param {Array} kind11112Tags - The proof carrier tags array + * @param {object} [options] - Optional parameters + * @param {string} [options.expectedAuthor] - Expected author pubkey (hex) for G56-03 identity binding + * @param {string} [options.outerPubkey] - The proof carrier event's pubkey (for G56-03 identity binding) * @returns {{ * valid: boolean, * results: Array, @@ -611,17 +662,20 @@ export function buildUpgradedEvent(originalEvent, upgradedOtsProof) { * pqProofsValid: boolean, * policySufficient: boolean, * validForMigration: boolean, + * identityBound: boolean, * errors: Array * }} * * `valid` is retained for backward compatibility and means "every individual * check that ran passed". `validForMigration` is the strict policy-gated * result: it is true only when the secp signature, e tag, sha256 tag, all - * present PQ proofs, AND the mandatory algorithm policy are all satisfied. + * present PQ proofs, the mandatory algorithm policy, AND identity binding + * are all satisfied. */ -export function verifyNIPQRContent(kind11112Content, kind11112Tags) { +export function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) { const results = []; const errors = []; + const { expectedAuthor, outerPubkey } = options; // --- Helper: push a result entry and track failures --- function pushResult(algorithm, valid, note) { @@ -652,6 +706,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid: false, policySufficient: false, validForMigration: false, + identityBound: false, errors: ['Content is not valid JSON'] }; } @@ -669,6 +724,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid: false, policySufficient: false, validForMigration: false, + identityBound: false, errors: ['Embedded event is not a valid kind 1 event'] }; } @@ -683,6 +739,31 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) { } pushResult('secp256k1 (kind 1 announcement)', kind1SigValid, kind1SigValid ? 'valid' : 'INVALID'); + // 2b. G56-03: Identity binding check + // + // The proof carrier pubkey, the embedded kind 1 pubkey, and (if + // provided) the expected author must all be equal. Without this, + // identity A can wrap identity B's announcement and the verifier + // would say "valid" for both. + let identityBound = true; + const embeddedPubkey = kind1Event.pubkey.toLowerCase(); + + if (outerPubkey && outerPubkey.toLowerCase() !== embeddedPubkey) { + identityBound = false; + errors.push(`identity binding: outer pubkey ${outerPubkey.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`); + } + + if (expectedAuthor && expectedAuthor.toLowerCase() !== embeddedPubkey) { + identityBound = false; + errors.push(`identity binding: expected author ${expectedAuthor.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`); + } + + pushResult( + 'identity binding (outer = embedded = author)', + identityBound, + identityBound ? 'all identities match' : 'identity mismatch' + ); + // 3. Verify the 'e' tag matches the kind 1 event ID const computedKind1Id = computeEventId(kind1Event); const eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter(t => Array.isArray(t) && t[0] === 'e'); @@ -879,7 +960,7 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) { // 7. Compute final results const allChecksPassed = results.every(r => r.valid); - const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient; + const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound; return { valid: allChecksPassed, @@ -891,6 +972,189 @@ export function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid, policySufficient, validForMigration, + identityBound, + errors + }; +} + +// ============================================================================ +// CANONICAL PROOF CARRIER SELECTION (G56-02) +// ============================================================================ +// +// When multiple proof carrier events exist for the same pubkey (e.g. a +// pending one and a later confirmed one, or multiple migration attempts), +// the client must select the one with the earliest valid Bitcoin anchor. +// This implements the "earliest valid anchor wins" rule from the NIP proposal. + +/** + * Select the canonical proof carrier from an array of candidates. + * + * This function: + * 1. Filters candidates whose `e` tag matches the kind 1 event ID + * 2. Filters candidates whose `sha256` tag matches the kind 1 event hash + * 3. Filters candidates with a valid secp256k1 signature + * 4. For each remaining candidate, verifies its OTS proof against the + * expected digest (the sha256 tag) + * 5. Among candidates with a verified Bitcoin attestation, selects the + * one with the earliest block height (deterministic tie-break by + * event ID lexicographically) + * 6. If no candidate has a confirmed Bitcoin anchor, returns the best + * pending candidate + * + * @param {Array} candidates - Array of proof carrier events (kind 9999) + * @param {object} kind1Event - The kind 1 announcement event they reference + * @param {string} [expectedAuthor] - Expected author pubkey (hex) for identity binding + * @returns {Promise<{canonical: object|null, allCandidates: Array, confirmedCandidates: Array, pendingCandidates: Array, errors: Array}>} + */ +export async function selectCanonicalProofCarrier(candidates, kind1Event, expectedAuthor) { + const errors = []; + if (!Array.isArray(candidates) || candidates.length === 0) { + return { canonical: null, allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ['No candidates provided'] }; + } + if (!kind1Event || !kind1Event.id) { + return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ['No kind 1 event provided'] }; + } + + const expectedKind1Id = kind1Event.id.toLowerCase(); + const expectedHash = hashFullEvent(kind1Event).toLowerCase(); + + // Step 1-3: Filter candidates by e tag, sha256 tag, and secp signature + const validCandidates = []; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') continue; + if (candidate.kind !== NIP_QR_KIND) { + errors.push(`candidate ${candidate.id || '?'}: wrong kind ${candidate.kind}`); + continue; + } + + // Check e tag matches kind 1 event ID + const eTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'e'); + if (!eTag || !eTag[1] || eTag[1].toLowerCase() !== expectedKind1Id) { + errors.push(`candidate ${candidate.id || '?'}: e tag does not match kind 1 event ID`); + continue; + } + + // Check sha256 tag matches kind 1 event hash + const sha256Tag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'sha256'); + if (!sha256Tag || !sha256Tag[1] || sha256Tag[1].toLowerCase() !== expectedHash) { + errors.push(`candidate ${candidate.id || '?'}: sha256 tag does not match kind 1 event hash`); + continue; + } + + // Check secp signature + let secpValid = false; + try { + secpValid = verifyNostrEvent(candidate); + } catch (e) { + secpValid = false; + } + if (!secpValid) { + errors.push(`candidate ${candidate.id || '?'}: invalid secp256k1 signature`); + continue; + } + + // G56-03: Check identity binding if expectedAuthor is provided + if (expectedAuthor && candidate.pubkey && candidate.pubkey.toLowerCase() !== expectedAuthor.toLowerCase()) { + errors.push(`candidate ${candidate.id || '?'}: pubkey does not match expected author`); + continue; + } + + validCandidates.push(candidate); + } + + if (validCandidates.length === 0) { + return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors }; + } + + // Step 4: Verify OTS proof for each valid candidate + const confirmedCandidates = []; + const pendingCandidates = []; + + for (const candidate of validCandidates) { + const otsTag = (candidate.tags || []).find(t => Array.isArray(t) && t[0] === 'ots'); + if (!otsTag || !otsTag[1]) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: ['no ots tag'] }); + continue; + } + + let otsBytes; + try { + otsBytes = base64ToBytes(otsTag[1]); + } catch (e) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`] }); + continue; + } + + try { + const verifyResult = await verifyOtsProof(otsBytes, expectedHash); + if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) { + confirmedCandidates.push({ + event: candidate, + bitcoinHeight: verifyResult.bitcoinAttestations[0].height, + bitcoinTime: verifyResult.bitcoinAttestations[0].time, + errors: [] + }); + } else { + pendingCandidates.push({ + event: candidate, + bitcoinHeight: null, + errors: verifyResult.errors + }); + } + } catch (e) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`] }); + } + } + + // Step 5: Among confirmed candidates, select earliest Bitcoin block height + if (confirmedCandidates.length > 0) { + confirmedCandidates.sort((a, b) => { + // Primary sort: lowest Bitcoin block height + if (a.bitcoinHeight !== b.bitcoinHeight) { + return a.bitcoinHeight - b.bitcoinHeight; + } + // Tie-break: lowest event ID lexicographically (deterministic) + return (a.event.id || '').localeCompare(b.event.id || ''); + }); + + return { + canonical: confirmedCandidates[0].event, + canonicalBitcoinHeight: confirmedCandidates[0].bitcoinHeight, + canonicalBitcoinTime: confirmedCandidates[0].bitcoinTime, + allCandidates: candidates, + confirmedCandidates, + pendingCandidates, + errors + }; + } + + // Step 6: No confirmed candidates — return the best pending one + // (earliest created_at, tie-break by event ID) + if (pendingCandidates.length > 0) { + pendingCandidates.sort((a, b) => { + if (a.event.created_at !== b.event.created_at) { + return a.event.created_at - b.event.created_at; + } + return (a.event.id || '').localeCompare(b.event.id || ''); + }); + return { + canonical: pendingCandidates[0].event, + canonicalBitcoinHeight: null, + canonicalBitcoinTime: null, + allCandidates: candidates, + confirmedCandidates: [], + pendingCandidates, + errors + }; + } + + return { + canonical: null, + canonicalBitcoinHeight: null, + canonicalBitcoinTime: null, + allCandidates: candidates, + confirmedCandidates: [], + pendingCandidates: [], errors }; } diff --git a/www/js/version.json b/www/js/version.json index 8915c5f..1329774 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.0.13", - "VERSION_NUMBER": "0.0.13", - "BUILD_DATE": "2026-07-23T17:35:37.091Z" + "VERSION": "v0.0.14", + "VERSION_NUMBER": "0.0.14", + "BUILD_DATE": "2026-07-24T11:16:26.143Z" } diff --git a/www/pq-crypto.bundle.js b/www/pq-crypto.bundle.js index 14278bd..161fc74 100644 --- a/www/pq-crypto.bundle.js +++ b/www/pq-crypto.bundle.js @@ -9771,7 +9771,7 @@ function verifyNostrEvent(event) { const pubkey = hexToBytes3(event.pubkey); return schnorr.verify(sig, hash, pubkey); } -var NIP_QR_KIND = 11112; +var NIP_QR_KIND = 9999; function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) { const npub = hexToNpub(hexPubkey); const content = `I am signaling that the post-quantum public keys listed in the tags of this event were generated by me and I hold the private keys. I may use these keys in the future as successors to my current Nostr identity. @@ -9830,34 +9830,55 @@ function hashFullEvent(signedEvent) { const json = JSON.stringify(signedEvent); return bytesToHex3(sha256(new TextEncoder().encode(json))); } -function buildKind11112Wrapper(kind1Event, otsProof) { +function buildProofCarrier(kind1Event, otsProof, options = {}) { const fullHash = hashFullEvent(kind1Event); + const otsStatus = options.otsStatus || "pending"; + const tags = [ + ["e", kind1Event.id], + ["sha256", fullHash], + ["ots", bytesToBase64(otsProof)], + ["ots_status", otsStatus] + ]; + if (options.upgradeOf) { + tags.push(["upgrade_of", options.upgradeOf]); + } return { kind: NIP_QR_KIND, content: JSON.stringify(kind1Event), - tags: [ - ["e", kind1Event.id], - ["sha256", fullHash], - ["ots", bytesToBase64(otsProof)] - ], + tags, pubkey: kind1Event.pubkey, created_at: Math.floor(Date.now() / 1e3) }; } -function buildUpgradedEvent(originalEvent, upgradedOtsProof) { - const tags = originalEvent.tags.filter((tag) => tag[0] !== "ots"); - tags.push(["ots", bytesToBase64(upgradedOtsProof)]); - return { - kind: NIP_QR_KIND, - created_at: Math.floor(Date.now() / 1e3), - tags, - content: originalEvent.content, - pubkey: originalEvent.pubkey - }; +function buildKind11112Wrapper(kind1Event, otsProof) { + return buildProofCarrier(kind1Event, otsProof); } -function verifyNIPQRContent(kind11112Content, kind11112Tags) { +function buildUpgradedEvent(originalEvent, upgradedOtsProof) { + let kind1Event; + try { + kind1Event = JSON.parse(originalEvent.content); + } catch (e) { + const tags = originalEvent.tags.filter((t) => t[0] !== "ots" && t[0] !== "ots_status" && t[0] !== "upgrade_of").map((t) => [...t]); + tags.push(["ots", bytesToBase64(upgradedOtsProof)]); + tags.push(["ots_status", "confirmed"]); + tags.push(["upgrade_of", originalEvent.id]); + return { + kind: NIP_QR_KIND, + created_at: Math.floor(Date.now() / 1e3), + tags, + content: originalEvent.content, + pubkey: originalEvent.pubkey + }; + } + return buildProofCarrier(kind1Event, upgradedOtsProof, { + otsStatus: "confirmed", + upgradeOf: originalEvent.id + }); +} +function verifyNIPQRContent(kind11112Content, kind11112Tags, options = {}) { const results = []; const errors = []; + const { expectedAuthor, outerPubkey } = options; function pushResult(algorithm, valid, note) { results.push({ algorithm, valid, note }); if (!valid) errors.push(`${algorithm}: ${note || "INVALID"}`); @@ -9882,6 +9903,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid: false, policySufficient: false, validForMigration: false, + identityBound: false, errors: ["Content is not valid JSON"] }; } @@ -9896,6 +9918,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid: false, policySufficient: false, validForMigration: false, + identityBound: false, errors: ["Embedded event is not a valid kind 1 event"] }; } @@ -9907,6 +9930,21 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) { errors.push(`secp256k1 (kind 1): ${e.message}`); } pushResult("secp256k1 (kind 1 announcement)", kind1SigValid, kind1SigValid ? "valid" : "INVALID"); + let identityBound = true; + const embeddedPubkey = kind1Event.pubkey.toLowerCase(); + if (outerPubkey && outerPubkey.toLowerCase() !== embeddedPubkey) { + identityBound = false; + errors.push(`identity binding: outer pubkey ${outerPubkey.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`); + } + if (expectedAuthor && expectedAuthor.toLowerCase() !== embeddedPubkey) { + identityBound = false; + errors.push(`identity binding: expected author ${expectedAuthor.substring(0, 16)}... does not match embedded pubkey ${embeddedPubkey.substring(0, 16)}...`); + } + pushResult( + "identity binding (outer = embedded = author)", + identityBound, + identityBound ? "all identities match" : "identity mismatch" + ); const computedKind1Id = computeEventId(kind1Event); const eTags = (Array.isArray(kind11112Tags) ? kind11112Tags : []).filter((t) => Array.isArray(t) && t[0] === "e"); let eTagValid = false; @@ -10056,7 +10094,7 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) { for (const e of policyErrors) errors.push(e); } const allChecksPassed = results.every((r) => r.valid); - const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient; + const validForMigration = kind1SigValid && eTagValid && sha256Valid && pqProofsValid && policySufficient && identityBound; return { valid: allChecksPassed, results, @@ -10067,6 +10105,132 @@ function verifyNIPQRContent(kind11112Content, kind11112Tags) { pqProofsValid, policySufficient, validForMigration, + identityBound, + errors + }; +} +async function selectCanonicalProofCarrier(candidates, kind1Event, expectedAuthor) { + const errors = []; + if (!Array.isArray(candidates) || candidates.length === 0) { + return { canonical: null, allCandidates: [], confirmedCandidates: [], pendingCandidates: [], errors: ["No candidates provided"] }; + } + if (!kind1Event || !kind1Event.id) { + return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors: ["No kind 1 event provided"] }; + } + const expectedKind1Id = kind1Event.id.toLowerCase(); + const expectedHash = hashFullEvent(kind1Event).toLowerCase(); + const validCandidates = []; + for (const candidate of candidates) { + if (!candidate || typeof candidate !== "object") continue; + if (candidate.kind !== NIP_QR_KIND) { + errors.push(`candidate ${candidate.id || "?"}: wrong kind ${candidate.kind}`); + continue; + } + const eTag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "e"); + if (!eTag || !eTag[1] || eTag[1].toLowerCase() !== expectedKind1Id) { + errors.push(`candidate ${candidate.id || "?"}: e tag does not match kind 1 event ID`); + continue; + } + const sha256Tag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "sha256"); + if (!sha256Tag || !sha256Tag[1] || sha256Tag[1].toLowerCase() !== expectedHash) { + errors.push(`candidate ${candidate.id || "?"}: sha256 tag does not match kind 1 event hash`); + continue; + } + let secpValid = false; + try { + secpValid = verifyNostrEvent(candidate); + } catch (e) { + secpValid = false; + } + if (!secpValid) { + errors.push(`candidate ${candidate.id || "?"}: invalid secp256k1 signature`); + continue; + } + if (expectedAuthor && candidate.pubkey && candidate.pubkey.toLowerCase() !== expectedAuthor.toLowerCase()) { + errors.push(`candidate ${candidate.id || "?"}: pubkey does not match expected author`); + continue; + } + validCandidates.push(candidate); + } + if (validCandidates.length === 0) { + return { canonical: null, allCandidates: candidates, confirmedCandidates: [], pendingCandidates: [], errors }; + } + const confirmedCandidates = []; + const pendingCandidates = []; + for (const candidate of validCandidates) { + const otsTag = (candidate.tags || []).find((t) => Array.isArray(t) && t[0] === "ots"); + if (!otsTag || !otsTag[1]) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: ["no ots tag"] }); + continue; + } + let otsBytes; + try { + otsBytes = base64ToBytes(otsTag[1]); + } catch (e) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots decode failed: ${e.message}`] }); + continue; + } + try { + const verifyResult = await verifyOtsProof(otsBytes, expectedHash); + if (verifyResult.verified && verifyResult.bitcoinAttestations.length > 0) { + confirmedCandidates.push({ + event: candidate, + bitcoinHeight: verifyResult.bitcoinAttestations[0].height, + bitcoinTime: verifyResult.bitcoinAttestations[0].time, + errors: [] + }); + } else { + pendingCandidates.push({ + event: candidate, + bitcoinHeight: null, + errors: verifyResult.errors + }); + } + } catch (e) { + pendingCandidates.push({ event: candidate, bitcoinHeight: null, errors: [`ots verification threw: ${e.message}`] }); + } + } + if (confirmedCandidates.length > 0) { + confirmedCandidates.sort((a, b) => { + if (a.bitcoinHeight !== b.bitcoinHeight) { + return a.bitcoinHeight - b.bitcoinHeight; + } + return (a.event.id || "").localeCompare(b.event.id || ""); + }); + return { + canonical: confirmedCandidates[0].event, + canonicalBitcoinHeight: confirmedCandidates[0].bitcoinHeight, + canonicalBitcoinTime: confirmedCandidates[0].bitcoinTime, + allCandidates: candidates, + confirmedCandidates, + pendingCandidates, + errors + }; + } + if (pendingCandidates.length > 0) { + pendingCandidates.sort((a, b) => { + if (a.event.created_at !== b.event.created_at) { + return a.event.created_at - b.event.created_at; + } + return (a.event.id || "").localeCompare(b.event.id || ""); + }); + return { + canonical: pendingCandidates[0].event, + canonicalBitcoinHeight: null, + canonicalBitcoinTime: null, + allCandidates: candidates, + confirmedCandidates: [], + pendingCandidates, + errors + }; + } + return { + canonical: null, + canonicalBitcoinHeight: null, + canonicalBitcoinTime: null, + allCandidates: candidates, + confirmedCandidates: [], + pendingCandidates: [], errors }; } @@ -10493,6 +10657,7 @@ export { base64ToBytes, buildKind11112Wrapper, buildKind1Announcement, + buildProofCarrier, buildUpgradedEvent, bytesToBase64, bytesToHex3 as bytesToHex, @@ -10512,6 +10677,7 @@ export { mnemonicToSeed, parseOtsFile, savePendingOts, + selectCanonicalProofCarrier, signWithFalcon, signWithMLDSA44, signWithMLDSA65, diff --git a/www/verify.html b/www/verify.html index 5fe0d3a..a0c7cd6 100644 --- a/www/verify.html +++ b/www/verify.html @@ -369,6 +369,8 @@ verifyNostrEvent, NIP_QR_KIND, buildUpgradedEvent, + buildProofCarrier, + selectCanonicalProofCarrier, bytesToBase64, base64ToBytes, hexToBytes, @@ -415,6 +417,7 @@ let currentEvent = null; let currentOtsBytes = null; + let currentExpectedAuthor = null; // F-H3: Build status DOM safely — type is internally controlled, message uses textContent function setStatus(element, type, message) { @@ -469,60 +472,56 @@ } /* ================================================================ - QUERY RELAY FOR KIND 11112 EVENT + QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates) ================================================================ */ - function queryRelayForEvent(pubkeyHex, relayUrl) { - return new Promise((resolve, reject) => { - try { - const ws = new WebSocket(relayUrl); - let resolved = false; - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true; - try { ws.close(); } catch (e) {} - reject(new Error('timeout')); - } - }, 15000); + function queryRelayForEvents(pubkeyHex, relayUrl) { + return new Promise((resolve, reject) => { + try { + const ws = new WebSocket(relayUrl); + let resolved = false; + const events = []; + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + try { ws.close(); } catch (e) {} + resolve(events); + } + }, 15000); - ws.onopen = () => { - // REQ for kind 11112, limit 1 (latest) - const subId = 'pq_verify_' + Math.random().toString(36).substring(7); - ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }])); - }; + ws.onopen = () => { + // REQ for all proof carrier events — no limit (G56-02 fix) + const subId = 'pq_verify_' + Math.random().toString(36).substring(7); + ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }])); + }; - ws.onmessage = (msg) => { - try { - const data = JSON.parse(msg.data); - if (data[0] === 'EVENT') { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - try { ws.close(); } catch (e) {} - resolve(data[2]); - } - } else if (data[0] === 'EOSE') { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - try { ws.close(); } catch (e) {} - resolve(null); - } - } - } catch (e) {} - }; + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data); + if (data[0] === 'EVENT') { + if (data[2]) events.push(data[2]); + } else if (data[0] === 'EOSE') { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + try { ws.close(); } catch (e) {} + resolve(events); + } + } + } catch (e) {} + }; - ws.onerror = () => { - if (!resolved) { - resolved = true; - clearTimeout(timeout); - reject(new Error('connection error')); - } - }; - } catch (e) { - reject(e); - } - }); - } + ws.onerror = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + reject(new Error('connection error')); + } + }; + } catch (e) { + reject(e); + } + }); + } /* ================================================================ VERIFY EVENT (shared by both tabs) @@ -535,31 +534,35 @@ otsUpgradeWrap.style.display = 'none'; otsBadge.innerHTML = ''; - // Display full kind 11112 JSON + // Display full proof carrier JSON eventJsonEl.textContent = JSON.stringify(event, null, 2); eventJsonWrap.style.display = 'block'; let allValid = true; - // 1. Verify kind 11112 secp256k1 signature + // 1. Verify proof carrier secp256k1 signature let secpValid = false; try { secpValid = verifyNostrEvent(event); } catch (e) { secpValid = false; - addResult('secp256k1 (kind 11112 wrapper)', false, `verification threw: ${e.message}`); + addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`); } if (secpValid) { - addResult('secp256k1 (kind 11112 wrapper)', true, 'valid'); + addResult('secp256k1 (proof carrier)', true, 'valid'); } else if (!resultsWrap.querySelector('.pq-result-item')) { - addResult('secp256k1 (kind 11112 wrapper)', false, 'INVALID'); + addResult('secp256k1 (proof carrier)', false, 'INVALID'); } if (!secpValid) allValid = false; // 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy) + // G56-03: pass outer pubkey and expected author for identity binding let pqResults; try { - pqResults = verifyNIPQRContent(event.content, event.tags); + pqResults = verifyNIPQRContent(event.content, event.tags, { + outerPubkey: event.pubkey, + expectedAuthor: currentExpectedAuthor || undefined + }); } catch (e) { pqResults = { valid: false, @@ -691,21 +694,19 @@ return; } - let foundEvent = null; + let allCandidates = []; let lastError = null; let successCount = 0; - // Query relays in parallel, take the first event returned + // G56-02: Query relays in parallel, collect ALL candidates (not limit: 1) const promises = relayUrls.map(async (url) => { try { - const event = await queryRelayForEvent(pubkeyHex, url); - if (event) { + const events = await queryRelayForEvents(pubkeyHex, url); + if (events && events.length > 0) { successCount++; - if (!foundEvent || (event.created_at > foundEvent.created_at)) { - foundEvent = event; - } + allCandidates.push(...events); } - return { url, ok: true, event }; + return { url, ok: true, count: events ? events.length : 0 }; } catch (e) { lastError = e.message; return { url, ok: false, error: e.message }; @@ -714,12 +715,61 @@ await Promise.all(promises); - if (foundEvent) { - const npub = hexToNpub(pubkeyHex); - setStatus(queryStatus, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`); - await verifyEvent(foundEvent); - } else { - setStatus(queryStatus, 'error', `No kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`); + // Deduplicate by event ID + const seenIds = new Set(); + allCandidates = allCandidates.filter(e => { + if (!e || !e.id || seenIds.has(e.id)) return false; + seenIds.add(e.id); + return true; + }); + + if (allCandidates.length === 0) { + setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`); + queryBtn.disabled = false; + return; + } + + const npub = hexToNpub(pubkeyHex); + setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`); + + // G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor) + // First, parse the kind 1 event from the first candidate to use for selection + let kind1Event = null; + try { + kind1Event = JSON.parse(allCandidates[0].content); + } catch (e) { + // Try other candidates + for (const c of allCandidates) { + try { + kind1Event = JSON.parse(c.content); + break; + } catch (e2) { /* keep trying */ } + } + } + + if (!kind1Event) { + setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.'); + queryBtn.disabled = false; + return; + } + + // Set the expected author for G56-03 identity binding + currentExpectedAuthor = pubkeyHex; + + try { + const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex); + + if (selection.canonical) { + const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)'; + const confirmedCount = selection.confirmedCandidates.length; + const pendingCount = selection.pendingCandidates.length; + setStatus(queryStatus, 'success', `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).`); + await verifyEvent(selection.canonical); + } else { + setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`); + } + } catch (e) { + setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`); } queryBtn.disabled = false;