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

1373 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
generateSeedPhrase,
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
buildKind1Announcement,
buildProofCarrier,
computeEventId,
hashFullEvent,
canonicalEventDigest,
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
bytesToBase64,
base64ToBytes,
bytesToHex,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
selectCanonicalProofCarrier,
timestampEvent,
upgradeOts,
isOtsConfirmed,
verifyOtsProof,
savePendingOts,
loadPendingOts,
clearPendingOts,
isDetachedOtsFile,
buildProofArchive
} 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');
}
/* ================================================================
F-D2: PROOF ARCHIVE DOWNLOAD (anchor availability)
A self-contained archive the user stores offline so the genuine
proof carrier remains verifiable even if relays delete it. */
function downloadProofArchive() {
if (!kind1Event || !pqEvent || !pendingOtsBytes) {
console.warn('[post-quantum] Cannot build 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 = `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] Proof archive downloaded');
} catch (e) {
console.error('[post-quantum] Failed to build proof archive:', e);
}
}
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 canonical kind 1 event digest (F-D1) ----
// F-D1: use the cross-implementation-reproducible canonical digest,
// not the legacy key-order-dependent hashFullEvent(). The same digest
// is recorded in the proof carrier's sha256 tag by buildProofCarrier().
const fullHash = canonicalEventDigest(kind1Event);
otsLog(`Canonical digest (F-D1 v1) of 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;
// G56-20: Show event size and warn if it may exceed relay limits
const eventBytes = new Blob([eventJsonStr]).size;
const sizeKB = (eventBytes / 1024).toFixed(1);
const sizeWarning = document.getElementById('pqEventSizeWarning');
if (sizeWarning) {
if (eventBytes > 60000) {
sizeWarning.innerHTML = `<strong>⚠ Event size: ${sizeKB} KiB.</strong> Some relays may reject events larger than ~5060 KiB. If publication fails, try adding relays with higher limits.`;
sizeWarning.style.display = 'block';
} else {
sizeWarning.innerHTML = `Event size: ${sizeKB} KiB`;
sizeWarning.style.display = 'block';
}
}
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(); });
// F-D2: Proof archive download (anchor availability / deletion resistance)
const archiveBtn = document.getElementById('pqDownloadArchiveBtn');
if (archiveBtn) {
archiveBtn.addEventListener('click', () => { downloadProofArchive(); });
}
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); }
}
// G56-10: Zeroize secret typed arrays before nulling references.
// Note: JavaScript garbage collection does not guarantee erasure —
// this is a best-effort reduction of the exposure window. See README
// for the full limitation discussion.
function zeroize(arr) {
if (arr instanceof Uint8Array) {
arr.fill(0);
}
}
if (pqSeed) zeroize(pqSeed);
if (pqSecpKeys) {
if (pqSecpKeys.privateKey) zeroize(pqSecpKeys.privateKey);
if (pqSecpKeys.publicKey) zeroize(pqSecpKeys.publicKey);
}
if (pqKeys) {
for (const alg of ['mlDsa44', 'mlDsa65', 'slhDsa', 'falcon512', 'mlKem']) {
if (pqKeys[alg] && pqKeys[alg].secretKey) zeroize(pqKeys[alg].secretKey);
}
}
// 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();
// G56-10: Clear DOM elements that displayed secret material
const seedDisplay = document.getElementById('pqSeedDisplay');
if (seedDisplay) seedDisplay.innerHTML = '';
const seedInput = document.getElementById('pqSeedInput');
if (seedInput) seedInput.value = '';
const entropyHint = document.getElementById('pqEntropyHint');
if (entropyHint) entropyHint.textContent = 'Optional: click here and type random characters, move your mouse to add entropy...';
// G56-10: Clear the clipboard (in case the user copied the seed phrase)
try { navigator.clipboard.writeText(''); } catch (e) { /* clipboard may not be available */ }
// 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');
})();