1373 lines
62 KiB
JavaScript
1373 lines
62 KiB
JavaScript
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 ~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://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');
|
||
})();
|