Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29f63950b | ||
|
|
a1e1759704 |
@@ -9,10 +9,10 @@
|
||||
| Severity | Total | Fixed | In Progress | Not Started |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Critical | 2 | 2 | 0 | 0 |
|
||||
| High | 6 | 2 | 0 | 4 |
|
||||
| High | 6 | 4 | 0 | 2 |
|
||||
| Medium | 8 | 2 | 0 | 6 |
|
||||
| Low / Info | 4 | 0 | 0 | 4 |
|
||||
| **Total** | **20** | **6** | **0** | **14** |
|
||||
| **Total** | **20** | **8** | **0** | **12** |
|
||||
|
||||
## Findings status
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
| G56-01 | Critical | Verifier accepts no PQ signatures and missing signatures as successful | ✅ Fixed | v0.0.12 | Strict mandatory algorithm policy, fail-closed on malformed input, structured result fields, 11 adversarial tests |
|
||||
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14–v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
|
||||
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
|
||||
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
|
||||
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ❌ Not started | — | Needs signer-validation helper |
|
||||
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ✅ Fixed | v0.0.21 | Tier 1+2: rewrote `fetchBitcoinBlockHeader()` to query ALL providers and cross-check merkle roots (fail on disagreement); added `trustMode` field to `verifyOtsProof()` result (`multi-explorer-checked` / `single-explorer-checked` / `structural-only`); updated verify.html + index.html UI to display trust mode label; removed misleading "lite-client verification" / "independently verifiable" comments. Full light-client PoW verification (Tier 3) remains as future work. 2 test assertions added (trustMode present on verified proof, structural-only on pending) |
|
||||
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
|
||||
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
|
||||
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
|
||||
| G56-08 | High | Browser origin security insufficient for seed handling | ❌ Not started | — | Needs CSP hardening, header fixes, asset pinning |
|
||||
@@ -100,6 +100,29 @@
|
||||
- verify.html relay path already had try/catch (G56-01); now defense-in-depth with internal fail-closed
|
||||
- 23 new fuzz/property tests: random JSON shapes to `verifyNIPQRContent` (1000 iterations), random bytes to `parseOtsFile` (2000 iterations), random objects to `verifyNostrEvent` (500 iterations), truncated valid-prefix bytes, oversized inputs, wrong-typed objects, `isOtsConfirmed` fuzz
|
||||
|
||||
### v0.0.20 — G56-05 fix (signer output validation)
|
||||
- Added `validateSignerOutput(signedEvent, template, expectedPubkey)` helper in `pq-crypto.mjs`:
|
||||
- Checks all 7 required NIP-01 fields present + correctly typed
|
||||
- Verifies id (64 hex), pubkey (64 hex), sig (128 hex) format
|
||||
- Verifies no template field changed (content, tags deep-compare, created_at, kind)
|
||||
- Verifies returned pubkey equals expected identity
|
||||
- Recomputes event ID and verifies match
|
||||
- Verifies Schnorr signature
|
||||
- Rejects extra unexpected fields
|
||||
- Returns the validated signed object (not a reconstruction)
|
||||
- Replaced reconstruction at all 4 `signEvent()` call sites:
|
||||
- `index.html`: kind 1 announcement (was mixing template + signer fields), proof carrier (same), upgrade (now validated)
|
||||
- `verify.html`: upgrade (now validated)
|
||||
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
|
||||
|
||||
### v0.0.21 — G56-04 fix (Bitcoin trust-mode labeling + multi-explorer cross-check)
|
||||
- Rewrote `fetchBitcoinBlockHeader()` to query ALL providers (blockstream + mempool) and cross-check their merkle roots; fails on disagreement (turns fallback-redundancy into a cross-check)
|
||||
- Added `trustMode` field to `verifyOtsProof()` return value: `multi-explorer-checked` (both providers agreed), `single-explorer-checked` (only one responded), `structural-only` (no Bitcoin attestation verified)
|
||||
- Updated verify.html (2 locations) and index.html (1 location) to display the trust mode label alongside verification results
|
||||
- Removed misleading "lite-client verification" / "independently verifiable against the Bitcoin PoW chain" comments from code
|
||||
- Full light-client PoW/difficulty/chain-linkage verification (Tier 3) remains as future work
|
||||
- 2 new test assertions: trustMode present + explorer-checked on verified proof, structural-only on pending proof
|
||||
|
||||
## Test count
|
||||
|
||||
- **Before remediation:** 38 tests
|
||||
@@ -108,4 +131,6 @@
|
||||
- **After G56-09:** 65 tests
|
||||
- **After G56-16:** 65 tests (docs-only)
|
||||
- **After G56-07:** 88 tests
|
||||
- **After G56-05:** 103 tests
|
||||
- **After G56-04:** 103 tests (2 assertions added to existing tests)
|
||||
- **All passing:** ✅
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "post_quantum_nostr",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.21",
|
||||
"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": {
|
||||
|
||||
@@ -311,6 +311,10 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
|
||||
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
|
||||
assert.equal(result.bitcoinAttestations.length, 1);
|
||||
assert.equal(result.bitcoinAttestations[0].height, 358391);
|
||||
// G56-04: trustMode must be present and indicate explorer-checked trust
|
||||
assert.ok(result.trustMode, 'trustMode field must be present');
|
||||
assert.ok(['multi-explorer-checked', 'single-explorer-checked'].includes(result.trustMode),
|
||||
`trustMode should be explorer-checked, got: ${result.trustMode}`);
|
||||
});
|
||||
|
||||
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
|
||||
@@ -334,6 +338,8 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
|
||||
const result = await m.verifyOtsProof(ots);
|
||||
assert.equal(result.verified, false);
|
||||
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
|
||||
// G56-04: trustMode should be 'structural-only' when no Bitcoin attestation is verified
|
||||
assert.equal(result.trustMode, 'structural-only', 'pending proof should have structural-only trust mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -853,3 +859,139 @@ describe('G56-07: isOtsConfirmed never throws on bad input', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// G56-05: Signer output validation
|
||||
// ============================================================================
|
||||
|
||||
describe('G56-05: validateSignerOutput', () => {
|
||||
const sk = new Uint8Array(32);
|
||||
sk[31] = 5;
|
||||
const pkHex = m.bytesToHex(schnorr.getPublicKey(sk));
|
||||
const otherSk = new Uint8Array(32);
|
||||
otherSk[31] = 9;
|
||||
const otherPkHex = m.bytesToHex(schnorr.getPublicKey(otherSk));
|
||||
|
||||
// Helper: create a valid template + correctly signed event
|
||||
function makeValidSignedEvent(content = 'test content', tags = [['t', 'x']]) {
|
||||
const template = {
|
||||
pubkey: pkHex,
|
||||
created_at: 1700000000,
|
||||
kind: 1,
|
||||
tags,
|
||||
content
|
||||
};
|
||||
const event = { ...template };
|
||||
event.id = m.computeEventId(event);
|
||||
event.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(event.id), sk));
|
||||
return { template, event };
|
||||
}
|
||||
|
||||
test('valid signer output passes validation', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const result = m.validateSignerOutput(event, template, pkHex);
|
||||
assert.equal(result.id, event.id);
|
||||
assert.equal(result.sig, event.sig);
|
||||
});
|
||||
|
||||
test('signer changes content → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, content: 'tampered content' };
|
||||
// Recompute id+sig so the tampered event is internally consistent
|
||||
// (a real malicious signer would do this, but the content must still match the template)
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /content was changed/);
|
||||
});
|
||||
|
||||
test('signer changes tags → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, tags: [['t', 'x'], ['extra', 'tag']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tags length changed/);
|
||||
});
|
||||
|
||||
test('signer changes created_at → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, created_at: 9999999999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /created_at changed/);
|
||||
});
|
||||
|
||||
test('signer changes kind → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, kind: 9999 };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /kind changed/);
|
||||
});
|
||||
|
||||
test('signer returns wrong pubkey → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Sign with a different key, use that key's pubkey
|
||||
const tampered = { ...template, pubkey: otherPkHex };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), otherSk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /pubkey mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns wrong ID → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: '0'.repeat(64) };
|
||||
// sig is valid for the real id, not for '0'.repeat(64)
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id mismatch/);
|
||||
});
|
||||
|
||||
test('signer returns invalid signature → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'f'.repeat(128) };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /invalid Schnorr signature/);
|
||||
});
|
||||
|
||||
test('signer returns extra field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, extra: 'malicious' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /unexpected extra field/);
|
||||
});
|
||||
|
||||
test('signer returns missing field → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const { sig, ...noSig } = event;
|
||||
assert.throws(() => m.validateSignerOutput(noSig, template, pkHex), /missing field 'sig'/);
|
||||
});
|
||||
|
||||
test('signer returns non-object → rejected', () => {
|
||||
const { template } = makeValidSignedEvent();
|
||||
assert.throws(() => m.validateSignerOutput(null, template, pkHex), /non-object/);
|
||||
assert.throws(() => m.validateSignerOutput('string', template, pkHex), /non-object/);
|
||||
});
|
||||
|
||||
test('signer returns malformed id (not 64 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, id: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /id must be 64/);
|
||||
});
|
||||
|
||||
test('signer returns malformed sig (not 128 hex) → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
const tampered = { ...event, sig: 'short' };
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /sig must be 128/);
|
||||
});
|
||||
|
||||
test('no expectedPubkey → pubkey check skipped', () => {
|
||||
const { template, event } = makeValidSignedEvent();
|
||||
// Should pass without expectedPubkey (backward compatible)
|
||||
const result = m.validateSignerOutput(event, template);
|
||||
assert.equal(result.id, event.id);
|
||||
});
|
||||
|
||||
test('tag element value changed → rejected', () => {
|
||||
const { template, event } = makeValidSignedEvent('test', [['t', 'original']]);
|
||||
const tampered = { ...event, tags: [['t', 'changed']] };
|
||||
tampered.id = m.computeEventId(tampered);
|
||||
tampered.sig = m.bytesToHex(schnorr.sign(m.hexToBytes(tampered.id), sk));
|
||||
assert.throws(() => m.validateSignerOutput(tampered, template, pkHex), /tag 0 element 1 changed/);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-25
@@ -623,6 +623,7 @@
|
||||
hashFullEvent,
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
validateSignerOutput,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
bytesToHex,
|
||||
@@ -1117,16 +1118,8 @@
|
||||
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
|
||||
|
||||
const kind1Signed = await window.nostr.signEvent(kind1Template);
|
||||
// Ensure the signed event has all fields
|
||||
kind1Event = {
|
||||
id: kind1Signed.id,
|
||||
pubkey: kind1Signed.pubkey,
|
||||
created_at: kind1Template.created_at,
|
||||
kind: 1,
|
||||
content: kind1Template.content,
|
||||
tags: kind1Template.tags,
|
||||
sig: kind1Signed.sig
|
||||
};
|
||||
// G56-05: validate signer output — never reconstruct from mixed fields
|
||||
kind1Event = validateSignerOutput(kind1Signed, kind1Template, currentPubkey);
|
||||
otsLog(`Kind 1 announcement signed. Event ID: ${kind1Event.id.substring(0, 32)}...`);
|
||||
|
||||
// ---- Phase 2: Timestamp the full kind 1 event hash ----
|
||||
@@ -1157,16 +1150,8 @@
|
||||
}
|
||||
|
||||
const proofCarrierSigned = await window.nostr.signEvent(proofCarrierTemplate);
|
||||
|
||||
pqEvent = {
|
||||
id: proofCarrierSigned.id,
|
||||
pubkey: proofCarrierSigned.pubkey,
|
||||
created_at: proofCarrierTemplate.created_at,
|
||||
kind: NIP_QR_KIND,
|
||||
content: proofCarrierTemplate.content,
|
||||
tags: proofCarrierTemplate.tags,
|
||||
sig: proofCarrierSigned.sig
|
||||
};
|
||||
// G56-05: validate signer output — never reconstruct from mixed fields
|
||||
pqEvent = validateSignerOutput(proofCarrierSigned, proofCarrierTemplate, currentPubkey);
|
||||
otsLog(`Proof carrier signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
|
||||
|
||||
// Save the pending OTS proof for the OTS step
|
||||
@@ -1718,9 +1703,14 @@
|
||||
if (verifyResult.verified) {
|
||||
const att = verifyResult.bitcoinAttestations[0];
|
||||
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
|
||||
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC). Proof: ${pendingOtsBytes.length} bytes.`);
|
||||
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
|
||||
? 'multi-explorer-checked'
|
||||
: verifyResult.trustMode === 'single-explorer-checked'
|
||||
? 'single-explorer-checked'
|
||||
: verifyResult.trustMode || 'unknown';
|
||||
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
|
||||
setKeyIcon('pqOtsConfirm', 'Done');
|
||||
confirmDetail.textContent = `Verified on Bitcoin blockchain (block ${att ? att.height : '?'})!`;
|
||||
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
|
||||
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
|
||||
|
||||
const saved = loadPendingOts();
|
||||
@@ -1771,8 +1761,10 @@
|
||||
if (!window.nostr || !window.nostr.signEvent) throw new Error('Nostr signer not available');
|
||||
otsLog('Requesting secp256k1 signature for upgraded proof carrier...');
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
// G56-05: validate signer output before publishing
|
||||
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentPubkey);
|
||||
const relayUrls = configuredRelays.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const results = await publishToRelays(signedEvent, relayUrls);
|
||||
const results = await publishToRelays(validatedEvent, relayUrls);
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
if (successCount === 0) throw new Error('No relay accepted the upgraded proof carrier');
|
||||
@@ -1780,7 +1772,7 @@
|
||||
setKeyIcon('pqOtsConfirmedPublish', 'Done');
|
||||
otsLog(`Upgraded proof carrier published. Successful: ${successCount}; failed: ${failCount}.`);
|
||||
// Update pqEvent to the new event so future operations use it
|
||||
pqEvent = signedEvent;
|
||||
pqEvent = validatedEvent;
|
||||
savePendingOts(pqEvent.id, upgradedOtsBytes, {
|
||||
ownerPubkey: currentPubkey,
|
||||
targetKind: NIP_QR_KIND,
|
||||
@@ -1792,7 +1784,7 @@
|
||||
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
|
||||
setStepDone(6);
|
||||
setStepActive(7);
|
||||
return signedEvent;
|
||||
return validatedEvent;
|
||||
} catch (error) {
|
||||
otsLog(`ERROR publishing upgraded event: ${error.message}`);
|
||||
setStatus(otsStatus, 'error', `Failed to publish upgraded event: ${error.message}`);
|
||||
|
||||
+197
-21
@@ -454,6 +454,122 @@ export function verifyNostrEvent(event) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the output of a NIP-07 signer's signEvent() call (G56-05).
|
||||
*
|
||||
* This ensures a malicious or buggy signer cannot substitute different content,
|
||||
* tags, pubkey, or an invalid signature. It verifies that the signed event
|
||||
* matches the template exactly (for the semantic fields), that the pubkey
|
||||
* equals the expected identity, that the computed ID matches, and that the
|
||||
* Schnorr signature is valid.
|
||||
*
|
||||
* Returns the validated signed event object (the signer's exact object, not a
|
||||
* reconstruction) on success, or throws on any mismatch.
|
||||
*
|
||||
* @param {object} signedEvent - The object returned by window.nostr.signEvent()
|
||||
* @param {object} template - The unsigned template that was passed to signEvent()
|
||||
* @param {string} expectedPubkey - The hex pubkey of the authenticated identity
|
||||
* @returns {object} the validated signed event
|
||||
* @throws {Error} if any field is missing, mismatched, or the signature is invalid
|
||||
*/
|
||||
export function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== 'object') {
|
||||
throw new Error('validateSignerOutput: signer returned non-object');
|
||||
}
|
||||
|
||||
// 1. Check all required fields are present and correctly typed
|
||||
const required = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error('validateSignerOutput: id must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error('validateSignerOutput: pubkey must be 64 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.sig !== 'string' || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error('validateSignerOutput: sig must be 128 lowercase hex chars');
|
||||
}
|
||||
if (typeof signedEvent.created_at !== 'number') {
|
||||
throw new Error('validateSignerOutput: created_at must be a number');
|
||||
}
|
||||
if (typeof signedEvent.kind !== 'number') {
|
||||
throw new Error('validateSignerOutput: kind must be a number');
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error('validateSignerOutput: tags must be an array');
|
||||
}
|
||||
if (typeof signedEvent.content !== 'string') {
|
||||
throw new Error('validateSignerOutput: content must be a string');
|
||||
}
|
||||
|
||||
// 2. Verify no template field was changed by the signer
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error('validateSignerOutput: content was changed by signer');
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
// Deep-compare tags (order-sensitive, as tag order affects the event ID)
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Verify the pubkey matches the expected identity
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 4. Recompute the event ID and verify it matches
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
|
||||
// 5. Verify the Schnorr signature
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes(signedEvent.sig);
|
||||
const pubkey = hexToBytes(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error('validateSignerOutput: invalid Schnorr signature');
|
||||
}
|
||||
|
||||
// 6. Reject extra unexpected fields (allow only the 7 NIP-01 fields)
|
||||
const allowedFields = new Set(['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig']);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NIP-QR EVENT CONSTRUCTION
|
||||
// ============================================================================
|
||||
@@ -1720,8 +1836,13 @@ function _classifyAttestation(tag, payload, digest, attestations) {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Bitcoin API endpoints for fetching block headers (for lite-client verification).
|
||||
* We try multiple providers for resilience.
|
||||
* Bitcoin block explorer API providers.
|
||||
*
|
||||
* G56-04: These are trusted public APIs, NOT a Bitcoin light-client. The block
|
||||
* headers they return are NOT independently verified against PoW, difficulty, or
|
||||
* chain linkage. We cross-check both providers and fail on disagreement to make
|
||||
* coordinated false responses harder, but this is still "multi-explorer-checked"
|
||||
* trust, not "header-chain-verified" or trustless.
|
||||
*/
|
||||
const BITCOIN_API_PROVIDERS = [
|
||||
{ name: 'blockstream', blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
|
||||
@@ -1729,15 +1850,23 @@ const BITCOIN_API_PROVIDERS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetch a Bitcoin block header (merkle root + timestamp) from a public API.
|
||||
* Uses lite-client verification: we trust the API for the block header, which
|
||||
* is the standard OTS lite-client model (the block header is independently
|
||||
* verifiable against the Bitcoin PoW chain).
|
||||
* Fetch a Bitcoin block header (merkle root + timestamp) from public explorer APIs.
|
||||
*
|
||||
* G56-04: This is NOT lite-client verification. We trust the APIs for the block
|
||||
* header data. To make coordinated false responses harder, we query ALL providers
|
||||
* and require them to agree on the merkle root. If they disagree, we fail.
|
||||
*
|
||||
* Trust mode: 'multi-explorer-checked' if all queried providers agree.
|
||||
* This is stronger than single-provider trust but is NOT trustless — a
|
||||
* compromise of both providers could still falsify a result.
|
||||
*
|
||||
* @param {number} height - Bitcoin block height
|
||||
* @returns {Promise<{merkleroot: string, time: number, height: number}>}
|
||||
* @returns {Promise<{merkleroot: string, time: number, height: number, trustMode: string, providers: string[]}>}
|
||||
* @throws {Error} if providers disagree or all fail
|
||||
*/
|
||||
async function fetchBitcoinBlockHeader(height) {
|
||||
const results = [];
|
||||
|
||||
for (const provider of BITCOIN_API_PROVIDERS) {
|
||||
try {
|
||||
// 1. Get block hash from height
|
||||
@@ -1753,37 +1882,74 @@ async function fetchBitcoinBlockHeader(height) {
|
||||
if (!blockData.merkle_root && !blockData.merkleroot) continue;
|
||||
if (!blockData.timestamp && !blockData.time) continue;
|
||||
|
||||
return {
|
||||
merkleroot: blockData.merkle_root || blockData.merkleroot,
|
||||
results.push({
|
||||
provider: provider.name,
|
||||
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
|
||||
time: blockData.timestamp || blockData.time,
|
||||
height
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
}
|
||||
|
||||
// G56-04: Cross-check — if multiple providers responded, they must agree
|
||||
if (results.length > 1) {
|
||||
const firstRoot = results[0].merkleroot;
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
if (results[i].merkleroot !== firstRoot) {
|
||||
const providerList = results.map(r => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(', ');
|
||||
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine trust mode based on how many providers agreed
|
||||
const trustMode = results.length >= 2
|
||||
? 'multi-explorer-checked'
|
||||
: 'single-explorer-checked';
|
||||
|
||||
return {
|
||||
merkleroot: results[0].merkleroot,
|
||||
time: results[0].time,
|
||||
height,
|
||||
trustMode,
|
||||
providers: results.map(r => r.provider)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographically verify an OTS proof against an expected target digest.
|
||||
* Verify an OTS proof against an expected target digest.
|
||||
*
|
||||
* This performs REAL verification:
|
||||
* G56-04: This performs Merkle-path verification against block headers obtained
|
||||
* from public explorer APIs. The block headers are NOT independently verified
|
||||
* against Bitcoin PoW/difficulty/chain-linkage — they are trusted from the APIs.
|
||||
* The `trustMode` field in the result indicates the trust level:
|
||||
* - 'multi-explorer-checked': multiple providers agreed on the merkle root
|
||||
* - 'single-explorer-checked': only one provider responded
|
||||
* - 'structural-only': no Bitcoin attestation was verified (pending/none)
|
||||
*
|
||||
* Steps:
|
||||
* 1. Parses the OTS file (op stream, not byte-pattern search)
|
||||
* 2. Checks the proof's target digest equals the expected digest (F-C3 fix)
|
||||
* 3. Walks the op tree to compute the commitment at each attestation
|
||||
* 4. For Bitcoin attestations: fetches the block header and checks the
|
||||
* computed merkle root matches the block's merkle root
|
||||
* 4. For Bitcoin attestations: fetches the block header (cross-checked across
|
||||
* providers) and checks the computed merkle root matches
|
||||
*
|
||||
* @param {Uint8Array} otsBytes - The .ots file bytes
|
||||
* @param {string} [expectedDigestHex] - The expected target digest (hex). If
|
||||
* provided, the proof's target digest must match this exactly (F-C3 binding).
|
||||
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, errors: Array}>}
|
||||
* - verified: true if at least one Bitcoin attestation is cryptographically valid
|
||||
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, trustMode: string, errors: Array}>}
|
||||
* - verified: true if at least one Bitcoin attestation's merkle root matches
|
||||
* - targetDigest: hex of the proof's target digest
|
||||
* - attestations: all attestations found (parsed)
|
||||
* - bitcoinAttestations: verified Bitcoin attestations with block time + height
|
||||
* - trustMode: 'multi-explorer-checked' | 'single-explorer-checked' | 'structural-only'
|
||||
* - errors: list of error messages (e.g., digest mismatch, pending only)
|
||||
*/
|
||||
export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
@@ -1794,10 +1960,10 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
try {
|
||||
parsed = parseOtsFile(otsBytes);
|
||||
} catch (e) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
}
|
||||
if (!parsed) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ['Invalid OTS file format'] };
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: ['Invalid OTS file format'] };
|
||||
}
|
||||
|
||||
const targetDigestHex = bytesToHex(parsed.targetDigest);
|
||||
@@ -1808,7 +1974,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
const actual = targetDigestHex.toLowerCase();
|
||||
if (expected !== actual) {
|
||||
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1822,14 +1988,23 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
} else {
|
||||
errors.push('Proof contains no Bitcoin attestations');
|
||||
}
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
|
||||
}
|
||||
|
||||
// 4. Verify each Bitcoin attestation against the actual Bitcoin block header
|
||||
// G56-04: block headers are cross-checked across providers; trustMode is
|
||||
// propagated from fetchBitcoinBlockHeader.
|
||||
const verified = [];
|
||||
let trustMode = 'structural-only';
|
||||
for (const att of bitcoinAttestations) {
|
||||
try {
|
||||
const blockHeader = await fetchBitcoinBlockHeader(att.height);
|
||||
// Track the strongest trust mode seen across all attestations
|
||||
if (blockHeader.trustMode === 'multi-explorer-checked') {
|
||||
trustMode = 'multi-explorer-checked';
|
||||
} else if (blockHeader.trustMode === 'single-explorer-checked' && trustMode === 'structural-only') {
|
||||
trustMode = 'single-explorer-checked';
|
||||
}
|
||||
// The attestation's digest is the merkle root (after walking the op tree).
|
||||
// Bitcoin OTS stores the digest in reversed byte order (little-endian).
|
||||
const computedMerkleRoot = bytesToHex(att.digest.slice().reverse());
|
||||
@@ -1852,6 +2027,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
trustMode,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.0.19",
|
||||
"VERSION_NUMBER": "0.0.19",
|
||||
"BUILD_DATE": "2026-07-24T13:25:51.884Z"
|
||||
"VERSION": "v0.0.21",
|
||||
"VERSION_NUMBER": "0.0.21",
|
||||
"BUILD_DATE": "2026-07-24T14:15:01.028Z"
|
||||
}
|
||||
|
||||
+120
-8
@@ -9788,6 +9788,89 @@ function verifyNostrEvent(event) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validateSignerOutput(signedEvent, template, expectedPubkey) {
|
||||
if (!signedEvent || typeof signedEvent !== "object") {
|
||||
throw new Error("validateSignerOutput: signer returned non-object");
|
||||
}
|
||||
const required = ["id", "pubkey", "created_at", "kind", "tags", "content", "sig"];
|
||||
for (const field of required) {
|
||||
if (!(field in signedEvent)) {
|
||||
throw new Error(`validateSignerOutput: missing field '${field}'`);
|
||||
}
|
||||
}
|
||||
if (typeof signedEvent.id !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.id)) {
|
||||
throw new Error("validateSignerOutput: id must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.pubkey !== "string" || !/^[0-9a-f]{64}$/.test(signedEvent.pubkey)) {
|
||||
throw new Error("validateSignerOutput: pubkey must be 64 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.sig !== "string" || !/^[0-9a-f]{128}$/.test(signedEvent.sig)) {
|
||||
throw new Error("validateSignerOutput: sig must be 128 lowercase hex chars");
|
||||
}
|
||||
if (typeof signedEvent.created_at !== "number") {
|
||||
throw new Error("validateSignerOutput: created_at must be a number");
|
||||
}
|
||||
if (typeof signedEvent.kind !== "number") {
|
||||
throw new Error("validateSignerOutput: kind must be a number");
|
||||
}
|
||||
if (!Array.isArray(signedEvent.tags)) {
|
||||
throw new Error("validateSignerOutput: tags must be an array");
|
||||
}
|
||||
if (typeof signedEvent.content !== "string") {
|
||||
throw new Error("validateSignerOutput: content must be a string");
|
||||
}
|
||||
if (signedEvent.kind !== template.kind) {
|
||||
throw new Error(`validateSignerOutput: kind changed (template=${template.kind}, signed=${signedEvent.kind})`);
|
||||
}
|
||||
if (signedEvent.content !== template.content) {
|
||||
throw new Error("validateSignerOutput: content was changed by signer");
|
||||
}
|
||||
if (signedEvent.created_at !== template.created_at) {
|
||||
throw new Error(`validateSignerOutput: created_at changed (template=${template.created_at}, signed=${signedEvent.created_at})`);
|
||||
}
|
||||
if (signedEvent.tags.length !== template.tags.length) {
|
||||
throw new Error(`validateSignerOutput: tags length changed (template=${template.tags.length}, signed=${signedEvent.tags.length})`);
|
||||
}
|
||||
for (let i = 0; i < template.tags.length; i++) {
|
||||
const tTag = template.tags[i];
|
||||
const sTag = signedEvent.tags[i];
|
||||
if (!Array.isArray(sTag) || sTag.length !== tTag.length) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} shape changed`);
|
||||
}
|
||||
for (let j = 0; j < tTag.length; j++) {
|
||||
if (sTag[j] !== tTag[j]) {
|
||||
throw new Error(`validateSignerOutput: tag ${i} element ${j} changed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expectedPubkey && signedEvent.pubkey !== expectedPubkey) {
|
||||
throw new Error(`validateSignerOutput: pubkey mismatch (expected=${expectedPubkey?.substring(0, 16)}..., got=${signedEvent.pubkey.substring(0, 16)}...)`);
|
||||
}
|
||||
const computedId = computeEventId(signedEvent);
|
||||
if (signedEvent.id !== computedId) {
|
||||
throw new Error(`validateSignerOutput: id mismatch (signed=${signedEvent.id.substring(0, 16)}..., computed=${computedId.substring(0, 16)}...)`);
|
||||
}
|
||||
const hash = sha256(new TextEncoder().encode(JSON.stringify([
|
||||
0,
|
||||
signedEvent.pubkey,
|
||||
signedEvent.created_at,
|
||||
signedEvent.kind,
|
||||
signedEvent.tags,
|
||||
signedEvent.content
|
||||
])));
|
||||
const sig = hexToBytes3(signedEvent.sig);
|
||||
const pubkey = hexToBytes3(signedEvent.pubkey);
|
||||
if (!schnorr.verify(sig, hash, pubkey)) {
|
||||
throw new Error("validateSignerOutput: invalid Schnorr signature");
|
||||
}
|
||||
const allowedFields = /* @__PURE__ */ new Set(["id", "pubkey", "created_at", "kind", "tags", "content", "sig"]);
|
||||
for (const key of Object.keys(signedEvent)) {
|
||||
if (!allowedFields.has(key)) {
|
||||
throw new Error(`validateSignerOutput: unexpected extra field '${key}'`);
|
||||
}
|
||||
}
|
||||
return signedEvent;
|
||||
}
|
||||
var NIP_QR_KIND = 9999;
|
||||
function buildKind1Announcement(hexPubkey, blockHeight, pqKeys) {
|
||||
const npub = hexToNpub(hexPubkey);
|
||||
@@ -10573,6 +10656,7 @@ var BITCOIN_API_PROVIDERS = [
|
||||
{ name: "mempool", blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` }
|
||||
];
|
||||
async function fetchBitcoinBlockHeader(height) {
|
||||
const results = [];
|
||||
for (const provider of BITCOIN_API_PROVIDERS) {
|
||||
try {
|
||||
const hashResp = await fetch(provider.blockHash(height), { headers: { "Accept": "text/plain" } });
|
||||
@@ -10584,17 +10668,37 @@ async function fetchBitcoinBlockHeader(height) {
|
||||
const blockData = await blockResp.json();
|
||||
if (!blockData.merkle_root && !blockData.merkleroot) continue;
|
||||
if (!blockData.timestamp && !blockData.time) continue;
|
||||
return {
|
||||
merkleroot: blockData.merkle_root || blockData.merkleroot,
|
||||
results.push({
|
||||
provider: provider.name,
|
||||
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
|
||||
time: blockData.timestamp || blockData.time,
|
||||
height
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
if (results.length === 0) {
|
||||
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
|
||||
}
|
||||
if (results.length > 1) {
|
||||
const firstRoot = results[0].merkleroot;
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
if (results[i].merkleroot !== firstRoot) {
|
||||
const providerList = results.map((r) => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(", ");
|
||||
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const trustMode = results.length >= 2 ? "multi-explorer-checked" : "single-explorer-checked";
|
||||
return {
|
||||
merkleroot: results[0].merkleroot,
|
||||
time: results[0].time,
|
||||
height,
|
||||
trustMode,
|
||||
providers: results.map((r) => r.provider)
|
||||
};
|
||||
}
|
||||
async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
const errors = [];
|
||||
@@ -10602,10 +10706,10 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
try {
|
||||
parsed = parseOtsFile(otsBytes);
|
||||
} catch (e) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: [`Failed to parse OTS file: ${e.message}`] };
|
||||
}
|
||||
if (!parsed) {
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ["Invalid OTS file format"] };
|
||||
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: ["Invalid OTS file format"] };
|
||||
}
|
||||
const targetDigestHex = bytesToHex3(parsed.targetDigest);
|
||||
if (expectedDigestHex) {
|
||||
@@ -10613,7 +10717,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
const actual = targetDigestHex.toLowerCase();
|
||||
if (expected !== actual) {
|
||||
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
|
||||
}
|
||||
}
|
||||
const bitcoinAttestations = parsed.attestations.filter((a) => a.type === "bitcoin");
|
||||
@@ -10624,12 +10728,18 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
} else {
|
||||
errors.push("Proof contains no Bitcoin attestations");
|
||||
}
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
|
||||
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
|
||||
}
|
||||
const verified = [];
|
||||
let trustMode = "structural-only";
|
||||
for (const att of bitcoinAttestations) {
|
||||
try {
|
||||
const blockHeader = await fetchBitcoinBlockHeader(att.height);
|
||||
if (blockHeader.trustMode === "multi-explorer-checked") {
|
||||
trustMode = "multi-explorer-checked";
|
||||
} else if (blockHeader.trustMode === "single-explorer-checked" && trustMode === "structural-only") {
|
||||
trustMode = "single-explorer-checked";
|
||||
}
|
||||
const computedMerkleRoot = bytesToHex3(att.digest.slice().reverse());
|
||||
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
|
||||
verified.push({
|
||||
@@ -10649,6 +10759,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
|
||||
targetDigest: targetDigestHex,
|
||||
attestations: parsed.attestations,
|
||||
bitcoinAttestations: verified,
|
||||
trustMode,
|
||||
errors
|
||||
};
|
||||
}
|
||||
@@ -10722,6 +10833,7 @@ export {
|
||||
signWithSLHDSA,
|
||||
timestampEvent,
|
||||
upgradeOts,
|
||||
validateSignerOutput,
|
||||
verifyFalcon,
|
||||
verifyMLDSA44,
|
||||
verifyMLDSA65,
|
||||
|
||||
+20
-7
@@ -375,6 +375,7 @@
|
||||
import {
|
||||
verifyNIPQRContent,
|
||||
verifyNostrEvent,
|
||||
validateSignerOutput,
|
||||
NIP_QR_KIND,
|
||||
buildUpgradedEvent,
|
||||
buildProofCarrier,
|
||||
@@ -639,8 +640,13 @@
|
||||
if (result.verified && result.bitcoinAttestations.length > 0) {
|
||||
const att = result.bitcoinAttestations[0];
|
||||
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
|
||||
const trustLabel = result.trustMode === 'multi-explorer-checked'
|
||||
? 'multi-explorer-checked (cross-verified)'
|
||||
: result.trustMode === 'single-explorer-checked'
|
||||
? 'single-explorer-checked (trusted API)'
|
||||
: result.trustMode || 'unknown';
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
|
||||
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
} else {
|
||||
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
|
||||
@@ -852,11 +858,16 @@
|
||||
if (confirmed) {
|
||||
const att = verifyResult.bitcoinAttestations[0];
|
||||
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
|
||||
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
|
||||
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
|
||||
? 'multi-explorer-checked (cross-verified)'
|
||||
: verifyResult.trustMode === 'single-explorer-checked'
|
||||
? 'single-explorer-checked (trusted API)'
|
||||
: verifyResult.trustMode || 'unknown';
|
||||
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
|
||||
otsUpgradeBtn.style.display = 'none';
|
||||
otsRepublishBtn.style.display = 'inline-block';
|
||||
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
|
||||
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att ? att.height : '?'}. Proof size: ${currentOtsBytes.length} bytes.`);
|
||||
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`);
|
||||
} else {
|
||||
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
|
||||
}
|
||||
@@ -879,6 +890,8 @@
|
||||
|
||||
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
|
||||
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
||||
// G56-05: validate signer output before publishing
|
||||
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentEvent.pubkey);
|
||||
|
||||
// Publish to the relays from the relay input field
|
||||
const relayInput = document.getElementById('pqRelayInput').value.trim();
|
||||
@@ -887,7 +900,7 @@
|
||||
throw new Error('No relay URLs specified in the relay input field.');
|
||||
}
|
||||
|
||||
const eventJson = JSON.stringify(['EVENT', signedEvent]);
|
||||
const eventJson = JSON.stringify(['EVENT', validatedEvent]);
|
||||
const publishResults = await Promise.all(relayUrls.map(url => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
@@ -898,7 +911,7 @@
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (data[0] === 'OK' && data[1] === signedEvent.id) {
|
||||
if (data[0] === 'OK' && data[1] === validatedEvent.id) {
|
||||
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -916,10 +929,10 @@
|
||||
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
|
||||
}
|
||||
|
||||
currentEvent = signedEvent;
|
||||
currentEvent = validatedEvent;
|
||||
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
|
||||
// Re-verify the new event
|
||||
await verifyEvent(signedEvent);
|
||||
await verifyEvent(validatedEvent);
|
||||
} catch (error) {
|
||||
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user