G56-02+03: Switch to non-replaceable kind 9999, append-only upgrades, earliest-anchor selection, identity binding
This commit is contained in:
+1
-1
@@ -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 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="<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 (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: <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 (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
|
||||
@@ -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
@@ -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 {
|
||||
otsLog('WARNING: Could not fetch the full event from relays. Republishing may fail.');
|
||||
// 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 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) {}
|
||||
};
|
||||
|
||||
+292
-28
@@ -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', '<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');
|
||||
// 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: 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
@@ -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"
|
||||
}
|
||||
|
||||
+176
-10
@@ -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,23 +9830,38 @@ 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 buildKind11112Wrapper(kind1Event, otsProof) {
|
||||
return buildProofCarrier(kind1Event, otsProof);
|
||||
}
|
||||
function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
|
||||
const tags = originalEvent.tags.filter((tag) => tag[0] !== "ots");
|
||||
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),
|
||||
@@ -9855,9 +9870,15 @@ function buildUpgradedEvent(originalEvent, upgradedOtsProof) {
|
||||
pubkey: originalEvent.pubkey
|
||||
};
|
||||
}
|
||||
function verifyNIPQRContent(kind11112Content, kind11112Tags) {
|
||||
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,
|
||||
|
||||
+80
-30
@@ -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,43 +472,39 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
QUERY RELAY FOR KIND 11112 EVENT
|
||||
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
|
||||
================================================================ */
|
||||
function queryRelayForEvent(pubkeyHex, relayUrl) {
|
||||
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) {}
|
||||
reject(new Error('timeout'));
|
||||
resolve(events);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// REQ for kind 11112, limit 1 (latest)
|
||||
// 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], 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);
|
||||
resolve(events);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -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) {
|
||||
// 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, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`);
|
||||
await verifyEvent(foundEvent);
|
||||
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 kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user