992 lines
44 KiB
JavaScript
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();
|