G56-02+03: Switch to non-replaceable kind 9999, append-only upgrades, earliest-anchor selection, identity binding

This commit is contained in:
Laan Tungir
2026-07-24 07:16:26 -04:00
parent a728e125a8
commit 5a4a822c51
8 changed files with 1042 additions and 159 deletions
+1 -1
View File
@@ -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": {
@@ -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 1000019999 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 09999 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="<pending event id>"
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 (09999 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: <original proof carrier event id>`
- 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: <pending event id>`
- 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 (09999 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 09999 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
+202
View File
@@ -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');
});
});
+50 -32
View File
@@ -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) {}
};
+298 -34
View File
@@ -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
* (09999 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', '<kind1_event_id>'] — reference to the kind 1 announcement
* - ['sha256', '<hex hash of full kind 1 event JSON>'] — what was timestamped
* - ['ots', '<base64 .ots proof>'] — the OpenTimestamps proof
* - ['ots_status', 'pending'|'confirmed'] — quick UI indicator
* - ['upgrade_of', '<previous_proof_carrier_event_id>'] — 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
};
}
+3 -3
View File
@@ -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"
}
+185 -19
View File
@@ -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,
+120 -70
View File
@@ -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;