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

1601 lines
74 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 {
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
derivationPathForAlgorithm,
buildKind1Announcement,
buildProofCarrier,
computeEventId,
hashFullEvent,
canonicalEventDigest,
verifyNIPQRContent,
verifyNostrEvent,
validateSignerOutput,
bytesToBase64,
base64ToBytes,
bytesToHex,
hexToNpub,
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
// Derivation scheme for this run: 'v2' (default, hardened coin-type paths)
// or 'v1' (legacy recovery of a pre-v2 seed). See plans/v2-hardened-derivation.md.
let pqScheme = 'v2';
/* ================================================================
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');
clearStepSpinner(stepNum);
}
}
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');
clearStepSpinner(stepNum);
}
}
/* Braille spinner: shown in a step's checklist box while that step is
actively working (e.g. step 6 waiting for Bitcoin confirmation).
Advances through the standard braille spinner frames every 80ms. */
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const stepSpinnerIntervals = {};
function setStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (!el) return;
const box = el.querySelector('.pq-checklist-box');
if (!box) return;
clearStepSpinner(stepNum);
el.classList.add('pq-active');
el.classList.remove('pq-done');
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
stepSpinnerIntervals[stepNum] = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function clearStepSpinner(stepNum) {
const el = stepElements[stepNum];
if (el) {
const box = el.querySelector('.pq-checklist-box');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
if (stepSpinnerIntervals[stepNum]) {
clearInterval(stepSpinnerIntervals[stepNum]);
delete stepSpinnerIntervals[stepNum];
}
}
/* Braille spinner on the "Wait for Bitcoin confirmation" checklist box
(pqOtsConfirm). Same frames as the step-6 checklist-box spinner. */
let otsConfirmSpinnerInterval = null;
function startOtsConfirmSpinner() {
const box = document.getElementById('pqOtsConfirmBox');
if (!box) return;
stopOtsConfirmSpinner();
box.classList.add('pq-spinner');
let frame = 0;
box.textContent = SPINNER_FRAMES[0];
otsConfirmSpinnerInterval = setInterval(() => {
frame = (frame + 1) % SPINNER_FRAMES.length;
box.textContent = SPINNER_FRAMES[frame];
}, 80);
}
function stopOtsConfirmSpinner() {
if (otsConfirmSpinnerInterval) { clearInterval(otsConfirmSpinnerInterval); otsConfirmSpinnerInterval = null; }
const box = document.getElementById('pqOtsConfirmBox');
if (box) { box.classList.remove('pq-spinner'); box.textContent = ''; }
}
/* ================================================================
VIEW MANAGEMENT
================================================================ */
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
================================================================ */
// Mandatory user-entropy collection. The seed box (pqSeedDisplay) is the
// collection zone: the user must move their mouse and/or press keys inside
// it until ENTROPY_REQUIRED_EVENTS samples are gathered. The seed is then
// generated by mixing CSPRNG output with the collected user entropy via
// SHA-256 (generateSeedPhraseWithEntropy), so the seed stays unpredictable
// even if the browser's CSPRNG is compromised. Either mouse or keyboard
// input counts toward the total, so laptop-only users aren't blocked.
const ENTROPY_REQUIRED_EVENTS = 256; // ~512 bits of true user entropy
// Minimum interval between accepted mousemove samples (ms). Browsers fire
// mousemove at ~60-1000+ Hz; spacing samples out reduces correlation
// between consecutive deltas (each sample carries more independent
// hand-jitter), increasing the true entropy per sample.
const MOUSE_SAMPLE_MIN_INTERVAL_MS = 32;
let userEntropyChunks = [];
let entropyCollected = 0;
let entropyListenersBound = false;
// Last mouse position, used to capture deltas (dx, dy) which hold the
// unpredictable hand-jitter entropy better than absolute coordinates.
let lastMouseX = null;
let lastMouseY = null;
// Timestamp of the last accepted mousemove sample (for throttling).
let lastMouseSampleTime = 0;
function getSelectedEntropyBits() {
const radio = document.querySelector('input[name="pqWordCount"]:checked');
return radio ? parseInt(radio.value, 10) : 256;
}
// Render the seed box in its collecting (pre-generation) state.
function renderSeedCollectingState() {
pqSeedDisplay.classList.add('pq-seed-collecting');
pqSeedDisplay.innerHTML =
'<div class="pq-seed-collect-prompt" id="pqSeedCollectPrompt">'
+ 'Move your mouse and/or press random keys inside this box to generate your seed phrase.'
+ '<div style="margin-top: 10px; height: 6px; background: var(--muted-color); border-radius: 3px; overflow: hidden;">'
+ '<div id="pqSeedEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>'
+ '</div>'
+ '<div id="pqSeedEntropyCount" style="margin-top: 6px; font-size: 12px; color: var(--muted-color);">0 / '
+ ENTROPY_REQUIRED_EVENTS + '</div>'
+ '</div>';
const bar = document.getElementById('pqSeedEntropyBar');
if (bar) bar.style.width = '0%';
}
function updateEntropyProgress() {
const bar = document.getElementById('pqSeedEntropyBar');
const count = document.getElementById('pqSeedEntropyCount');
const pct = Math.min(100, Math.round((entropyCollected / ENTROPY_REQUIRED_EVENTS) * 100));
if (bar) bar.style.width = pct + '%';
if (count) count.textContent = `${entropyCollected} / ${ENTROPY_REQUIRED_EVENTS}`;
}
// Collect low-entropy environment/fingerprint values and mix them into the
// seed. Each of these is low-entropy on its own (and a remote attacker can
// often query the same values), but together they add a few extra bits and
// cost nothing. They are mixed via SHA-256 alongside the CSPRNG and user
// input, so they can only help, never hurt.
function collectSystemEntropy() {
const parts = [];
try { parts.push('ua:' + navigator.userAgent); } catch (e) {}
try { parts.push('plat:' + navigator.platform); } catch (e) {}
try { parts.push('lang:' + navigator.language); } catch (e) {}
try { parts.push('langs:' + (navigator.languages || []).join(',')); } catch (e) {}
try { parts.push('sw:' + screen.width); } catch (e) {}
try { parts.push('sh:' + screen.height); } catch (e) {}
try { parts.push('scd:' + screen.colorDepth); } catch (e) {}
try { parts.push('dpr:' + window.devicePixelRatio); } catch (e) {}
try { parts.push('hwc:' + navigator.hardwareConcurrency); } catch (e) {}
try { parts.push('mem:' + navigator.deviceMemory); } catch (e) {}
try { parts.push('tz:' + Intl.DateTimeFormat().resolvedOptions().timeZone); } catch (e) {}
// performance.now() at collection time adds a little timing jitter.
try { parts.push('now:' + performance.now()); } catch (e) {}
return parts.join('|');
}
// Generate the seed from the collected user entropy + system entropy +
// CSPRNG and reveal the word grid in the same box.
function generateAndShowSeed() {
const entropyBits = getSelectedEntropyBits();
// Prepend system entropy so it is always mixed in, regardless of how
// many mouse/keyboard samples were collected.
const combined = collectSystemEntropy() + '\n' + userEntropyChunks.join('\n');
const userEntropy = new TextEncoder().encode(combined);
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy, entropyBits);
otsLog(`Seed generated with ${userEntropyChunks.length} user entropy samples + system entropy (${entropyBits}-bit).`);
const words = pqMnemonic.split(' ');
pqSeedDisplay.classList.remove('pq-seed-collecting');
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;
}
// Reset the collection zone to its pre-generation state and (re)bind the
// mouse/keyboard listeners. Called when entering step 2, switching word
// counts, or switching back to the "Generate New Seed" panel.
function resetSeedCollection() {
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pqMnemonic = null;
pqSeedConfirmed.checked = false;
pqSeedContinueBtn.disabled = true;
renderSeedCollectingState();
updateEntropyProgress();
bindEntropyListeners();
}
// Bind mouse/keyboard collection listeners to the seed box. Idempotent.
function bindEntropyListeners() {
if (entropyListenersBound) return;
entropyListenersBound = true;
function collectEvent(data) {
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) return;
// performance.now() gives sub-millisecond monotonic timing jitter,
// which is harder for a remote attacker to predict than Date.now().
userEntropyChunks.push(data + ':' + performance.now() + ':' + Math.random());
entropyCollected++;
updateEntropyProgress();
if (entropyCollected >= ENTROPY_REQUIRED_EVENTS) {
// Threshold reached — generate the seed immediately.
generateAndShowSeed();
}
}
pqSeedDisplay.addEventListener('mousemove', (e) => {
// Throttle: only accept a sample if enough time has passed since the
// last one. This doubles the effective interval between samples
// (browsers normally fire at ~16ms/frame), reducing correlation
// between consecutive deltas so each accepted sample carries more
// independent entropy.
const now = performance.now();
if (now - lastMouseSampleTime < MOUSE_SAMPLE_MIN_INTERVAL_MS) return;
lastMouseSampleTime = now;
// Capture deltas (dx, dy) between consecutive samples rather than
// absolute coordinates. The micro-jitter in deltas is the real
// unpredictable entropy in mouse movement; absolute positions are
// highly correlated between samples and bounded by the box size.
const dx = lastMouseX === null ? 0 : e.clientX - lastMouseX;
const dy = lastMouseY === null ? 0 : e.clientY - lastMouseY;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
collectEvent(`m${dx},${dy}`);
});
pqSeedDisplay.addEventListener('keypress', (e) => {
collectEvent(`k${e.key}:${e.keyCode}`);
});
pqSeedDisplay.addEventListener('keydown', (e) => {
// Capture non-printable keys too (Arrow, Shift, etc.). Prevent default
// scrolling/behavior when the box is focused and still collecting.
if (entropyCollected < ENTROPY_REQUIRED_EVENTS) {
if (e.key.length > 1) e.preventDefault();
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');
// Entering / re-entering the generate panel resets the collection zone.
resetSeedCollection();
}
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');
// Show the paths for the active scheme in the key checklist
document.querySelectorAll('.pq-derive-path').forEach(el => {
try { el.textContent = derivationPathForAlgorithm(el.dataset.alg, pqScheme); } catch (_) { /* leave default */ }
});
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, pqScheme);
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',
pqScheme === 'v1'
? 'All 5 post-quantum keys derived with legacy v1 paths (recovery mode). Verify these match your published event, then re-run with v2 to upgrade.'
: 'All 5 post-quantum keys derived successfully (v2 hardened scheme)!');
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, pqScheme);
// Strip the non-NIP-01 helper field 'statementBytes' before sending to
// the signer. buildKind1Announcement includes it for internal use, but
// it is not a valid NIP-01 event field and validateSignerOutput (G56-05)
// rejects extra fields. The signer must only see the 6 NIP-01 template
// fields (pubkey, created_at, kind, tags, content — id/sig are added by
// the signer).
const kind1SignerTemplate = {
pubkey: kind1Template.pubkey,
created_at: kind1Template.created_at,
kind: kind1Template.kind,
tags: kind1Template.tags,
content: kind1Template.content
};
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
const kind1Signed = await window.nostr.signEvent(kind1SignerTemplate);
// G56-05: validate signer output — never reconstruct from mixed fields
kind1Event = validateSignerOutput(kind1Signed, kind1SignerTemplate, 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 ~50–60 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://sendit.nosflare.com/',
'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', () => {
showView('pqSeedStep');
setStepActive(2);
// Reset the collection zone and start mandatory mouse/keyboard collection.
resetSeedCollection();
});
// Seed mode toggle
document.getElementById('pqSeedToggleGenerate').addEventListener('click', () => showSeedGeneratePanel());
document.getElementById('pqSeedToggleOwn').addEventListener('click', () => showSeedOwnPanel());
// Word-count selector: switching 12/24 words resets the collection zone
// so the user re-collects entropy for the new word count.
document.querySelectorAll('input[name="pqWordCount"]').forEach((radio) => {
radio.addEventListener('change', () => {
resetSeedCollection();
});
});
// Bring your own seed: real-time validation
const pqSeedInput = document.getElementById('pqSeedInput');
const pqSeedValidation = document.getElementById('pqSeedValidation');
const pqSeedOwnContinueBtn = document.getElementById('pqSeedOwnContinueBtn');
// v1 recovery toggle: derive legacy v1 keys from a pre-v2 seed
const pqSeedV1Recovery = document.getElementById('pqSeedV1Recovery');
const pqSeedV1Note = document.getElementById('pqSeedV1Note');
if (pqSeedV1Recovery) {
pqSeedV1Recovery.addEventListener('change', () => {
pqScheme = pqSeedV1Recovery.checked ? 'v1' : 'v2';
if (pqSeedV1Note) pqSeedV1Note.style.display = pqSeedV1Recovery.checked ? 'block' : 'none';
});
}
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();
// Scheme was set by the v1-recovery checkbox; default v2 otherwise.
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);
});
}
});
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(); }
});
// 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; }
clearStepSpinner(6);
stopOtsConfirmSpinner();
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
pqScheme = 'v2'; // reset derivation scheme to the default
// Reset the v1-recovery toggle UI
const v1Toggle = document.getElementById('pqSeedV1Recovery');
if (v1Toggle) v1Toggle.checked = false;
const v1Note = document.getElementById('pqSeedV1Note');
if (v1Note) v1Note.style.display = 'none';
userEntropyChunks = [];
entropyCollected = 0;
lastMouseX = null;
lastMouseY = null;
lastMouseSampleTime = 0;
pendingOtsBytes = null;
currentBlockHeight = 0;
pqRelays = DEFAULT_RELAYS.slice();
// G56-10: Clear DOM elements that displayed secret material.
// Reset the seed box to its collecting (pre-generation) state so no
// generated words remain visible after sign-out.
const seedDisplay = document.getElementById('pqSeedDisplay');
if (seedDisplay) renderSeedCollectingState();
const seedInput = document.getElementById('pqSeedInput');
if (seedInput) seedInput.value = '';
// 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', '');
stopOtsConfirmSpinner();
hideUpgradedArchiveButton();
// 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>';
showUpgradedArchiveButton();
return;
}
hideUpgradedArchiveButton();
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 9999 event. Waiting for Bitcoin confirmation...</div>';
startOtsPolling();
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).`);
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).`);
// 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}`);
}
}
// The raw OTS proof base64 display and standalone .ots download have been
// removed from step 6. Instead, after confirmation + publish, the user is
// offered an upgraded proof archive (kind 1 + upgraded kind 9999 + confirmed
// OTS proof) via showUpgradedArchiveButton().
function showUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.remove('pq-hidden');
}
function hideUpgradedArchiveButton() {
const wrap = document.getElementById('pqOtsArchiveWrap');
if (wrap) wrap.classList.add('pq-hidden');
}
function startOtsPolling() {
const confirmDetail = document.getElementById('pqOtsConfirmDetail');
let pollCount = 0;
if (otsPollInterval) clearInterval(otsPollInterval);
// Show a braille spinner in the step-6 checklist box and inline next
// to the "Wait for Bitcoin confirmation" label so the user can see
// the page is still actively working while waiting for Bitcoin.
setStepSpinner(6);
startOtsConfirmSpinner();
async function poll() {
pollCount++;
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
});
// 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');
stopOtsConfirmSpinner();
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
const saved = loadPendingOts();
if (!saved || !saved.confirmedPublished) {
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Publishing new kind 9999 with confirmed proof...');
await publishUpgradedEvent(pendingOtsBytes, att ? att.height : null, att ? att.time : null);
}
} 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, bitcoinHeight = null, bitcoinTime = null) {
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
});
// Publish a kind 1 reply announcing the confirmed attestation.
// This is a regular Nostr text note (reply to the original kind 1
// announcement) so it shows up in feeds/thread views. It only needs
// the secp256k1 signature — no PQ signatures.
if (kind1Event && kind1Event.id) {
try {
otsLog('Publishing kind 1 reply announcing confirmed attestation...');
const npub = hexToNpub(currentPubkey);
const dateStr = bitcoinTime
? new Date(bitcoinTime * 1000).toISOString().substring(0, 19)
: 'unknown';
const blockStr = bitcoinHeight ? `Bitcoin block ${bitcoinHeight} (mined ${dateStr} UTC)` : 'a Bitcoin block';
const replyContent = `✅ The OpenTimestamps proof for my post-quantum key attestation has been confirmed in ${blockStr}. The link between my current Nostr identity and my post-quantum keys is now anchored to the Bitcoin blockchain and cannot be backdated.
Verify it here: https://laantungir.net/quantum-prep/verify.html?npub=${npub}
This is a reply to my original attestation announcement. The confirmed proof is carried in a new kind 9999 event referencing the original.`;
const replyTemplate = {
kind: 1,
content: replyContent,
tags: [
['e', kind1Event.id, '', 'root'],
['e', validatedEvent.id, '', 'mention']
],
pubkey: currentPubkey,
created_at: Math.floor(Date.now() / 1000)
};
const replySigned = await window.nostr.signEvent(replyTemplate);
const replyValidated = validateSignerOutput(replySigned, replyTemplate, currentPubkey);
const replyResults = await publishToRelays(replyValidated, relayUrls);
const replyOk = replyResults.filter(r => r.success).length;
const replyFail = replyResults.filter(r => !r.success).length;
otsLog(`Kind 1 reply published. Successful: ${replyOk}; failed: ${replyFail}.`);
} catch (replyErr) {
// The upgraded proof carrier is already published; a failed reply
// is non-fatal — log it but don't fail the whole operation.
otsLog(`WARNING: kind 1 reply failed: ${replyErr.message}`);
}
}
setStatus(otsStatus, 'success', `Upgraded proof carrier published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
showUpgradedArchiveButton();
return validatedEvent;
} catch (error) {
otsLog(`ERROR publishing upgraded event: ${error.message}`);
setStatus(otsStatus, 'error', `Failed to publish upgraded event: ${error.message}`);
throw error;
}
}
// OTS event listener: download the upgraded proof archive (kind 1 event +
// upgraded kind 9999 proof carrier + confirmed OTS proof) after Bitcoin
// confirmation and publish.
document.getElementById('pqOtsArchiveBtn').addEventListener('click', () => {
if (!kind1Event || !pqEvent || !pendingOtsBytes) {
console.warn('[post-quantum] Cannot build upgraded archive: missing kind1Event, proofCarrier, or OTS proof');
return;
}
try {
const archiveJson = buildProofArchive(kind1Event, pqEvent, pendingOtsBytes);
const blob = new Blob([archiveJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `nostr-pq-proof-${pqEvent.id.substring(0, 12)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('[post-quantum] Upgraded proof archive downloaded');
} catch (e) {
console.error('[post-quantum] Failed to build upgraded archive:', e);
}
});
/* ================================================================
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');
})();