814 lines
30 KiB
HTML
814 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
|
|
<title>Verify NIP-QR Event</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
|
|
|
|
<style>
|
|
#divBody {
|
|
flex-direction: column !important;
|
|
flex-wrap: nowrap !important;
|
|
align-items: center !important;
|
|
justify-content: flex-start !important;
|
|
align-content: flex-start !important;
|
|
}
|
|
|
|
.pq-container {
|
|
max-width: 760px;
|
|
width: 100%;
|
|
padding: 10px;
|
|
}
|
|
|
|
.pq-card {
|
|
background: var(--secondary-color);
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 25px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.pq-card-title {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: var(--primary-color);
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.pq-info-text {
|
|
font-size: 14px;
|
|
color: var(--primary-color);
|
|
line-height: 1.6;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.pq-info-text strong { color: var(--primary-color); }
|
|
|
|
.pq-button {
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
border: var(--border-width) solid var(--primary-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 12px 24px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: opacity 0.2s;
|
|
width: 100%;
|
|
}
|
|
|
|
.pq-button:hover { opacity: 0.7; }
|
|
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
|
|
.pq-button-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.pq-button-row .pq-button { width: auto; }
|
|
|
|
.pq-input {
|
|
width: 100%;
|
|
background: var(--secondary-color);
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 12px;
|
|
font-size: 14px;
|
|
color: var(--primary-color);
|
|
margin: 10px 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.pq-input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.pq-textarea {
|
|
width: 100%;
|
|
background: var(--secondary-color);
|
|
border: var(--border);
|
|
border-radius: var(--border-radius);
|
|
padding: 12px;
|
|
font-size: 12px;
|
|
color: var(--primary-color);
|
|
margin: 10px 0;
|
|
min-height: 160px;
|
|
resize: vertical;
|
|
font-family: monospace;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.pq-textarea:focus {
|
|
outline: none;
|
|
border-color: var(--accent-color);
|
|
}
|
|
|
|
.pq-status {
|
|
padding: 10px 15px;
|
|
border-radius: var(--border-radius);
|
|
margin: 10px 0;
|
|
font-size: 14px;
|
|
text-align: left;
|
|
}
|
|
|
|
.pq-status-info {
|
|
background: var(--secondary-color);
|
|
border: var(--border);
|
|
}
|
|
|
|
.pq-status-success {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.pq-status-error {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.pq-result-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin: 15px 0;
|
|
}
|
|
|
|
.pq-result-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 15px;
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
border-radius: var(--border-radius);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.pq-result-valid {
|
|
border-left: 4px solid #00aa00;
|
|
}
|
|
|
|
.pq-result-invalid {
|
|
border-left: 4px solid #cc0000;
|
|
}
|
|
|
|
.pq-event-preview {
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
border-radius: var(--border-radius);
|
|
padding: 15px;
|
|
font-size: 11px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
text-align: left;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.pq-link {
|
|
color: var(--accent-color);
|
|
text-decoration: underline;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.pq-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-bottom: 15px;
|
|
border-bottom: var(--border);
|
|
}
|
|
|
|
.pq-tab {
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
color: var(--muted-color);
|
|
border-bottom: 3px solid transparent;
|
|
transition: color 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.pq-tab:hover { color: var(--primary-color); }
|
|
|
|
.pq-tab.pq-tab-active {
|
|
color: var(--primary-color);
|
|
border-bottom-color: var(--primary-color);
|
|
}
|
|
|
|
.pq-tab-panel { display: none; }
|
|
.pq-tab-panel.pq-tab-panel-active { display: block; }
|
|
|
|
.pq-ots-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: var(--border-radius);
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.pq-ots-badge-pending {
|
|
background: #f0ad4e;
|
|
color: #fff;
|
|
}
|
|
|
|
.pq-ots-badge-confirmed {
|
|
background: #00aa00;
|
|
color: #fff;
|
|
}
|
|
|
|
.pq-ots-badge-none {
|
|
background: var(--muted-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<!-- HEADER -->
|
|
<div id="divHeader">
|
|
<div id="divHeaderFlexLeft"></div>
|
|
<div id="divHeaderFlexCenter">
|
|
<div class="divHeaderText">Verify NIP-QR Event</div>
|
|
</div>
|
|
<div id="divHeaderFlexRight"></div>
|
|
</div>
|
|
|
|
<!-- BODY -->
|
|
<div id="divBody">
|
|
<div class="pq-container">
|
|
|
|
<div class="pq-card">
|
|
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
|
|
<div class="pq-info-text">
|
|
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
|
|
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
|
|
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
|
|
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
|
|
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
|
|
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
|
|
</div>
|
|
<div class="pq-info-text">
|
|
<a href="./" class="pq-link">Back to migration page</a>
|
|
</div>
|
|
|
|
<div class="pq-tabs">
|
|
<div class="pq-tab pq-tab-active" id="pqTabRelay">Query Relay</div>
|
|
<div class="pq-tab" id="pqTabPaste">Paste Event JSON</div>
|
|
</div>
|
|
|
|
<!-- TAB 1: Query Relay -->
|
|
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
|
|
<div class="pq-info-text">
|
|
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
|
|
relay(s) for the latest kind 11112 event from that pubkey and verify it.
|
|
</div>
|
|
<input type="text" class="pq-input" id="pqPubkeyInput"
|
|
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
|
|
<input type="text" class="pq-input" id="pqRelayInput"
|
|
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
|
|
placeholder="wss://relay1.com,wss://relay2.com" />
|
|
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
|
|
<div id="pqQueryStatus"></div>
|
|
</div>
|
|
|
|
<!-- TAB 2: Paste Event JSON -->
|
|
<div class="pq-tab-panel" id="pqPanelPaste">
|
|
<div class="pq-info-text">
|
|
Paste a kind 11112 event JSON below to verify all signatures.
|
|
</div>
|
|
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
|
|
{
|
|
"id": "...",
|
|
"pubkey": "...",
|
|
"kind": 11112,
|
|
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
|
|
"tags": [
|
|
["e", "<kind 1 event id>"],
|
|
["sha256", "<hash of full kind 1 event>"],
|
|
["ots", "<base64 .ots proof>"]
|
|
],
|
|
"sig": "..."
|
|
}'></textarea>
|
|
<button class="pq-button" id="pqVerifyBtn">Verify Signatures</button>
|
|
<div id="pqVerifyStatus"></div>
|
|
</div>
|
|
|
|
<!-- Shared results area -->
|
|
<div id="pqResultsWrap" style="display: none;">
|
|
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;">
|
|
<strong>Verification Results:</strong>
|
|
<span id="pqOtsBadge"></span>
|
|
</div>
|
|
<div class="pq-result-list" id="pqResultList"></div>
|
|
</div>
|
|
|
|
<div id="pqEventDisplayWrap" style="display: none; margin-top: 15px;">
|
|
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Event content:</strong></div>
|
|
<div class="pq-event-preview" id="pqEventContent"></div>
|
|
</div>
|
|
|
|
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
|
|
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
|
|
<div class="pq-event-preview" id="pqEventJson"></div>
|
|
</div>
|
|
|
|
<!-- OTS upgrade section -->
|
|
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 15px;">
|
|
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>OpenTimestamps:</strong></div>
|
|
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
|
|
<div class="pq-button-row">
|
|
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
|
|
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
|
|
</div>
|
|
<div id="pqOtsUpgradeStatus"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FOOTER -->
|
|
<div id="divFooter">
|
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
|
<div id="divFooterRight" class="divFooterBox"></div>
|
|
</div>
|
|
|
|
<!-- SCRIPTS -->
|
|
<script src="/nostr-login-lite/nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
import {
|
|
verifyNIPQRContent,
|
|
verifyNostrEvent,
|
|
NIP_QR_KIND,
|
|
buildUpgradedEvent,
|
|
bytesToBase64,
|
|
base64ToBytes,
|
|
hexToBytes,
|
|
bytesToHex,
|
|
isOtsConfirmed,
|
|
verifyOtsProof,
|
|
upgradeOts,
|
|
isDetachedOtsFile
|
|
} from './pq-crypto.bundle.js';
|
|
|
|
/* ================================================================
|
|
TAB MANAGEMENT
|
|
================================================================ */
|
|
const tabRelay = document.getElementById('pqTabRelay');
|
|
const tabPaste = document.getElementById('pqTabPaste');
|
|
const panelRelay = document.getElementById('pqPanelRelay');
|
|
const panelPaste = document.getElementById('pqPanelPaste');
|
|
|
|
function switchTab(which) {
|
|
const isRelay = which === 'relay';
|
|
tabRelay.classList.toggle('pq-tab-active', isRelay);
|
|
tabPaste.classList.toggle('pq-tab-active', !isRelay);
|
|
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
|
|
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
|
|
}
|
|
|
|
tabRelay.addEventListener('click', () => switchTab('relay'));
|
|
tabPaste.addEventListener('click', () => switchTab('paste'));
|
|
|
|
/* ================================================================
|
|
SHARED DOM + STATE
|
|
================================================================ */
|
|
const resultsWrap = document.getElementById('pqResultsWrap');
|
|
const resultList = document.getElementById('pqResultList');
|
|
const eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
|
|
const eventContent = document.getElementById('pqEventContent');
|
|
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
|
|
const eventJsonEl = document.getElementById('pqEventJson');
|
|
const otsBadge = document.getElementById('pqOtsBadge');
|
|
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
|
|
const otsInfo = document.getElementById('pqOtsInfo');
|
|
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
|
|
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
|
|
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
|
|
|
|
let currentEvent = null;
|
|
let currentOtsBytes = null;
|
|
|
|
// 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);
|
|
}
|
|
|
|
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
|
|
function setOtsBadge(className, text) {
|
|
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
|
|
otsBadge.querySelector('span').textContent = text;
|
|
}
|
|
function setOtsInfo(text) {
|
|
otsInfo.textContent = text;
|
|
}
|
|
|
|
/* ================================================================
|
|
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 KIND 11112 EVENT
|
|
================================================================ */
|
|
function queryRelayForEvent(pubkeyHex, relayUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const ws = new WebSocket(relayUrl);
|
|
let resolved = false;
|
|
const timeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
try { ws.close(); } catch (e) {}
|
|
reject(new Error('timeout'));
|
|
}
|
|
}, 15000);
|
|
|
|
ws.onopen = () => {
|
|
// REQ for kind 11112, limit 1 (latest)
|
|
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
|
|
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
|
|
};
|
|
|
|
ws.onmessage = (msg) => {
|
|
try {
|
|
const data = JSON.parse(msg.data);
|
|
if (data[0] === 'EVENT') {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(timeout);
|
|
try { ws.close(); } catch (e) {}
|
|
resolve(data[2]);
|
|
}
|
|
} else if (data[0] === 'EOSE') {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(timeout);
|
|
try { ws.close(); } catch (e) {}
|
|
resolve(null);
|
|
}
|
|
}
|
|
} 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';
|
|
eventDisplayWrap.style.display = 'none';
|
|
eventJsonWrap.style.display = 'none';
|
|
otsUpgradeWrap.style.display = 'none';
|
|
otsBadge.innerHTML = '';
|
|
|
|
// Display content (the embedded kind 1 event, pretty-printed) and full kind 11112 JSON
|
|
try {
|
|
const parsed = JSON.parse(event.content);
|
|
eventContent.textContent = JSON.stringify(parsed, null, 2);
|
|
} catch (e) {
|
|
eventContent.textContent = event.content;
|
|
}
|
|
eventDisplayWrap.style.display = 'block';
|
|
eventJsonEl.textContent = JSON.stringify(event, null, 2);
|
|
eventJsonWrap.style.display = 'block';
|
|
|
|
let allValid = true;
|
|
|
|
// 1. Verify kind 11112 secp256k1 signature
|
|
const secpValid = verifyNostrEvent(event);
|
|
addResult('secp256k1 (kind 11112 wrapper)', secpValid, secpValid ? 'valid' : 'INVALID');
|
|
if (!secpValid) allValid = false;
|
|
|
|
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag
|
|
const pqResults = verifyNIPQRContent(event.content, event.tags);
|
|
for (const r of pqResults.results) {
|
|
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
|
|
if (!r.valid) allValid = false;
|
|
}
|
|
|
|
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
|
|
// First do a quick structural check for immediate UI feedback, then
|
|
// run full cryptographic verification (async — fetches Bitcoin block
|
|
// headers and validates the Merkle path).
|
|
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';
|
|
|
|
// Quick structural display first
|
|
if (!validFile) {
|
|
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
|
|
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
|
|
otsUpgradeWrap.style.display = 'none';
|
|
} else if (hasBitcoinAttestation) {
|
|
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
|
|
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
|
|
otsUpgradeWrap.style.display = 'block';
|
|
otsUpgradeBtn.style.display = 'none';
|
|
otsRepublishBtn.style.display = 'none';
|
|
|
|
// 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);
|
|
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}.`);
|
|
otsUpgradeBtn.style.display = 'none';
|
|
} else {
|
|
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
|
|
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
|
|
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
|
|
otsUpgradeBtn.style.display = 'inline-block';
|
|
}
|
|
}).catch(err => {
|
|
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
|
|
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
|
|
otsUpgradeBtn.style.display = 'inline-block';
|
|
});
|
|
} else {
|
|
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
|
|
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
|
|
otsUpgradeWrap.style.display = 'block';
|
|
otsUpgradeBtn.style.display = 'inline-block';
|
|
otsRepublishBtn.style.display = 'none';
|
|
}
|
|
} catch (e) {
|
|
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
|
|
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
|
|
otsUpgradeWrap.style.display = 'none';
|
|
}
|
|
} else {
|
|
setOtsBadge('pq-ots-badge-none', 'OTS: None');
|
|
setOtsInfo('No OpenTimestamps proof tag found in this event.');
|
|
otsUpgradeWrap.style.display = 'none';
|
|
}
|
|
|
|
resultsWrap.style.display = 'block';
|
|
return allValid;
|
|
}
|
|
|
|
/* ================================================================
|
|
TAB 1: QUERY RELAY
|
|
================================================================ */
|
|
const queryBtn = document.getElementById('pqQueryBtn');
|
|
const queryStatus = document.getElementById('pqQueryStatus');
|
|
|
|
queryBtn.addEventListener('click', async () => {
|
|
queryBtn.disabled = true;
|
|
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;
|
|
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;
|
|
return;
|
|
}
|
|
|
|
let foundEvent = null;
|
|
let lastError = null;
|
|
let successCount = 0;
|
|
|
|
// Query relays in parallel, take the first event returned
|
|
const promises = relayUrls.map(async (url) => {
|
|
try {
|
|
const event = await queryRelayForEvent(pubkeyHex, url);
|
|
if (event) {
|
|
successCount++;
|
|
if (!foundEvent || (event.created_at > foundEvent.created_at)) {
|
|
foundEvent = event;
|
|
}
|
|
}
|
|
return { url, ok: true, event };
|
|
} catch (e) {
|
|
lastError = e.message;
|
|
return { url, ok: false, error: e.message };
|
|
}
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
|
|
if (foundEvent) {
|
|
const npub = hexToNpub(pubkeyHex);
|
|
setStatus(queryStatus, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`);
|
|
await verifyEvent(foundEvent);
|
|
} else {
|
|
setStatus(queryStatus, 'error', `No kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
|
|
}
|
|
|
|
queryBtn.disabled = false;
|
|
});
|
|
|
|
/* ================================================================
|
|
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 verified successfully!');
|
|
} else {
|
|
setStatus(verifyStatus, 'error', 'Some signatures failed verification.');
|
|
}
|
|
} catch (error) {
|
|
console.error('[verify] Error:', error);
|
|
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
|
|
} finally {
|
|
verifyBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
/* ================================================================
|
|
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';
|
|
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
|
|
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.`);
|
|
} else {
|
|
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
|
|
}
|
|
} 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 republish.');
|
|
}
|
|
|
|
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
|
|
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
|
|
|
|
// 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', signedEvent]);
|
|
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] === signedEvent.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 = signedEvent;
|
|
setStatus(otsUpgradeStatus, 'success', `Upgraded kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
|
|
// Re-verify the new event
|
|
await verifyEvent(signedEvent);
|
|
} catch (error) {
|
|
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
|
|
} finally {
|
|
otsRepublishBtn.disabled = false;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|