Files
nostr_quantum_preparation/www/js/index-app.mjs
T

1293 lines
58 KiB
JavaScript

import {
generateSeedPhrase,
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
buildKind1Announcement,
buildProofCarrier,
computeEventId,
hashFullEvent,
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
bytesToBase64,
base64ToBytes,
bytesToHex,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
selectCanonicalProofCarrier,
timestampEvent,
upgradeOts,
isOtsConfirmed,
verifyOtsProof,
savePendingOts,
loadPendingOts,
clearPendingOts,
isDetachedOtsFile
} from './pq-crypto.bundle.js';
/* ================================================================
GLOBAL STATE
================================================================ */
let currentPubkey = null;
let currentBlockHeight = 0;
let pqMnemonic = null;
let pqSeed = null;
let pqKeys = null;
let pqSecpKeys = null;
let pqEvent = null; // kind 9999 proof carrier event
let kind1Event = null; // kind 1 announcement event
/* ================================================================
DOM REFERENCES
================================================================ */
const pqNotAuthed = document.getElementById('pqNotAuthed');
const pqAuthed = document.getElementById('pqAuthed');
const pqNpub = document.getElementById('pqNpub');
const pqSeedStep = document.getElementById('pqSeedStep');
const pqSeedDisplay = document.getElementById('pqSeedDisplay');
const pqSeedConfirmed = document.getElementById('pqSeedConfirmed');
const pqSeedContinueBtn = document.getElementById('pqSeedContinueBtn');
const pqDeriveStep = document.getElementById('pqDeriveStep');
const pqDeriveProgress = document.getElementById('pqDeriveProgress');
const pqDeriveStatus = document.getElementById('pqDeriveStatus');
const pqDeriveContinueBtn = document.getElementById('pqDeriveContinueBtn');
const pqSignStep = document.getElementById('pqSignStep');
const pqSignStatus = document.getElementById('pqSignStatus');
const pqSignBtn = document.getElementById('pqSignBtn');
const pqEventPreviewWrap = document.getElementById('pqEventPreviewWrap');
const pqEventPreview = document.getElementById('pqEventPreview');
const pqPublishStep = document.getElementById('pqPublishStep');
const pqEventIdDisplay = document.getElementById('pqEventIdDisplay');
const noSignerWarning = document.getElementById('noSignerWarning');
const pqSignInError = document.getElementById('pqSignInError');
/* ================================================================
CHECKLIST MANAGEMENT
================================================================ */
const stepElements = {
1: document.getElementById('step-1'),
2: document.getElementById('step-2'),
3: document.getElementById('step-3'),
4: document.getElementById('step-4'),
5: document.getElementById('step-5'),
6: document.getElementById('step-6'),
7: document.getElementById('step-7')
};
function setStepDone(stepNum) {
const el = stepElements[stepNum];
if (el) {
el.classList.add('pq-done');
el.classList.remove('pq-active');
el.querySelector('.pq-checklist-box').textContent = '';
}
}
function setStepActive(stepNum) {
const el = stepElements[stepNum];
if (el) {
el.classList.add('pq-active');
el.classList.remove('pq-done');
}
}
function setStepPending(stepNum) {
const el = stepElements[stepNum];
if (el) {
el.classList.remove('pq-active', 'pq-done');
el.querySelector('.pq-checklist-box').textContent = '';
}
}
/* ================================================================
VIEW MANAGEMENT
================================================================ */
const pqOtsStep = document.getElementById('pqOtsStep');
function showView(elementId) {
[pqNotAuthed, pqAuthed, pqSeedStep, pqDeriveStep, pqSignStep, pqPublishStep, pqOtsStep].forEach(el => {
el.classList.add('pq-hidden');
});
const el = document.getElementById(elementId);
if (el) el.classList.remove('pq-hidden');
}
async function showAuthedView() {
pqNpub.textContent = currentPubkey;
showView('pqAuthed');
setStepDone(1);
setStepActive(2);
// Show sign out button
document.getElementById('pqSignOutBtn').classList.remove('pq-hidden');
const blockHeightEl = document.getElementById('pqBlockHeight');
blockHeightEl.textContent = 'Loading...';
const height = await fetchBlockHeight();
blockHeightEl.textContent = height > 0 ? String(height) : 'Unavailable';
// Offer a persisted OTS workflow only to the same signed-in identity.
const pendingOts = loadPendingOts();
document.querySelectorAll('.pq-ots-resume-notice').forEach(el => el.remove());
if (pendingOts && pendingOts.ownerPubkey && pendingOts.ownerPubkey === currentPubkey) {
console.log('[ots] Found saved OTS workflow from previous session, event:', pendingOts.eventId);
pendingOtsBytes = pendingOts.ots;
// G56-12: restore the complete persisted event if available, not just the ID
if (pendingOts.proofCarrierEventJson) {
try {
pqEvent = JSON.parse(pendingOts.proofCarrierEventJson);
} catch (e) {
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || NIP_QR_KIND };
}
} else {
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || NIP_QR_KIND };
}
if (pendingOts.kind1EventJson) {
try { kind1Event = JSON.parse(pendingOts.kind1EventJson); } catch (e) { /* ignore */ }
}
if (pendingOts.relayUrls) setRelayUrls(pendingOts.relayUrls);
// F-H3: Build the OTS resume notice with safe DOM construction
const otsNotice = document.createElement('div');
otsNotice.className = 'pq-status pq-status-info pq-ots-resume-notice';
otsNotice.style.marginTop = '15px';
const stateLabel = pendingOts.confirmedPublished ? 'completed' : (pendingOts.pendingPublished ? 'pending Bitcoin confirmation' : 'not yet published');
otsNotice.textContent = `You have an OpenTimestamps workflow from a previous session (${stateLabel}; event: ${pendingOts.eventId.substring(0, 16)}...). `;
const resumeLink = document.createElement('a');
resumeLink.href = '#';
resumeLink.id = 'pqResumeOtsLink';
resumeLink.style.color = 'var(--accent-color)';
resumeLink.style.cursor = 'pointer';
resumeLink.textContent = 'View / resume timestamping';
otsNotice.appendChild(resumeLink);
pqAuthed.appendChild(otsNotice);
document.getElementById('pqResumeOtsLink').addEventListener('click', async (e) => {
e.preventDefault();
for (let i = 1; i <= 5; i++) setStepDone(i);
setStepActive(6);
// G56-02: Fetch ALL proof carrier events from relays and select
// the canonical one (earliest valid Bitcoin anchor)
if (!pqEvent.tags || !pqEvent.content) {
otsLog('Resuming OTS workflow: fetching proof carrier events from relays...');
const relayUrls = getRelayUrls();
let allCandidates = [];
await Promise.all(relayUrls.map(async (url) => {
try {
const events = await queryRelayForProofCarriers(currentPubkey, url);
if (events && events.length > 0) allCandidates.push(...events);
} catch (e) { /* ignore */ }
}));
// 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) {
// G56-12: Try to find the one matching our persisted event ID
const matching = allCandidates.find(e => e.id === pqEvent.id);
if (matching) {
// G56-12: validate the fetched event's signature before adopting
if (verifyNostrEvent(matching)) {
pqEvent = matching;
otsLog(`Resuming OTS workflow: fetched and verified event ${pqEvent.id.substring(0, 16)}... from relays (${allCandidates.length} total candidates).`);
} else {
otsLog(`WARNING: Fetched event ${matching.id.substring(0, 16)}... has invalid signature. Using persisted event data instead.`);
}
} else {
// G56-12: Don't blindly adopt an unknown event — only use a candidate
// if its signature is valid AND it belongs to the current user
const validCandidate = allCandidates.find(e =>
e.pubkey === currentPubkey && verifyNostrEvent(e)
);
if (validCandidate) {
pqEvent = validCandidate;
otsLog(`Resuming OTS workflow: persisted event not found, using verified candidate ${pqEvent.id.substring(0, 16)}... (${allCandidates.length} total candidates).`);
} else {
otsLog(`WARNING: Persisted event not found and no valid signed candidate found among ${allCandidates.length} candidates. Using persisted event data.`);
}
}
} else {
otsLog('WARNING: Could not fetch any proof carrier events from relays. Republishing may fail.');
}
}
await startOtsFlow();
});
}
}
// 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 setKeyIcon(elementId, icon) {
const el = document.getElementById(elementId);
if (!el) return;
// For checklist items (Step 3, Step 6), toggle the pq-done class to
// show a solid red box — no text inside the box, matching the
// Migration Steps checklist style.
if (el.classList.contains('pq-checklist-item')) {
const box = el.querySelector('.pq-checklist-box');
if (box) box.textContent = '';
if (icon) {
el.classList.add('pq-done');
el.classList.remove('pq-active');
} else {
el.classList.remove('pq-done');
}
return;
}
// Legacy .pq-key-icon elements (no longer used but kept for safety)
const iconEl = el.querySelector('.pq-key-icon');
if (iconEl) iconEl.textContent = icon;
}
/* ================================================================
BLOCK HEIGHT
================================================================ */
async function fetchBlockHeight() {
try {
const response = await fetch('https://mempool.space/api/blocks/tip/height');
const height = await response.text();
currentBlockHeight = parseInt(height, 10);
return currentBlockHeight;
} catch (e) {
console.warn('[post-quantum] Could not fetch block height:', e.message);
return 0;
}
}
/* ================================================================
AUTHENTICATION
================================================================ */
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;
}
async function signIn() {
if (window.NOSTR_LOGIN_LITE) {
await initNostrLoginLite();
if (window.nostr) {
try {
currentPubkey = await window.nostr.getPublicKey();
if (currentPubkey) {
console.log('[post-quantum] Authenticated (restored) as:', currentPubkey);
await showAuthedView();
return;
}
} catch (e) {
console.log('[post-quantum] Not yet authenticated, launching login modal...');
}
}
currentPubkey = 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('[post-quantum] getPublicKey not ready:', e.message); }
}
}
function authHandler(e) {
console.log('[post-quantum] Auth event:', e.type, e.detail ? JSON.stringify(e.detail).substring(0, 100) : '');
// nlMethodSelected includes the pubkey directly in the detail
if (e.type === 'nlMethodSelected' && e.detail && e.detail.pubkey) {
if (!settled) { settled = true; cleanup(); resolve(e.detail.pubkey); }
return;
}
// For other events, try getPublicKey with delays
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);
// launch() may return undefined (not a Promise) after sign out
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); } });
} else {
// launch() returned undefined — just wait for events
console.log('[post-quantum] launch() returned non-Promise, waiting for auth events...');
}
} catch (e) {
if (!settled) { settled = true; cleanup(); reject(e); }
}
});
console.log('[post-quantum] Authenticated as:', currentPubkey);
await showAuthedView();
return;
}
if (!window.nostr) {
throw new Error('No Nostr signer found. Install a NIP-07 browser extension (nos2x, Alby) or use nostr-login-lite.');
}
currentPubkey = await window.nostr.getPublicKey();
console.log('[post-quantum] Authenticated (extension) as:', currentPubkey);
await showAuthedView();
}
/* ================================================================
STEP 2: SEED PHRASE
================================================================ */
let userEntropyChunks = [];
let entropyCollecting = false;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
function generateAndShowSeed() {
const entropyBits = getSelectedEntropyBits();
// Use user entropy if available, otherwise pure CSPRNG
if (userEntropyChunks.length > 0) {
const userEntropy = new TextEncoder().encode(userEntropyChunks.join(''));
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input (${entropyBits}-bit).`);
} else {
pqMnemonic = generateSeedPhrase(entropyBits);
otsLog(`Seed generated (${entropyBits}-bit).`);
}
const words = pqMnemonic.split(' ');
pqSeedDisplay.innerHTML = words.map((word, i) =>
`<div class="pq-seed-word"><span class="pq-seed-number">${i + 1}.</span>${word}</div>`
).join('');
pqSeedConfirmed.checked = false;
pqSeedContinueBtn.disabled = true;
}
// Entropy collection from mouse and keyboard (optional)
function startEntropyCollection() {
const entropyBox = document.getElementById('pqEntropyBox');
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
const maxChunks = 64; // collect up to 64 events
let collected = 0;
function updateBar() {
const pct = Math.min(100, Math.round((collected / maxChunks) * 100));
entropyBar.style.width = pct + '%';
if (collected >= maxChunks) {
entropyHint.textContent = 'Enough entropy collected. Click "Generate with Extra Entropy" to use it.';
} else if (collected > 0) {
entropyHint.textContent = `Collected ${collected}/${maxChunks} entropy samples. Keep going, or click "Generate with Extra Entropy" to use what you have.`;
}
// Show the "Generate with Extra Entropy" button once we have at least 1 chunk
if (collected > 0 && regenWithEntropyBtn) {
regenWithEntropyBtn.classList.remove('pq-hidden');
regenWithEntropyBtn.disabled = false;
}
}
function collectEvent(data) {
if (collected >= maxChunks) return;
userEntropyChunks.push(data + ':' + Date.now() + ':' + Math.random());
collected++;
updateBar();
}
entropyBox.addEventListener('mousemove', (e) => {
collectEvent(`m${e.clientX},${e.clientY}`);
});
entropyBox.addEventListener('keypress', (e) => {
collectEvent(`k${e.key}:${e.keyCode}`);
});
entropyBox.addEventListener('keydown', (e) => {
// Also capture non-printable keys
if (e.key.length > 1) collectEvent(`k${e.key}:${e.keyCode}`);
});
}
// Seed mode toggle
function showSeedGeneratePanel() {
document.getElementById('pqSeedGeneratePanel').style.display = 'block';
document.getElementById('pqSeedOwnPanel').style.display = 'none';
document.getElementById('pqSeedToggleGenerate').classList.add('pq-seed-toggle-active');
document.getElementById('pqSeedToggleOwn').classList.remove('pq-seed-toggle-active');
// Generate a fresh seed when switching to the generate panel
userEntropyChunks = [];
generateAndShowSeed();
// Reset the entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
}
function showSeedOwnPanel() {
document.getElementById('pqSeedGeneratePanel').style.display = 'none';
document.getElementById('pqSeedOwnPanel').style.display = 'block';
document.getElementById('pqSeedToggleGenerate').classList.remove('pq-seed-toggle-active');
document.getElementById('pqSeedToggleOwn').classList.add('pq-seed-toggle-active');
}
/* ================================================================
STEP 3: DERIVE PQ KEYS
================================================================ */
async function derivePQKeys() {
showView('pqDeriveStep');
setStepActive(3);
pqDeriveProgress.style.width = '0%';
pqDeriveContinueBtn.classList.add('pq-hidden');
try {
pqSeed = mnemonicToSeed(pqMnemonic);
pqDeriveProgress.style.width = '15%';
pqSecpKeys = deriveSecp256k1FromSeed(pqSeed);
pqDeriveProgress.style.width = '30%';
await new Promise(r => setTimeout(r, 100));
pqKeys = derivePQKeysFromSeed(pqSeed);
pqDeriveProgress.style.width = '60%';
setKeyIcon('pqKeyMlDsa44', 'Done');
document.getElementById('pqKeyMlDsa44Pub').textContent = bytesToBase64(pqKeys.mlDsa44.publicKey).substring(0, 60) + '...';
await new Promise(r => setTimeout(r, 100));
setKeyIcon('pqKeyMlDsa65', 'Done');
document.getElementById('pqKeyMlDsa65Pub').textContent = bytesToBase64(pqKeys.mlDsa65.publicKey).substring(0, 60) + '...';
await new Promise(r => setTimeout(r, 100));
setKeyIcon('pqKeySlhDsa', 'Done');
document.getElementById('pqKeySlhDsaPub').textContent = bytesToBase64(pqKeys.slhDsa.publicKey).substring(0, 60) + '...';
await new Promise(r => setTimeout(r, 100));
setKeyIcon('pqKeyFalcon', 'Done');
document.getElementById('pqKeyFalconPub').textContent = bytesToBase64(pqKeys.falcon512.publicKey).substring(0, 60) + '...';
await new Promise(r => setTimeout(r, 100));
setKeyIcon('pqKeyMlKem', 'Done');
document.getElementById('pqKeyMlKemPub').textContent = bytesToBase64(pqKeys.mlKem.publicKey).substring(0, 60) + '...';
pqDeriveProgress.style.width = '100%';
setStatus(pqDeriveStatus, 'success', 'All 5 post-quantum keys derived successfully!');
pqDeriveContinueBtn.classList.remove('pq-hidden');
setStepDone(3);
} catch (error) {
console.error('[post-quantum] Key derivation failed:', error);
setStatus(pqDeriveStatus, 'error', `Key derivation failed: ${error.message}`);
}
}
/* ================================================================
STEP 4: SIGN EVENT
================================================================ */
async function signEvent() {
pqSignBtn.disabled = true;
setStepActive(4);
setStatus(pqSignStatus, 'info', ' Refreshing Bitcoin block height...');
try {
const blockHeight = await fetchBlockHeight();
console.log('[post-quantum] Block height for statement:', blockHeight);
const blockHeightEl = document.getElementById('pqBlockHeight');
if (blockHeightEl && blockHeight > 0) blockHeightEl.textContent = String(blockHeight);
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer not available (window.nostr.signEvent missing)');
}
// ---- Phase 1: Build and sign the kind 1 announcement event ----
setStatus(pqSignStatus, 'info', ' Building kind 1 announcement event...');
const kind1Template = buildKind1Announcement(currentPubkey, blockHeight, pqKeys);
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
const kind1Signed = await window.nostr.signEvent(kind1Template);
// G56-05: validate signer output — never reconstruct from mixed fields
kind1Event = validateSignerOutput(kind1Signed, kind1Template, currentPubkey);
otsLog(`Kind 1 announcement signed. Event ID: ${kind1Event.id.substring(0, 32)}...`);
// ---- Phase 2: Timestamp the full kind 1 event hash ----
const fullHash = hashFullEvent(kind1Event);
otsLog(`SHA-256 of full kind 1 event: ${fullHash.substring(0, 32)}...`);
let otsProof = null;
try {
setStatus(pqSignStatus, 'info', ' Submitting kind 1 event hash to OpenTimestamps...');
otsProof = await timestampEvent(fullHash);
otsLog(`Pending OTS proof created (${otsProof.length} bytes) for kind 1 event hash.`);
} catch (otsError) {
console.warn('[post-quantum] OTS submission failed, continuing without proof:', otsError.message);
otsLog(`WARNING: OTS submission failed: ${otsError.message}. Kind 9999 event will not have OTS proof yet.`);
}
// ---- Phase 3: Build and sign the proof carrier event (kind 9999) ----
setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for proof carrier...');
let proofCarrierTemplate;
if (otsProof && otsProof.length > 0) {
proofCarrierTemplate = buildProofCarrier(kind1Event, otsProof, { otsStatus: 'pending' });
} else {
// No OTS proof — build carrier without ots/ots_status tags (will be added later)
proofCarrierTemplate = buildProofCarrier(kind1Event, new Uint8Array(0), { otsStatus: 'pending' });
// Remove the empty ots tag
proofCarrierTemplate.tags = proofCarrierTemplate.tags.filter(t => t[0] !== 'ots');
}
const proofCarrierSigned = await window.nostr.signEvent(proofCarrierTemplate);
// G56-05: validate signer output — never reconstruct from mixed fields
pqEvent = validateSignerOutput(proofCarrierSigned, proofCarrierTemplate, currentPubkey);
otsLog(`Proof carrier signed. Event ID: ${pqEvent.id.substring(0, 32)}...`);
// Save the pending OTS proof for the OTS step
if (otsProof && otsProof.length > 0) {
pendingOtsBytes = otsProof;
}
const eventJsonStr = JSON.stringify(pqEvent, null, 2);
pqEventPreview.textContent = eventJsonStr;
pqEventPreviewWrap.classList.remove('pq-hidden');
setStatus(pqSignStatus, 'success', 'Both events signed successfully!');
setStepDone(4);
setStepActive(5);
// Show publish step
document.getElementById('pqKind1IdDisplay').textContent = `Event ID: ${kind1Event.id}`;
document.getElementById('pqKind1Preview').textContent = JSON.stringify(kind1Event, null, 2);
pqEventIdDisplay.textContent = `Event ID: ${pqEvent.id}`;
document.getElementById('pqSuccessEventPreview').textContent = eventJsonStr;
setTimeout(() => { renderRelayList(); showView('pqPublishStep'); }, 1000);
} catch (error) {
console.error('[post-quantum] Sign failed:', error);
setStatus(pqSignStatus, 'error', `Failed: ${error.message}`);
pqSignBtn.disabled = false;
}
}
/* ================================================================
STEP 5: PUBLISH TO RELAYS
================================================================ */
function publishToRelays(event, relayUrls) {
const eventJson = JSON.stringify(['EVENT', event]);
return Promise.all(relayUrls.map(relayUrl => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) { resolved = true; try { ws.close(); } catch (e) {} resolve({ relay: relayUrl, 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] === event.id) {
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve({ relay: relayUrl, success: true }); }
} else if (data[0] === 'NOTICE') {
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve({ relay: relayUrl, success: false, error: data[1] }); }
}
} catch (e) {}
};
ws.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve({ relay: relayUrl, success: false, error: 'connection error' }); } };
ws.onclose = () => { if (!resolved) { resolved = true; clearTimeout(timeout); resolve({ relay: relayUrl, success: false, error: 'closed without OK' }); } };
} catch (e) { resolve({ relay: relayUrl, success: false, error: e.message }); }
});
}));
}
/* ================================================================
RELAY QUERY (fetch ALL proof carrier events for OTS resume)
G56-02: fetch all candidates, not limit: 1
================================================================ */
function queryRelayForProofCarriers(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 = () => {
const subId = 'pq_resume_' + 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); }
});
}
/* ================================================================
RELAY LIST MANAGEMENT
================================================================ */
const DEFAULT_RELAYS = [
'wss://laantungir.net/relay',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net'
];
let pqRelays = DEFAULT_RELAYS.slice();
// Per-relay publish status: { url: { kind1: bool, kind9999: bool } }
const pqRelayStatus = {};
function makeStatusCheckbox(label) {
const wrap = document.createElement('label');
wrap.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted-color); cursor: default; flex: 0 0 auto;';
const box = document.createElement('span');
box.className = 'pq-checklist-box';
box.textContent = '';
box.style.cssText = 'width: 16px; height: 16px; border: 2px solid var(--muted-color); border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; font-weight: bold;';
const lbl = document.createElement('span');
lbl.textContent = label;
wrap.appendChild(box);
wrap.appendChild(lbl);
return { wrap, box };
}
function setRelayStatus(url, kind, ok) {
if (!pqRelayStatus[url]) pqRelayStatus[url] = { kind1: false, kind9999: false };
pqRelayStatus[url][kind] = ok;
const row = document.querySelector(`.pq-relay-row[data-relay="${CSS.escape(url)}"]`);
if (!row) return;
const box = row.querySelector(`.pq-relay-status-${kind}`);
if (box) {
box.textContent = '';
box.style.background = ok ? 'var(--accent-color)' : 'transparent';
box.style.borderColor = ok ? 'var(--accent-color)' : 'var(--muted-color)';
}
}
function renderRelayList() {
const listEl = document.getElementById('pqRelayList');
if (!listEl) return;
listEl.innerHTML = '';
pqRelays.forEach((url, idx) => {
const row = document.createElement('div');
row.className = 'pq-relay-row';
row.dataset.relay = url;
row.style.cssText = 'display: flex; align-items: center; gap: 10px; background: var(--secondary-color); border: var(--border); border-radius: var(--border-radius); padding: 10px 14px;';
const urlSpan = document.createElement('span');
urlSpan.textContent = url;
urlSpan.style.cssText = 'flex: 1; font-size: 14px; color: var(--primary-color); word-break: break-all;';
row.appendChild(urlSpan);
const k1 = makeStatusCheckbox('k1');
k1.box.classList.add('pq-relay-status-kind1');
row.appendChild(k1.wrap);
const k9999 = makeStatusCheckbox('k9999');
k9999.box.classList.add('pq-relay-status-kind9999');
row.appendChild(k9999.wrap);
const removeBtn = document.createElement('button');
removeBtn.className = 'pq-button pq-button-secondary';
removeBtn.textContent = 'Remove';
removeBtn.style.cssText = 'flex: 0 0 auto; width: auto; padding: 8px 16px; font-size: 13px;';
removeBtn.addEventListener('click', () => {
pqRelays.splice(idx, 1);
delete pqRelayStatus[url];
renderRelayList();
});
row.appendChild(removeBtn);
listEl.appendChild(row);
// Restore any existing status
const st = pqRelayStatus[url];
if (st) {
if (st.kind1) setRelayStatus(url, 'kind1', true);
if (st.kind9999) setRelayStatus(url, 'kind9999', true);
}
});
}
function getRelayUrls() {
return pqRelays.slice();
}
function setRelayUrls(urls) {
// Accept either a comma-separated string or an array
if (typeof urls === 'string') {
pqRelays = urls.split(',').map(s => s.trim()).filter(Boolean);
} else if (Array.isArray(urls)) {
pqRelays = urls.slice();
}
if (pqRelays.length === 0) pqRelays = DEFAULT_RELAYS.slice();
renderRelayList();
}
function getRelayUrlsString() {
return pqRelays.join(',');
}
/* ================================================================
EVENT LISTENERS
================================================================ */
document.getElementById('pqSignInBtn').addEventListener('click', async () => {
pqSignInError.innerHTML = '';
try { await signIn(); }
catch (error) { console.error('[post-quantum] Sign-in failed:', error); setStatus(pqSignInError, 'error', error.message); }
});
document.getElementById('pqStartBtn').addEventListener('click', () => {
userEntropyChunks = [];
generateAndShowSeed();
showView('pqSeedStep');
setStepActive(2);
startEntropyCollection();
});
// Seed mode toggle
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: regenerate when the user switches between 12/24 words
document.querySelectorAll('input[name="pqWordCount"]').forEach((radio) => {
radio.addEventListener('change', () => {
userEntropyChunks = [];
generateAndShowSeed();
// Reset entropy UI
const entropyBar = document.getElementById('pqEntropyBar');
const entropyHint = document.getElementById('pqEntropyHint');
const regenWithEntropyBtn = document.getElementById('pqRegenerateWithEntropyBtn');
if (entropyBar) entropyBar.style.width = '0%';
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
if (regenWithEntropyBtn) { regenWithEntropyBtn.classList.add('pq-hidden'); regenWithEntropyBtn.disabled = true; }
});
});
// Bring your own seed: real-time validation
const pqSeedInput = document.getElementById('pqSeedInput');
const pqSeedValidation = document.getElementById('pqSeedValidation');
const pqSeedOwnContinueBtn = document.getElementById('pqSeedOwnContinueBtn');
pqSeedInput.addEventListener('input', () => {
const value = pqSeedInput.value.trim();
if (!value) {
pqSeedValidation.innerHTML = '';
pqSeedOwnContinueBtn.disabled = true;
return;
}
if (isValidMnemonic(value)) {
const wordCount = value.split(/\s+/).length;
pqSeedValidation.textContent = `Valid ${wordCount}-word BIP39 seed phrase`;
pqSeedValidation.style.color = '#00aa00';
pqSeedOwnContinueBtn.disabled = false;
} else {
pqSeedValidation.textContent = 'Invalid seed phrase (checksum or word list mismatch)';
pqSeedValidation.style.color = '#cc0000';
pqSeedOwnContinueBtn.disabled = true;
}
});
pqSeedOwnContinueBtn.addEventListener('click', () => {
pqMnemonic = pqSeedInput.value.trim();
setStepDone(2);
derivePQKeys();
});
document.getElementById('pqCopySeedBtn').addEventListener('click', () => {
if (pqMnemonic) {
navigator.clipboard.writeText(pqMnemonic).then(() => {
const btn = document.getElementById('pqCopySeedBtn');
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
});
}
});
// "Generate with Extra Entropy" — generates a fresh seed mixing CSPRNG + user entropy
document.getElementById('pqRegenerateWithEntropyBtn').addEventListener('click', () => { generateAndShowSeed(); });
pqSeedConfirmed.addEventListener('change', () => { pqSeedContinueBtn.disabled = !pqSeedConfirmed.checked; });
pqSeedContinueBtn.addEventListener('click', () => { setStepDone(2); derivePQKeys(); });
pqDeriveContinueBtn.addEventListener('click', () => { showView('pqSignStep'); setStepActive(4); });
pqSignBtn.addEventListener('click', () => { signEvent(); });
document.getElementById('pqPublishBtn').addEventListener('click', async () => {
const publishBtn = document.getElementById('pqPublishBtn');
const publishStatus = document.getElementById('pqPublishStatus');
publishBtn.disabled = true;
setStepActive(5);
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 1 announcement to relays...</div>';
try {
const relayUrls = getRelayUrls();
// Publish kind 1 announcement first
const results1 = await publishToRelays(kind1Event, relayUrls);
results1.forEach(r => setRelayStatus(r.relay, 'kind1', r.success));
const success1 = results1.filter(r => r.success).length;
const fail1 = results1.filter(r => !r.success).length;
otsLog(`Kind 1 announcement published: ${success1} ok, ${fail1} failed.`);
// Publish kind 9999 proof carrier
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 9999 proof carrier to relays...</div>';
const results2 = await publishToRelays(pqEvent, relayUrls);
results2.forEach(r => setRelayStatus(r.relay, 'kind9999', r.success));
const success2 = results2.filter(r => r.success).length;
const fail2 = results2.filter(r => !r.success).length;
otsLog(`Kind 9999 proof carrier published: ${success2} ok, ${fail2} failed.`);
// G56-11: Require at least one relay to accept EACH event before marking complete
if (success1 === 0 || success2 === 0) {
const which = [];
if (success1 === 0) which.push('kind 1 announcement');
if (success2 === 0) which.push('kind 9999 proof carrier');
setStatus(publishStatus, 'error',
`Publication incomplete: no relay accepted the ${which.join(' and ')}. ` +
`Kind 1: ${success1}/${relayUrls.length}, Kind 9999: ${success2}/${relayUrls.length}. ` +
`Check relay URLs and try again.`);
publishBtn.disabled = false;
// Do NOT mark step done or show continue button
return;
}
// At least one relay accepted both events
if (success1 < relayUrls.length || success2 < relayUrls.length) {
setStatus(publishStatus, 'success',
`Published with partial relay coverage. Kind 1: ${success1}/${relayUrls.length} relays. Kind 9999: ${success2}/${relayUrls.length} relays. ` +
`Consider adding more relays for durability.`);
} else {
setStatus(publishStatus, 'success',
`Published! Kind 1: ${success1}/${relayUrls.length} relays. Kind 9999: ${success2}/${relayUrls.length} relays.`);
}
publishBtn.textContent = 'Published';
publishBtn.disabled = true;
setStepDone(5);
// Show a Continue button so the user can review the relay results before advancing.
const continueBtn = document.getElementById('pqOtsContinueBtn');
if (continueBtn) continueBtn.classList.remove('pq-hidden');
} catch (error) {
setStatus(publishStatus, 'error', `Publish failed: ${error.message}`);
publishBtn.disabled = false;
}
});
// Continue to OpenTimestamps step (shown after successful publish)
document.getElementById('pqOtsContinueBtn').addEventListener('click', () => { prepareOtsStep(); });
// Add Relay button
document.getElementById('pqRelayAddBtn').addEventListener('click', () => {
const input = document.getElementById('pqRelayAddInput');
const url = input.value.trim();
if (!url) return;
if (!/^wss?:\/\//.test(url)) {
setStatus(document.getElementById('pqPublishStatus'), 'error', 'Relay URL must start with wss:// or ws://');
return;
}
if (pqRelays.includes(url)) {
setStatus(document.getElementById('pqPublishStatus'), 'error', 'That relay is already in the list.');
return;
}
pqRelays.push(url);
input.value = '';
renderRelayList();
document.getElementById('pqPublishStatus').innerHTML = '';
});
// Allow pressing Enter in the add-relay input to add the relay
document.getElementById('pqRelayAddInput').addEventListener('keydown', (e) => {
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 () => {
console.log('[post-quantum] Signing out...');
// Stop OTS polling, but preserve its persisted workflow for the next login.
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
pendingOtsBytes = null;
const persistedOts = localStorage.getItem('pq-pending-ots');
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
try { await window.NOSTR_LOGIN_LITE.logout(); } catch (e) { console.warn('[post-quantum] NLL logout failed:', e); }
}
// Clear local state
currentPubkey = null;
pqMnemonic = null;
pqSeed = null;
pqKeys = null;
pqSecpKeys = null;
pqEvent = null;
kind1Event = null; // F-L1: clear all secret references
userEntropyChunks = [];
pendingOtsBytes = null;
currentBlockHeight = 0;
pqRelays = DEFAULT_RELAYS.slice();
// Clear auth/session storage, then restore only the OTS workflow record.
try {
localStorage.clear();
if (persistedOts) localStorage.setItem('pq-pending-ots', persistedOts);
} catch (e) {}
try { sessionStorage.clear(); } catch (e) {}
// Hide sign out button
pqSignOutBtn.classList.add('pq-hidden');
// Reset checklist
for (let i = 1; i <= 7; i++) setStepPending(i);
setStepActive(1);
// Show sign-in view
showView('pqNotAuthed');
console.log('[post-quantum] Signed out');
});
/* ================================================================
STEP 6: OPENTIMESTAMPS
================================================================ */
let otsPollInterval = null;
let pendingOtsBytes = null;
function otsLog(message) {
const logEl = document.getElementById('pqOtsLog');
if (!logEl) return;
const time = new Date().toLocaleTimeString();
const line = document.createElement('div');
line.textContent = `[${time}] ${message}`;
line.style.borderBottom = '1px solid var(--muted-color)';
line.style.padding = '2px 0';
logEl.appendChild(line);
// Auto-scroll to bottom
logEl.scrollTop = logEl.scrollHeight;
}
function prepareOtsStep() {
// The OTS submission already happened in step 4. Auto-start the polling flow.
startOtsFlow();
}
async function startOtsFlow() {
showView('pqOtsStep');
setStepActive(6);
const otsStatus = document.getElementById('pqOtsStatus');
const otsLogEl = document.getElementById('pqOtsLog');
if (otsLogEl) otsLogEl.innerHTML = '';
setKeyIcon('pqOtsConfirm', '');
setKeyIcon('pqOtsConfirmedPublish', '');
// Check for a saved workflow from a previous session (same event ID)
let existing = loadPendingOts();
if (existing && !isDetachedOtsFile(existing.ots)) {
otsLog('Discarding stale proof data created by an older implementation (not a complete detached .ots file).');
clearPendingOts();
existing = null;
}
if (existing && existing.eventId === pqEvent.id) {
pendingOtsBytes = existing.ots;
otsLog(`Loaded saved OTS workflow for event ${pqEvent.id.substring(0, 16)}...`);
if (existing.pendingPublished) setKeyIcon('pqOtsPendingPublish', 'Done');
if (existing.confirmedPublished) {
setKeyIcon('pqOtsConfirm', 'Done');
setKeyIcon('pqOtsConfirmedPublish', 'Done');
setStepDone(6);
setStepActive(7);
otsStatus.innerHTML = '<div class="pq-status pq-status-success">Confirmed timestamp proof was already published.</div>';
showOtsProof(pendingOtsBytes);
return;
}
showOtsProof(pendingOtsBytes);
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();
return;
}
// The pending proof should already be embedded in the event from step 4 (signEvent).
// Extract it from the event's 'ots' tag.
const otsTag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'ots');
if (otsTag && otsTag[1]) {
try {
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,
relayUrls: getRelayUrlsString(),
pendingPublished: true,
confirmedPublished: false,
proofCarrierEvent: pqEvent,
kind1Event
});
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 (polling every 60 seconds)...</div>';
startOtsPolling();
return;
}
} catch (e) {
otsLog(`WARNING: Could not parse OTS tag from event: ${e.message}`);
}
}
// No proof in the event — OTS submission failed during step 4.
// Submit the kind 1 event hash to OpenTimestamps now, then publish a new kind 9999 event with the proof.
const sha256Tag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'sha256');
if (!sha256Tag || !sha256Tag[1]) {
otsLog('ERROR: No sha256 tag found in event. Cannot timestamp.');
otsStatus.innerHTML = '<div class="pq-status pq-status-error">Event is missing the sha256 tag.</div>';
return;
}
const fullHash = sha256Tag[1];
otsLog(`Submitting kind 1 event hash (${fullHash.substring(0, 16)}...) to OpenTimestamps calendar...`);
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Submitting kind 1 event hash to OpenTimestamps...</div>';
try {
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);
setKeyIcon('pqOtsPendingPublish', 'Done');
savePendingOts(pqEvent.id, otsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
relayUrls: getRelayUrlsString(),
pendingPublished: true,
confirmedPublished: false,
proofCarrierEvent: pqEvent,
kind1Event
});
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof embedded. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
startOtsPolling();
} catch (error) {
console.error('[ots] Submission failed:', error);
otsLog(`ERROR: ${error.message}`);
setStatus(otsStatus, 'error', `OpenTimestamps workflow failed: ${error.message}`);
}
}
function showOtsProof(otsBytes) {
const proofWrap = document.getElementById('pqOtsProofWrap');
const proofEl = document.getElementById('pqOtsProof');
proofEl.textContent = bytesToBase64(otsBytes);
proofWrap.classList.remove('pq-hidden');
}
function startOtsPolling() {
const confirmDetail = document.getElementById('pqOtsConfirmDetail');
let pollCount = 0;
if (otsPollInterval) clearInterval(otsPollInterval);
async function poll() {
pollCount++;
otsLog(`Poll ${pollCount}: Sending current .ots proof to upgrade service...`);
confirmDetail.textContent = `Polling (attempt ${pollCount})...`;
try {
const result = await upgradeOts(pendingOtsBytes);
pendingOtsBytes = result.proof;
savePendingOts(pqEvent.id, pendingOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
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.
const sha256Tag = pqEvent.tags && pqEvent.tags.find(t => t[0] === 'sha256');
const verifyResult = await verifyOtsProof(pendingOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
if (verifyResult.verified) {
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'
: verifyResult.trustMode === 'single-explorer-checked'
? 'single-explorer-checked'
: verifyResult.trustMode || 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
confirmDetail.textContent = `Verified on Bitcoin (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);
}
} else if (isOtsConfirmed(pendingOtsBytes)) {
// Structural check found a Bitcoin attestation tag but full
// verification failed — log the errors for diagnosis.
const errs = verifyResult.errors.length > 0 ? ` Errors: ${verifyResult.errors.join('; ')}` : '';
otsLog(`Poll ${pollCount}: Bitcoin attestation tag found but verification incomplete.${errs} Will retry.`);
confirmDetail.textContent = `Attestation found, verifying (attempt ${pollCount}). Retrying in 60s...`;
} else {
// Still pending — the upgrade helper's stderr often contains
// "Failed! Timestamp not complete" which is normal pending output
// from ots-cli, not an actual error. Sanitize it for display.
const rawDetail = (result.detail || '').replace(/\s+/g, ' ').trim();
const friendlyDetail = rawDetail
.replace(/Failed!\s*Timestamp not complete\.?\s*/gi, '')
.replace(/Failed!/gi, 'Pending')
.replace(/Pending confirmation in Bitcoin blockchain/gi, 'Waiting for Bitcoin confirmation')
.trim();
const detail = friendlyDetail ? ` (${friendlyDetail.slice(0, 180)})` : '';
otsLog(`Poll ${pollCount}: Still pending${detail}. Next poll in 60 seconds.`);
confirmDetail.textContent = `Still pending (attempt ${pollCount}). Next poll in 60s...`;
}
} catch (error) {
otsLog(`Poll ${pollCount}: ERROR - ${error.message}. Retrying in 60 seconds.`);
confirmDetail.textContent = `Poll failed (attempt ${pollCount}). Retrying in 60s...`;
}
}
poll();
otsPollInterval = setInterval(poll, 60000);
}
async function publishUpgradedEvent(upgradedOtsBytes) {
const otsStatus = document.getElementById('pqOtsStatus');
try {
otsLog('Building upgraded proof carrier with confirmed OTS proof...');
const savedWorkflow = loadPendingOts();
const configuredRelays = getRelayUrlsString() || (savedWorkflow && savedWorkflow.relayUrls) || 'wss://laantungir.net/relay';
// Build the upgraded event: copy all tags from the original, replace the ots tag
const upgradedTemplate = buildUpgradedEvent(pqEvent, upgradedOtsBytes);
if (!window.nostr || !window.nostr.signEvent) throw new Error('Nostr signer not available');
otsLog('Requesting secp256k1 signature for upgraded proof carrier...');
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// G56-05: validate signer output before publishing
const validatedEvent = validateSignerOutput(signedEvent, upgradedTemplate, currentPubkey);
const relayUrls = configuredRelays.split(',').map(s => s.trim()).filter(Boolean);
const results = await publishToRelays(validatedEvent, relayUrls);
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
if (successCount === 0) throw new Error('No relay accepted the upgraded proof carrier');
setKeyIcon('pqOtsConfirmedPublish', 'Done');
otsLog(`Upgraded proof carrier published. Successful: ${successCount}; failed: ${failCount}.`);
// Update pqEvent to the new event so future operations use it
pqEvent = validatedEvent;
savePendingOts(pqEvent.id, upgradedOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: NIP_QR_KIND,
relayUrls: configuredRelays,
pendingPublished: true,
confirmedPublished: true,
proofCarrierEvent: pqEvent,
kind1Event
});
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
return validatedEvent;
} catch (error) {
otsLog(`ERROR publishing upgraded event: ${error.message}`);
setStatus(otsStatus, 'error', `Failed to publish upgraded event: ${error.message}`);
throw error;
}
}
// OTS event listeners
document.getElementById('pqOtsDownloadBtn').addEventListener('click', () => {
if (pendingOtsBytes) {
const blob = new Blob([pendingOtsBytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${pqEvent.id.substring(0, 16)}.ots`;
a.click();
URL.revokeObjectURL(url);
}
});
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log('[post-quantum] Starting...');
setStepActive(1);
if (window.NOSTR_LOGIN_LITE) {
try {
await initNostrLoginLite();
if (window.nostr) {
try {
currentPubkey = await window.nostr.getPublicKey();
if (currentPubkey) {
console.log('[post-quantum] Auto-logged in as:', currentPubkey);
await showAuthedView();
return;
}
} catch (e) { console.log('[post-quantum] Auth not restored:', e.message); }
}
} catch (e) { console.log('[post-quantum] nostr-login-lite init failed:', e.message); }
}
if (!window.NOSTR_LOGIN_LITE && !window.nostr) {
noSignerWarning.style.display = 'block';
document.getElementById('pqSignInBtn').disabled = true;
}
if (!window.NOSTR_LOGIN_LITE && window.nostr && window.nostr.getPublicKey) {
try {
currentPubkey = await window.nostr.getPublicKey();
if (currentPubkey) {
console.log('[post-quantum] Auto-logged in (extension) as:', currentPubkey);
await showAuthedView();
return;
}
} catch (e) { console.log('[post-quantum] Auto-login failed:', e.message); }
}
showView('pqNotAuthed');
console.log('[post-quantum] Initialization complete');
})();