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