Compare commits

...
8 Commits
8 changed files with 565 additions and 175 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nostr_quantum_preparation",
"version": "0.0.31",
"version": "0.1.1",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+22 -15
View File
@@ -104,6 +104,7 @@
line-height: 20px;
}
.pq-checklist-link {
color: var(--accent-color);
text-decoration: underline;
@@ -355,14 +356,14 @@
<li>A new seed phrase. This should be kept secret and private.</li>
<li>A proof archive. This information is published to relays, but keeping a copy yourself is useful in case the relays don't keep the event.</li>
</ol>
In case quantum computers break nostr, you can use your seed phrase to create a new nostr
</div>
<div class="pq-info-text">
In case quantum computers break nostr, you can use your new seed phrase to create a new nostr
identity, and prove that your old nostr identity created this new one.
</div>
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Warning:</strong> Don't enter a valuable existing seed phrase. This tool doesn't make
your Nostr identity post-quantum secure on its own — it creates a public link your future self
can prove is yours. Run through it once with a test identity before you use it on your real one.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
</div>
@@ -452,6 +453,11 @@
<!-- Bring your own mode -->
<div id="pqSeedOwnPanel" style="display: none;">
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
<strong>Warning:</strong> Don't enter a valuable existing seed phrase. This tool doesn't make
your Nostr identity post-quantum secure on its own — it creates a public link your future self
can prove is yours. Run through it once with a test identity before you use it on your real one.
</div>
<div class="pq-info-text">
Paste your 12 or 24-word BIP39 seed phrase below. It will be validated and used to
derive your post-quantum keys. Make sure you generated it offline or with a trusted tool.
@@ -543,9 +549,6 @@
<div id="pqEventIdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqSuccessEventPreview"></div>
<div class="pq-info-text" id="pqEventSizeWarning" style="display: none; font-size: 13px; margin-top: 8px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqCopyProofCarrierBtn">Copy Proof Carrier Event</button>
</div>
<div style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Relays to publish to:</strong></div>
@@ -600,7 +603,7 @@
<span>Pending proof embedded in kind 9999 event</span>
</div>
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
<span class="pq-checklist-box"></span>
<span class="pq-checklist-box" id="pqOtsConfirmBox"></span>
<span>Wait for Bitcoin confirmation
<div class="pq-key-meta" id="pqOtsConfirmDetail" style="margin-top: 2px;">Polling every 60 seconds...</div>
</span>
@@ -610,11 +613,15 @@
<span>Publish new kind 9999 with confirmed proof — automatic on confirmation</span>
</div>
</div>
<div id="pqOtsProofWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;">OTS proof file (base64):</div>
<div class="pq-event-preview" id="pqOtsProof" style="max-height: 150px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqOtsDownloadBtn">Download .ots file</button>
<div id="pqOtsArchiveWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 8px;">
Bitcoin confirmation verified and the upgraded proof carrier has been published to relays.
Download the upgraded proof archive below — it contains the kind 1 event, the upgraded kind 9999
proof carrier, and the <strong>confirmed</strong> OTS proof (with Bitcoin attestation). Keep a copy
in case relays delete the event.
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary" id="pqOtsArchiveBtn">Download Upgraded Proof Archive</button>
</div>
</div>
</div>
+103 -31
View File
@@ -16,6 +16,7 @@
bytesToBase64,
base64ToBytes,
bytesToHex,
hexToNpub,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
@@ -140,6 +141,27 @@
}
}
/* Braille spinner on the "Wait for Bitcoin confirmation" checklist box
(pqOtsConfirm). Same frames as the step-6 checklist-box spinner. */
let otsConfirmSpinnerInterval = null;
function startOtsConfirmSpinner() {
const box = document.getElementById('pqOtsConfirmBox');
if (!box) return;
stopOtsConfirmSpinner();
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
otsConfirmSpinnerInterval = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function stopOtsConfirmSpinner() {
if (otsConfirmSpinnerInterval) { clearInterval(otsConfirmSpinnerInterval); otsConfirmSpinnerInterval = null; }
const box = document.getElementById('pqOtsConfirmBox');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
/* ================================================================
VIEW MANAGEMENT
================================================================ */
@@ -1029,16 +1051,6 @@
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('pqRelayAddBtn').click(); }
});
// Copy Proof Carrier Event button
document.getElementById('pqCopyProofCarrierBtn').addEventListener('click', () => {
if (!pqEvent) return;
navigator.clipboard.writeText(JSON.stringify(pqEvent, null, 2)).then(() => {
const btn = document.getElementById('pqCopyProofCarrierBtn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy Proof Carrier Event'; }, 2000);
});
});
// Sign out button
const pqSignOutBtn = document.getElementById('pqSignOutBtn');
pqSignOutBtn.addEventListener('click', async () => {
@@ -1046,6 +1058,7 @@
// Stop OTS polling, but preserve its persisted workflow for the next login.
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
clearStepSpinner(6);
stopOtsConfirmSpinner();
pendingOtsBytes = null;
const persistedOts = localStorage.getItem('pq-pending-ots');
// Logout from nostr-login-lite
@@ -1142,6 +1155,8 @@
if (otsLogEl) otsLogEl.innerHTML = '';
setKeyIcon('pqOtsConfirm', '');
setKeyIcon('pqOtsConfirmedPublish', '');
stopOtsConfirmSpinner();
hideUpgradedArchiveButton();
// Check for a saved workflow from a previous session (same event ID)
let existing = loadPendingOts();
@@ -1160,10 +1175,10 @@
setStepDone(6);
setStepActive(7);
otsStatus.innerHTML = '<div class="pq-status pq-status-success">Confirmed timestamp proof was already published.</div>';
showOtsProof(pendingOtsBytes);
showUpgradedArchiveButton();
return;
}
showOtsProof(pendingOtsBytes);
hideUpgradedArchiveButton();
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 9999 event. Waiting for Bitcoin confirmation...</div>';
startOtsPolling();
@@ -1178,7 +1193,6 @@
pendingOtsBytes = base64ToBytes(otsTag[1]);
if (isDetachedOtsFile(pendingOtsBytes)) {
otsLog(`Found pending OTS proof embedded in event (${pendingOtsBytes.length} bytes).`);
showOtsProof(pendingOtsBytes);
savePendingOts(pqEvent.id, pendingOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
@@ -1214,8 +1228,6 @@
const otsBytes = await timestampEvent(fullHash);
pendingOtsBytes = otsBytes;
otsLog(`Created pending detached .ots file (${otsBytes.length} bytes).`);
showOtsProof(otsBytes);
// Republish the event with the OTS proof embedded
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing new kind 9999 event with embedded OTS proof...</div>';
await publishUpgradedEvent(otsBytes);
@@ -1238,20 +1250,28 @@
}
}
function showOtsProof(otsBytes) {
const proofWrap = document.getElementById('pqOtsProofWrap');
const proofEl = document.getElementById('pqOtsProof');
proofEl.textContent = bytesToBase64(otsBytes);
proofWrap.classList.remove('pq-hidden');
// The raw OTS proof base64 display and standalone .ots download have been
// removed from step 6. Instead, after confirmation + publish, the user is
// offered an upgraded proof archive (kind 1 + upgraded kind 9999 + confirmed
// OTS proof) via showUpgradedArchiveButton().
function showUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.remove('pq-hidden');
}
function hideUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.add('pq-hidden');
}
function startOtsPolling() {
const confirmDetail = document.getElementById('pqOtsConfirmDetail');
let pollCount = 0;
if (otsPollInterval) clearInterval(otsPollInterval);
// Show a braille spinner in the step-6 checklist box so the user can
// see the page is still actively working while waiting for Bitcoin.
// Show a braille spinner in the step-6 checklist box and inline next
// to the "Wait for Bitcoin confirmation" label so the user can see
// the page is still actively working while waiting for Bitcoin.
setStepSpinner(6);
startOtsConfirmSpinner();
async function poll() {
pollCount++;
@@ -1266,8 +1286,6 @@
proofCarrierEvent: pqEvent,
kind1Event
});
showOtsProof(pendingOtsBytes);
// Run full cryptographic verification: parse the proof, bind the
// target digest to the event's sha256 tag, walk the Merkle path,
// and check the block header against a Bitcoin API.
@@ -1284,13 +1302,14 @@
: verifyResult.trustMode || 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
stopOtsConfirmSpinner();
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
const saved = loadPendingOts();
if (!saved || !saved.confirmedPublished) {
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Publishing new kind 9999 with confirmed proof...');
await publishUpgradedEvent(pendingOtsBytes);
await publishUpgradedEvent(pendingOtsBytes, att ? att.height : null, att ? att.time : null);
}
} else if (isOtsConfirmed(pendingOtsBytes)) {
// Structural check found a Bitcoin attestation tag but full
@@ -1322,7 +1341,7 @@
otsPollInterval = setInterval(poll, 60000);
}
async function publishUpgradedEvent(upgradedOtsBytes) {
async function publishUpgradedEvent(upgradedOtsBytes, bitcoinHeight = null, bitcoinTime = null) {
const otsStatus = document.getElementById('pqOtsStatus');
try {
otsLog('Building upgraded proof carrier with confirmed OTS proof...');
@@ -1357,9 +1376,50 @@
kind1Event
});
// Publish a kind 1 reply announcing the confirmed attestation.
// This is a regular Nostr text note (reply to the original kind 1
// announcement) so it shows up in feeds/thread views. It only needs
// the secp256k1 signature — no PQ signatures.
if (kind1Event && kind1Event.id) {
try {
otsLog('Publishing kind 1 reply announcing confirmed attestation...');
const npub = hexToNpub(currentPubkey);
const dateStr = bitcoinTime
? new Date(bitcoinTime * 1000).toISOString().substring(0, 19)
: 'unknown';
const blockStr = bitcoinHeight ? `Bitcoin block ${bitcoinHeight} (mined ${dateStr} UTC)` : 'a Bitcoin block';
const replyContent = `✅ The OpenTimestamps proof for my post-quantum key attestation has been confirmed in ${blockStr}. The link between my current Nostr identity and my post-quantum keys is now anchored to the Bitcoin blockchain and cannot be backdated.
Verify it here: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
This is a reply to my original attestation announcement. The confirmed proof is carried in a new kind 9999 event referencing the original.`;
const replyTemplate = {
kind: 1,
content: replyContent,
tags: [
['e', kind1Event.id, '', 'root'],
['e', validatedEvent.id, '', 'mention']
],
pubkey: currentPubkey,
created_at: Math.floor(Date.now() / 1000)
};
const replySigned = await window.nostr.signEvent(replyTemplate);
const replyValidated = validateSignerOutput(replySigned, replyTemplate, currentPubkey);
const replyResults = await publishToRelays(replyValidated, relayUrls);
const replyOk = replyResults.filter(r => r.success).length;
const replyFail = replyResults.filter(r => !r.success).length;
otsLog(`Kind 1 reply published. Successful: ${replyOk}; failed: ${replyFail}.`);
} catch (replyErr) {
// The upgraded proof carrier is already published; a failed reply
// is non-fatal — log it but don't fail the whole operation.
otsLog(`WARNING: kind 1 reply failed: ${replyErr.message}`);
}
}
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
showUpgradedArchiveButton();
return validatedEvent;
} catch (error) {
otsLog(`ERROR publishing upgraded event: ${error.message}`);
@@ -1368,16 +1428,28 @@
}
}
// OTS event listeners
document.getElementById('pqOtsDownloadBtn').addEventListener('click', () => {
if (pendingOtsBytes) {
const blob = new Blob([pendingOtsBytes], { type: 'application/octet-stream' });
// OTS event listener: download the upgraded proof archive (kind 1 event +
// upgraded kind 9999 proof carrier + confirmed OTS proof) after Bitcoin
// confirmation and publish.
document.getElementById('pqOtsArchiveBtn').addEventListener('click', () => {
if (!kind1Event || !pqEvent || !pendingOtsBytes) {
console.warn('[post-quantum] Cannot build upgraded archive: missing kind1Event, proofCarrier, or OTS proof');
return;
}
try {
const archiveJson = buildProofArchive(kind1Event, pqEvent, pendingOtsBytes);
const blob = new Blob([archiveJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pqEvent.id.substring(0, 16)}.ots`;
a.download = `nostr-pq-proof-${pqEvent.id.substring(0, 12)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('[post-quantum] Upgraded proof archive downloaded');
} catch (e) {
console.error('[post-quantum] Failed to build upgraded archive:', e);
}
});
+2 -1
View File
@@ -638,7 +638,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
Verify this attestation: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
+350 -66
View File
@@ -22,19 +22,26 @@
================================================================ */
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', !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', !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
@@ -42,19 +49,56 @@
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const downloadEventBtn = document.getElementById('pqDownloadEventBtn');
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');
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');
@@ -71,13 +115,70 @@
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;
// 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 setOtsInfo(text) {
otsInfo.textContent = text;
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.';
}
}
/* ================================================================
@@ -168,9 +269,13 @@
resultsWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
hideOtsCard('stamp');
hideOtsCard('upgraded');
summaryWrap.style.display = 'none';
otsStampState = { valid: null, label: '', detail: '' };
otsUpgradedState = { valid: null, label: '', detail: '' };
// Display full proof carrier JSON
// Display full proof carrier JSON at the top of the page
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
@@ -231,9 +336,10 @@
}
// 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).
// 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]) {
@@ -248,17 +354,27 @@
: `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
otsUpgradeWrap.style.display = 'block';
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';
// 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
@@ -272,36 +388,56 @@
: result.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: result.trustMode || 'unknown';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
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 {
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}`);
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 => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
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 {
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';
// 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) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
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 {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
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';
@@ -316,6 +452,7 @@
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setButtonSpinner(queryBtn);
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
@@ -325,6 +462,7 @@
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -332,6 +470,7 @@
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -367,6 +506,7 @@
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;
}
@@ -391,6 +531,7 @@
if (!kind1Event) {
setStatus(queryStatus, 'error', 'Could not parse kind 1 announcement from any candidate.');
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
return;
}
@@ -421,6 +562,7 @@
}
queryBtn.disabled = false;
clearButtonSpinner(queryBtn);
});
/* ================================================================
@@ -489,22 +631,6 @@
});
}
/* ================================================================
DOWNLOAD EVENT JSON
================================================================ */
downloadEventBtn.addEventListener('click', () => {
if (!currentEvent) return;
const blob = new Blob([JSON.stringify(currentEvent, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (currentEvent.id ? currentEvent.id.substring(0, 16) : 'event') + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
@@ -533,10 +659,20 @@
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. Proof size: ${currentOtsBytes.length} bytes.`);
// 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}`);
@@ -608,20 +744,168 @@
});
/* ================================================================
AUTO-DETECT SIGNED-IN USER
On page load, check for a NIP-07 signer / nostr-login-lite. If the
user is already signed in, pre-fill the pubkey field and auto-query
relays for their kind 9999 event.
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() {
// Initialize nostr-login-lite if available (shares session with main page)
// 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 {
if (!window.NOSTR_LOGIN_LITE._initialized) {
await window.NOSTR_LOGIN_LITE.init({
methods: { extension: true, local: true, nip46: true, seedphrase: true, nsigner: true }
});
}
await initNostrLoginLite();
} catch (e) {
console.log('[verify] nostr-login-lite init failed:', e.message);
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.31",
"VERSION_NUMBER": "0.0.31",
"BUILD_DATE": "2026-07-26T15:44:07.425Z"
"VERSION": "v0.1.1",
"VERSION_NUMBER": "0.1.1",
"BUILD_DATE": "2026-07-29T13:00:25.910Z"
}
+2 -1
View File
@@ -9929,7 +9929,8 @@ Post-quantum public keys in tags:
Each post-quantum key has cryptographically signed this attestation. This event is pending timestamp on the Bitcoin blockchain via OpenTimestamps.
Verify this attestation: https://laantungir.net/quantum-prep/verify.html
Verify this attestation: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
Created at: https://laantungir.net/quantum-prep/`;
const statementBytes = new TextEncoder().encode(content);
const mlDsa44Sig = signWithMLDSA44(statementBytes, pqKeys.mlDsa44.secretKey);
+82 -57
View File
@@ -69,6 +69,22 @@
.pq-button:hover { opacity: 0.7; }
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
/* Braille spinner shown inside a button while its action is running.
Frames are advanced by JS (setButtonSpinner); this class just styles
the inline spinner glyph. */
.pq-btn-spinner {
display: inline-block;
color: var(--accent-color);
font-size: 16px;
line-height: 1;
margin-right: 6px;
vertical-align: middle;
}
/* Keep the spinner fully visible even while the button is dimmed
(disabled) during a running action. */
.pq-button:disabled .pq-btn-spinner { opacity: 1; }
.pq-button-row {
display: flex;
gap: 10px;
@@ -164,6 +180,31 @@
border-left: 4px solid #cc0000;
}
.pq-result-pending {
border-left: 4px solid #f0ad4e;
}
/* Multi-line OTS / summary cards wrap their detail text. */
.pq-result-item.pq-result-detail {
display: block;
line-height: 1.6;
white-space: normal;
}
.pq-result-detail .pq-result-head {
font-weight: bold;
margin-bottom: 4px;
}
.pq-result-detail .pq-result-body {
font-size: 13px;
font-weight: normal;
}
.pq-summary-card {
font-size: 16px;
font-weight: bold;
padding: 15px 18px;
}
.pq-event-preview {
background: var(--secondary-color);
color: var(--primary-color);
@@ -211,30 +252,6 @@
.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>
@@ -254,35 +271,19 @@
<div class="pq-card">
<div class="pq-card-title">Verify Post-Quantum Key Link</div>
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Note:</strong> Passing these checks confirms the keys are linked and the signatures
are valid — it does not mean the identity is post-quantum secure. The Bitcoin timestamp check
relies on a public block explorer rather than running a full Bitcoin node, so a compromised
explorer could fool it.
</div>
<div class="pq-info-text">
You can check a post-quantum key-link event two ways: look it up on a relay by pubkey, or
paste the event JSON directly. The event you're checking (kind 9999) wraps a public
announcement (kind 1) and carries a Bitcoin timestamp proof. Verification checks each piece
separately and reports the results below: both Nostr signatures, the link between the two
events, each post-quantum signature (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512 —
ML-KEM-768 is for encryption, so it has no signature to check), that all required algorithms
are present, that the event really belongs to the claimed identity, and the Bitcoin timestamp.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to preparation 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 class="pq-tab" id="pqTabSignIn">Sign In</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 9999 event from that pubkey and verify it.
relay(s) for the latest kind 9999 event from that pubkey and verify the signatures are
valid, and whether it has been submitted to Open Time Stamps.
</div>
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
@@ -319,33 +320,57 @@
<input type="file" id="pqArchiveFileInput" accept="application/json,.json" style="margin-top: 6px; font-size: 13px;" />
</div>
<!-- Shared results area -->
<!-- TAB 3: SIGN IN -->
<div class="pq-tab-panel" id="pqPanelSignIn">
<div class="pq-info-text">
Sign in with your Nostr identity (NIP-07 extension or nostr-login-lite) to look up your
own kind 9999 event automatically.
</div>
<div id="pqSignInError"></div>
<button class="pq-button" id="pqSignInBtn">Sign In</button>
<div id="pqSignInStatus"></div>
</div>
<!-- Full event JSON (shown at the top once an event is loaded) -->
<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>
<!-- Shared results area: signature + PQ checks + OTS cards, all in
one continuous card list. The OTS stamp/upgraded cards are
appended into pqResultList by JS so the flow is unbroken. -->
<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="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 class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqDownloadEventBtn">Download Event JSON</button>
</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>
<!-- OTS upgrade actions (cards live in pqResultList above) -->
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 10px;">
<div class="pq-button-row">
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Publish Upgraded Event</button>
</div>
<div id="pqOtsUpgradeStatus"></div>
</div>
<!-- Summary card (shown once verification completes) -->
<div id="pqSummaryWrap" style="display: none; margin-top: 15px;">
<div class="pq-result-item pq-summary-card" id="pqSummaryCard"></div>
</div>
<hr style="border: var(--border); margin: 25px 0 15px;" />
<div class="pq-info-text" style="color: var(--accent-color); font-size: 13px;">
<strong>Note:</strong> Passing every check does not mean that your current nostr identity
is now post-quantum secure. This tool is the preparation step. Full quantum safety for
day-to-day Nostr use still requires companion protocols that are not yet built.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to preparation page</a>
</div>
</div>
</div>