Files

992 lines
44 KiB
JavaScript

import {
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
NIP_QR_KIND,
buildUpgradedEvent,
buildProofCarrier,
selectCanonicalProofCarrier,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile,
parseProofArchive
} from '../pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const tabSignIn = document.getElementById('pqTabSignIn');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
const panelSignIn = document.getElementById('pqPanelSignIn');
function switchTab(which) {
const isRelay = which === 'relay';
const isPaste = which === 'paste';
const isSignIn = which === 'signin';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', isPaste);
tabSignIn.classList.toggle('pq-tab-active', isSignIn);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', isPaste);
panelSignIn.classList.toggle('pq-tab-panel-active', isSignIn);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
tabSignIn.addEventListener('click', () => switchTab('signin'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const eventJsonEl = document.getElementById('pqEventJson');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
const summaryWrap = document.getElementById('pqSummaryWrap');
const summaryCard = document.getElementById('pqSummaryCard');
// OTS cards are created dynamically and appended into the shared result
// list so they flow as part of the same card list as the signature checks.
let otsStampCard = null;
let otsUpgradedCard = null;
let currentEvent = null;
let currentOtsBytes = null;
let currentExpectedAuthor = null;
/* Braille spinner: shown inside a button while its action is running
(e.g. Query & Verify). Advances through the standard braille spinner
frames every 80ms, matching the spinners on the preparation page. */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const buttonSpinnerIntervals = new Map();
function setButtonSpinner(btn) {
if (!btn) return;
clearButtonSpinner(btn);
let span = btn.querySelector('.pq-btn-spinner');
if (!span) {
span = document.createElement('span');
span.className = 'pq-btn-spinner';
btn.insertBefore(span, btn.firstChild);
}
let frame = 0;
span.textContent = SPINNER_FRAMES[0];
buttonSpinnerIntervals.set(btn, setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
span.textContent = SPINNER_FRAMES[frame];
}, 80));
}
function clearButtonSpinner(btn) {
if (!btn) return;
if (buttonSpinnerIntervals.has(btn)) {
clearInterval(buttonSpinnerIntervals.get(btn));
buttonSpinnerIntervals.delete(btn);
}
const span = btn.querySelector('.pq-btn-spinner');
if (span) span.remove();
}
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'} — ${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// OTS state tracked for the summary card.
let otsStampState = { valid: null, label: '', detail: '' };
let otsUpgradedState = { valid: null, label: '', detail: '' };
// Render an OTS card (stamp or upgraded). The card is created on demand
// and appended to the shared result list so it flows with the signature
// checks. `which` is 'stamp' or 'upgraded'; `state` is one of
// 'valid' | 'invalid' | 'pending'.
function setOtsCard(which, state, head, body) {
const card = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
if (!card) {
// Create the card and append it to the result list.
const el = document.createElement('div');
el.className = 'pq-result-item pq-result-detail';
resultList.appendChild(el);
if (which === 'upgraded') otsUpgradedCard = el; else otsStampCard = el;
}
const target = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
target.style.display = 'block';
target.classList.remove('pq-result-valid', 'pq-result-invalid', 'pq-result-pending');
if (state === 'valid') target.classList.add('pq-result-valid');
else if (state === 'invalid') target.classList.add('pq-result-invalid');
else if (state === 'pending') target.classList.add('pq-result-pending');
// F-H3: build DOM safely — head and body use textContent
target.innerHTML = '';
const headEl = document.createElement('div');
headEl.className = 'pq-result-head';
headEl.textContent = head;
const bodyEl = document.createElement('div');
bodyEl.className = 'pq-result-body';
bodyEl.textContent = body;
target.appendChild(headEl);
target.appendChild(bodyEl);
}
function hideOtsCard(which) {
const card = which === 'upgraded' ? otsUpgradedCard : otsStampCard;
if (card) { card.remove(); }
if (which === 'upgraded') otsUpgradedCard = null; else otsStampCard = null;
}
// Render the final summary card based on signature validity and OTS state.
function renderSummary(allValid) {
const stampOk = otsStampState.valid === true;
const upgradedOk = otsUpgradedState.valid === true;
const anyOtsConfirmed = stampOk || upgradedOk;
summaryWrap.style.display = 'block';
summaryCard.classList.remove('pq-result-valid', 'pq-result-invalid', 'pq-result-pending');
if (allValid && anyOtsConfirmed) {
summaryCard.classList.add('pq-result-valid');
const anchor = upgradedOk
? (otsUpgradedState.label || 'the upgraded proof')
: (otsStampState.label || 'the original proof');
summaryCard.textContent = `All signatures are verified and valid, and the event is stamped on the Bitcoin blockchain (via ${anchor}).`;
} else if (allValid && otsStampState.valid === 'pending') {
summaryCard.classList.add('pq-result-pending');
summaryCard.textContent = 'All signatures are verified and valid. The OpenTimestamps proof is pending — not yet stamped on the Bitcoin blockchain. You can upgrade it below.';
} else if (allValid) {
summaryCard.classList.add('pq-result-invalid');
summaryCard.textContent = 'All signatures are verified and valid, but no Bitcoin timestamp attestation was found.';
} else {
summaryCard.classList.add('pq-result-invalid');
summaryCard.textContent = 'Some checks failed verification. See the results above for details.';
}
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR PROOF CARRIER EVENTS (fetch ALL candidates)
================================================================ */
function queryRelayForEvents(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const events = [];
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
resolve(events);
}
}, 15000);
ws.onopen = () => {
// REQ for all proof carrier events — no limit (G56-02 fix)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex] }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (data[2]) events.push(data[2]);
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(events);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
hideOtsCard('stamp');
hideOtsCard('upgraded');
summaryWrap.style.display = 'none';
otsStampState = { valid: null, label: '', detail: '' };
otsUpgradedState = { valid: null, label: '', detail: '' };
// Display full proof carrier JSON at the top of the page
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify proof carrier secp256k1 signature
let secpValid = false;
try {
secpValid = verifyNostrEvent(event);
} catch (e) {
secpValid = false;
addResult('secp256k1 (proof carrier)', false, `verification threw: ${e.message}`);
}
if (secpValid) {
addResult('secp256k1 (proof carrier)', true, 'valid');
} else if (!resultsWrap.querySelector('.pq-result-item')) {
addResult('secp256k1 (proof carrier)', false, 'INVALID');
}
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag (strict policy)
// G56-03: pass outer pubkey and expected author for identity binding
let pqResults;
try {
pqResults = verifyNIPQRContent(event.content, event.tags, {
outerPubkey: event.pubkey,
expectedAuthor: currentExpectedAuthor || undefined
});
} catch (e) {
pqResults = {
valid: false,
results: [{ algorithm: 'verifyNIPQRContent', valid: false, note: `threw: ${e.message}` }],
kind1Event: null,
fullHash: null,
sha256Valid: false,
eTagValid: false,
pqProofsValid: false,
policySufficient: false,
validForMigration: false,
errors: [`verifyNIPQRContent threw: ${e.message}`]
};
}
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 2b. Display policy sufficiency as a distinct result (G56-01)
if (pqResults.kind1Event) {
addResult(
'PQ algorithm policy',
pqResults.policySufficient,
pqResults.policySufficient
? 'all mandatory algorithms present and verified'
: (pqResults.errors.length > 0 ? pqResults.errors.join('; ') : 'insufficient PQ evidence')
);
if (!pqResults.policySufficient) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// The OTS result is rendered as a dedicated card (pqOtsStampCard)
// alongside the signature results. If the proof is pending, an
// upgrade card (pqOtsUpgradedCard) is reserved for a later upgraded
// proof. A final summary card is rendered at the end.
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
otsUpgradeWrap.style.display = 'block';
if (!validFile) {
// Invalid format — no upgrade possible
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Invalid format',
`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
} else if (hasBitcoinAttestation) {
// Contains a Bitcoin attestation — run full async verification.
// Show a "verifying" stamp card first, then update it.
otsStampState = { valid: null, label: '', detail: '' };
setOtsCard('stamp', 'pending', 'OTS Stamp: Verifying...',
`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
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';
otsStampState = {
valid: true,
label: `Bitcoin block ${att.height}`,
detail: `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}.`
};
setOtsCard('stamp', 'valid', 'OTS Stamp: Verified (Bitcoin)', otsStampState.detail);
otsUpgradeBtn.style.display = 'none';
renderSummary(allValid);
} else {
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Verification failed',
`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
renderSummary(allValid);
}
}).catch(err => {
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Verification error',
`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
renderSummary(allValid);
});
} else {
// Pending — no Bitcoin attestation yet. Reserve the upgraded card
// for when the user upgrades the proof below.
otsStampState = { valid: 'pending', label: '', detail: '' };
setOtsCard('stamp', 'pending', 'OTS Stamp: Pending',
`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
}
} catch (e) {
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: Error',
`Failed to parse OTS proof: ${e.message}`);
hideOtsCard('upgraded');
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
renderSummary(allValid);
}
} else {
otsStampState = { valid: false, label: '', detail: '' };
setOtsCard('stamp', 'invalid', 'OTS Stamp: None',
'No OpenTimestamps proof tag found in this event.');
hideOtsCard('upgraded');
otsUpgradeWrap.style.display = 'none';
renderSummary(allValid);
}
resultsWrap.style.display = 'block';
// Only the event owner (signed-in pubkey matches event pubkey) may
// upgrade/republish. Non-owners see the verification result but the
// upgrade action is hidden to avoid confusion and endpoint abuse.
applyOwnershipGate(event);
return allValid;
}
/* ================================================================
OWNERSHIP GATE: only the event owner can upgrade/republish the OTS
proof. Non-owners see the verification result but the upgrade action
buttons are hidden and replaced with an explanatory note.
================================================================ */
// Cache the signed-in pubkey so we don't call getPublicKey repeatedly.
let cachedSignedInPubkey = null;
let cachedSignedInPubkeyResolved = false;
async function getSignedInPubkey() {
if (cachedSignedInPubkeyResolved) return cachedSignedInPubkey;
cachedSignedInPubkeyResolved = true;
if (!window.nostr || !window.nostr.getPublicKey) return null;
try {
cachedSignedInPubkey = await window.nostr.getPublicKey();
} catch (e) {
cachedSignedInPubkey = null;
}
return cachedSignedInPubkey;
}
function applyOwnershipGate(event) {
// Default: hide the upgrade action row until we confirm ownership.
const actionRow = otsUpgradeWrap.querySelector('.pq-button-row');
if (!actionRow) return;
getSignedInPubkey().then(signedInPubkey => {
const isOwner = signedInPubkey && event && event.pubkey &&
signedInPubkey.toLowerCase() === event.pubkey.toLowerCase();
if (isOwner) {
actionRow.style.display = '';
const note = document.getElementById('pqOtsOwnerNote');
if (note) note.remove();
} else {
// Non-owner (or not signed in): hide the buttons and show a note.
actionRow.style.display = 'none';
let note = document.getElementById('pqOtsOwnerNote');
if (!note) {
note = document.createElement('div');
note.id = 'pqOtsOwnerNote';
note.className = 'pq-info-text';
note.style.fontSize = '13px';
note.style.marginTop = '8px';
otsUpgradeWrap.appendChild(note);
}
note.textContent = signedInPubkey
? 'Only the event owner can upgrade and republish the OpenTimestamps proof. You are signed in as a different identity.'
: 'Only the event owner can upgrade and republish the OpenTimestamps proof. Sign in with the Nostr identity that published this event to enable upgrading.';
}
});
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setButtonSpinner(queryBtn);
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
let allCandidates = [];
let lastError = null;
let successCount = 0;
// G56-02: Query relays in parallel, collect ALL candidates (not limit: 1)
const promises = relayUrls.map(async (url) => {
try {
const events = await queryRelayForEvents(pubkeyHex, url);
if (events && events.length > 0) {
successCount++;
allCandidates.push(...events);
}
return { url, ok: true, count: events ? events.length : 0 };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
// Deduplicate by event ID
const seenIds = new Set();
allCandidates = allCandidates.filter(e => {
if (!e || !e.id || seenIds.has(e.id)) return false;
seenIds.add(e.id);
return true;
});
if (allCandidates.length === 0) {
setStatus(queryStatus, 'error', `No proof carrier events found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'info', `Found ${allCandidates.length} proof carrier event(s) from ${npub.substring(0, 20)}... across ${successCount}/${relayUrls.length} relay(s). Selecting canonical (earliest Bitcoin anchor)...`);
// G56-02: Select the canonical proof carrier (earliest valid Bitcoin anchor)
// First, parse the kind 1 event from the first candidate to use for selection
let kind1Event = null;
try {
kind1Event = JSON.parse(allCandidates[0].content);
} catch (e) {
// Try other candidates
for (const c of allCandidates) {
try {
kind1Event = JSON.parse(c.content);
break;
} catch (e2) { /* keep trying */ }
}
}
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
// Set the expected author for G56-03 identity binding
currentExpectedAuthor = pubkeyHex;
try {
const selection = await selectCanonicalProofCarrier(allCandidates, kind1Event, pubkeyHex);
if (selection.canonical) {
const heightNote = selection.canonicalBitcoinHeight ? `Bitcoin block ${selection.canonicalBitcoinHeight}` : 'pending (no Bitcoin anchor yet)';
const confirmedCount = selection.confirmedCandidates.length;
const pendingCount = selection.pendingCandidates.length;
// F-D4/L-2: a pending-only selection is NOT a trust decision. Surface
// canonicalTrustMode explicitly so the UI never presents a pending-only
// selection as a confirmed anchor.
const trustModeNote = selection.canonicalTrustMode === 'pending-only'
? ' ⚠ PENDING-ONLY — no Bitcoin attestation has been verified yet; this is not a trust decision.'
: (selection.canonicalTrustMode === 'none' ? ' ⚠ no trustable anchor found.' : '');
// F-D4/L-2: a pending-only selection is informational, not an error —
// it is not a trust decision, so do not render it in the error (red) style.
const statusClass = selection.canonicalTrustMode === 'pending-only' ? 'info' : 'success';
setStatus(queryStatus, statusClass, `Selected canonical proof carrier: ${selection.canonical.id.substring(0, 16)}... (${heightNote}). ${confirmedCount} confirmed, ${pendingCount} pending, ${allCandidates.length} total candidate(s).${trustModeNote}`);
await verifyEvent(selection.canonical);
} else {
setStatus(queryStatus, 'error', `No valid proof carrier found among ${allCandidates.length} candidate(s). Errors: ${selection.errors.join('; ')}`);
}
} catch (e) {
setStatus(queryStatus, 'error', `Canonical selection failed: ${e.message}`);
}
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures and PQ algorithm policy verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some checks failed verification. See results below for details.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
F-D2: IMPORT PROOF ARCHIVE (offline verification, no relay needed)
================================================================ */
const archiveFileInput = document.getElementById('pqArchiveFileInput');
if (archiveFileInput) {
archiveFileInput.addEventListener('change', async (ev) => {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
setStatus(verifyStatus, 'info', `Importing archive: ${file.name}...`);
try {
const text = await file.text();
const archive = parseProofArchive(text);
setStatus(verifyStatus, 'info', `Archive parsed (v${archive.archiveVersion}). Verifying proof carrier...`);
// Verify the extracted proof carrier end-to-end without any relay
const allValid = await verifyEvent(archive.proofCarrierEvent);
if (allValid) {
setStatus(verifyStatus, 'success',
`Archive verified successfully (offline, no relay contacted). ` +
`Kind 1 ID: ${archive.kind1Event.id.substring(0, 16)}... ` +
`Proof carrier ID: ${archive.proofCarrierEvent.id.substring(0, 16)}...`);
} else {
setStatus(verifyStatus, 'error', 'Archive proof carrier failed verification. See results below.');
}
} catch (error) {
console.error('[verify] Archive import error:', error);
setStatus(verifyStatus, 'error', `Archive import failed: ${error.message}`);
} finally {
// Allow re-selecting the same file
archiveFileInput.value = '';
}
});
}
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
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';
// Render the upgraded proof as a second OTS card and refresh summary.
otsUpgradedState = {
valid: true,
label: `Bitcoin block ${att ? att.height : '?'}`,
detail: `OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`
};
setOtsCard('upgraded', 'valid', 'OTS Upgraded: Verified (Bitcoin)', otsUpgradedState.detail);
renderSummary(true);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
otsUpgradedState = { valid: 'pending', label: '', detail: '' };
setOtsCard('upgraded', 'pending', 'OTS Upgraded: Still pending',
`Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
renderSummary(true);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to publish the upgraded event.');
}
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();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', validatedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
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) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = validatedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded proof carrier published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(validatedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Publish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
/* ================================================================
AUTHENTICATION (nostr-login-lite / NIP-07)
================================================================ */
async function initNostrLoginLite() {
if (!window.NOSTR_LOGIN_LITE) return false;
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
return true;
}
// Fill the pubkey field and auto-query relays for the signed-in user's
// kind 9999 event. Switches to the Query Relay tab so the user sees the
// results. Returns the pubkey hex on success, null otherwise.
async function queryAsSignedInUser() {
if (!window.nostr || !window.nostr.getPublicKey) {
throw new Error('No Nostr signer available. Install a NIP-07 browser extension (nos2x, Alby) or use nostr-login-lite.');
}
const pubkeyHex = await window.nostr.getPublicKey();
if (!pubkeyHex) throw new Error('No pubkey returned by signer.');
console.log('[verify] Signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Show the query tab so the user sees the results
switchTab('relay');
queryBtn.click();
return pubkeyHex;
}
/* ================================================================
TAB 3: SIGN IN
================================================================ */
const signInBtn = document.getElementById('pqSignInBtn');
const signInError = document.getElementById('pqSignInError');
const signInStatus = document.getElementById('pqSignInStatus');
signInBtn.addEventListener('click', async () => {
signInBtn.disabled = true;
signInError.innerHTML = '';
setStatus(signInStatus, 'info', 'Signing in...');
try {
if (window.NOSTR_LOGIN_LITE) {
await initNostrLoginLite();
// Try a restored session first
if (window.nostr) {
try {
const pk = await window.nostr.getPublicKey();
if (pk) {
await queryAsSignedInUser();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
return;
}
} catch (e) {
console.log('[verify] Not yet authenticated, launching login modal...');
}
}
// Launch the login modal and wait for auth events
const pubkeyHex = await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (!settled) { settled = true; cleanup(); reject(new Error('Login timed out')); }
}, 120000);
async function tryGetPubkey() {
if (settled) return;
if (window.nostr) {
try {
const pk = await window.nostr.getPublicKey();
if (pk && !settled) { settled = true; cleanup(); resolve(pk); }
} catch (e) { console.log('[verify] getPublicKey not ready:', e.message); }
}
}
function authHandler(e) {
if (e.type === 'nlMethodSelected' && e.detail && e.detail.pubkey) {
if (!settled) { settled = true; cleanup(); resolve(e.detail.pubkey); }
return;
}
tryGetPubkey();
setTimeout(tryGetPubkey, 200);
setTimeout(tryGetPubkey, 500);
setTimeout(tryGetPubkey, 1000);
setTimeout(tryGetPubkey, 2000);
}
function cleanup() {
window.removeEventListener('nlAuth', authHandler);
window.removeEventListener('nlAuthRestored', authHandler);
window.removeEventListener('nlMethodSelected', authHandler);
window.removeEventListener('nlAuthComplete', authHandler);
clearTimeout(timeout);
}
window.addEventListener('nlAuth', authHandler);
window.addEventListener('nlAuthRestored', authHandler);
window.addEventListener('nlMethodSelected', authHandler);
window.addEventListener('nlAuthComplete', authHandler);
try {
const result = window.NOSTR_LOGIN_LITE.launch();
if (result && typeof result.then === 'function') {
result.then(() => {
tryGetPubkey(); setTimeout(tryGetPubkey, 200); setTimeout(tryGetPubkey, 500);
}).catch(e => { if (!settled) { settled = true; cleanup(); reject(e); } });
}
} catch (e) {
if (!settled) { settled = true; cleanup(); reject(e); }
}
});
// Fill the pubkey field with the launched-session pubkey and query
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
switchTab('relay');
queryBtn.click();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
} else {
// No nostr-login-lite — fall back to a plain NIP-07 extension
await queryAsSignedInUser();
setStatus(signInStatus, 'success', 'Signed in. Querying your event...');
}
} catch (error) {
console.error('[verify] Sign-in failed:', error);
setStatus(signInError, 'error', error.message);
setStatus(signInStatus, 'error', 'Sign-in failed.');
} finally {
signInBtn.disabled = false;
}
});
/* ================================================================
URL ?npub= PARAMETER + AUTO-DETECT SIGNED-IN USER
On page load, first check for a ?npub= query parameter (used by the
"Verify this attestation" link in the kind 1 event). If present,
pre-fill the pubkey field and auto-query relays for that pubkey's
kind 9999 event. Otherwise, fall back to detecting a NIP-07 signer /
nostr-login-lite session and auto-query for the signed-in user.
================================================================ */
async function initAutoDetect() {
// 1. Check for ?npub= (or ?pubkey=) URL parameter first
const params = new URLSearchParams(window.location.search);
const npubParam = params.get('npub') || params.get('pubkey');
if (npubParam) {
const pubkeyHex = normalizePubkey(npubParam);
if (pubkeyHex) {
console.log('[verify] URL parameter pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
queryBtn.click();
return;
}
}
// 2. Fall back to signed-in user detection
if (window.NOSTR_LOGIN_LITE) {
try {
await initNostrLoginLite();
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
}
if (!window.nostr || !window.nostr.getPublicKey) return;
let pubkeyHex = null;
try {
pubkeyHex = await window.nostr.getPublicKey();
} catch (e) {
console.log('[verify] Not signed in:', e.message);
return;
}
if (!pubkeyHex) return;
console.log('[verify] Auto-detected signed-in pubkey:', pubkeyHex);
const pubkeyInput = document.getElementById('pqPubkeyInput');
const npub = hexToNpub(pubkeyHex);
pubkeyInput.value = npub || pubkeyHex;
// Auto-trigger the query
queryBtn.click();
}
initAutoDetect();