Compare commits

...
2 Commits
8 changed files with 529 additions and 69 deletions
+29 -4
View File
@@ -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.14v0.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
View File
@@ -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": {
+142
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {