Files
nostr_quantum_preparation/www/index.html
T

1662 lines
74 KiB
HTML

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
<title>Post-Quantum Nostr</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
}
.pq-container {
max-width: 700px;
width: 100%;
padding: 10px;
}
/* CHECKLIST */
.pq-checklist {
background: var(--secondary-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 20px 25px;
margin-bottom: 20px;
}
.pq-checklist-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: var(--primary-color);
}
.pq-checklist-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 14px;
color: var(--muted-color);
border-bottom: 1px solid var(--border-color);
}
.pq-checklist-item:last-child { border-bottom: none; }
.pq-checklist-item.pq-done {
color: var(--primary-color);
}
.pq-checklist-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid var(--muted-color);
border-radius: 4px;
flex-shrink: 0;
font-size: 12px;
font-weight: bold;
}
.pq-checklist-item.pq-done .pq-checklist-box {
background: var(--accent-color);
border-color: var(--accent-color);
color: var(--secondary-color);
}
.pq-checklist-item.pq-active {
color: var(--primary-color);
font-weight: bold;
}
.pq-checklist-item.pq-active .pq-checklist-box {
border-color: var(--accent-color);
}
.pq-checklist-link {
color: var(--accent-color);
text-decoration: underline;
}
/* CARDS */
.pq-card {
background: var(--secondary-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 25px;
margin-bottom: 20px;
}
.pq-card-title {
font-size: 18px;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 15px;
}
.pq-info-text {
font-size: 14px;
color: var(--primary-color);
line-height: 1.6;
margin-bottom: 15px;
}
.pq-info-text strong { color: var(--primary-color); }
.pq-warning {
background: var(--primary-color);
color: var(--secondary-color);
border-radius: var(--border-radius);
padding: 12px 15px;
margin: 15px 0;
font-size: 13px;
line-height: 1.5;
}
.pq-warning strong { color: var(--accent-color); }
.pq-seed-display {
background: var(--primary-color);
color: var(--secondary-color);
border-radius: var(--border-radius);
padding: 20px;
margin: 15px 0;
font-size: 16px;
line-height: 1.8;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 20px;
word-break: break-word;
}
.pq-seed-word { display: flex; align-items: center; gap: 8px; }
.pq-seed-number { color: var(--muted-color); font-size: 12px; min-width: 20px; }
.pq-button {
background: var(--primary-color);
color: var(--secondary-color);
border: var(--border-width) solid var(--primary-color);
border-radius: var(--border-radius);
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
width: 100%;
}
.pq-button:hover { opacity: 0.7; }
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
.pq-button-secondary {
background: transparent;
color: var(--primary-color);
border: var(--border-width) solid var(--primary-color);
}
.pq-button-row { display: flex; gap: 10px; margin: 10px 0; }
.pq-button-row .pq-button { flex: 1; }
.pq-checkbox-row {
display: flex; align-items: center; gap: 8px;
margin: 15px 0; font-size: 14px; cursor: pointer; color: var(--primary-color);
}
.pq-checkbox-row input { width: 18px; height: 18px; cursor: pointer; }
.pq-seed-toggle {
flex: 1;
opacity: 0.5;
}
.pq-seed-toggle-active {
opacity: 1;
border-color: var(--accent-color);
color: var(--accent-color);
}
.pq-progress-bar {
background: var(--muted-color); border-radius: 4px; height: 8px; margin: 15px 0; overflow: hidden;
}
.pq-progress-fill { background: var(--accent-color); height: 100%; width: 0%; transition: width 0.3s; }
.pq-key-list { display: flex; flex-direction: column; gap: 10px; margin: 15px 0; }
.pq-key-item {
display: flex; align-items: flex-start; gap: 12px; padding: 12px;
background: var(--primary-color); color: var(--secondary-color); border-radius: var(--border-radius);
}
.pq-key-icon { font-size: 20px; flex-shrink: 0; }
.pq-key-details { flex: 1; min-width: 0; }
.pq-key-name { font-size: 14px; font-weight: bold; margin-bottom: 2px; }
.pq-key-meta { font-size: 12px; color: var(--primary-color); margin-bottom: 4px; }
.pq-key-pubkey { font-size: 11px; color: var(--primary-color); word-break: break-all; }
.pq-status {
padding: 10px 15px; border-radius: var(--border-radius); margin: 10px 0; font-size: 14px; text-align: left;
}
.pq-status-info { background: var(--secondary-color); border: var(--border); }
.pq-status-success { background: var(--accent-color); color: var(--secondary-color); }
.pq-status-error { background: var(--accent-color); color: var(--secondary-color); }
.pq-event-preview {
background: var(--primary-color); color: var(--secondary-color); border-radius: var(--border-radius);
padding: 15px; font-size: 11px; white-space: pre-wrap; word-break: break-all;
max-height: 400px; overflow-y: auto; text-align: left;
}
.pq-npub-display {
font-size: 12px; word-break: break-all;
background: var(--primary-color); color: var(--secondary-color);
padding: 8px 12px; border-radius: var(--border-radius); margin: 10px 0;
}
.pq-relay-input {
width: 100%; background: var(--secondary-color); border: var(--border);
border-radius: var(--border-radius); padding: 10px 14px; font-size: 14px;
color: var(--primary-color); margin: 8px 0;
}
.pq-relay-input:focus { outline: none; border-color: var(--accent-color); }
.pq-hidden { display: none !important; }
ol { margin: 10px 0 10px 20px; line-height: 1.8; }
</style>
</head>
<body>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">Post-Quantum Nostr</div>
</div>
<div id="divHeaderFlexRight">
<button class="pq-button pq-button-secondary pq-hidden" id="pqSignOutBtn" style="width: auto; padding: 6px 16px; font-size: 13px;">Sign Out</button>
</div>
</div>
<div id="divBody">
<div class="pq-container">
<!-- ================================================================
PERSISTENT CHECKLIST (always visible at top)
================================================================ -->
<div class="pq-checklist">
<div class="pq-checklist-title">Migration Steps</div>
<div class="pq-checklist-item" id="step-1">
<span class="pq-checklist-box"></span>
<span>1. Sign in with your Nostr identity</span>
</div>
<div class="pq-checklist-item" id="step-2">
<span class="pq-checklist-box"></span>
<span>2. Generate a quantum-safe seed phrase</span>
</div>
<div class="pq-checklist-item" id="step-3">
<span class="pq-checklist-box"></span>
<span>3. Derive post-quantum keys from seed</span>
</div>
<div class="pq-checklist-item" id="step-4">
<span class="pq-checklist-box"></span>
<span>4. Sign the NIP-QR migration event</span>
</div>
<div class="pq-checklist-item" id="step-5">
<span class="pq-checklist-box"></span>
<span>5. Publish events to relays</span>
</div>
<div class="pq-checklist-item" id="step-6">
<span class="pq-checklist-box"></span>
<span>6. Timestamp the event with OpenTimestamps</span>
</div>
<div class="pq-checklist-item" id="step-7">
<span class="pq-checklist-box"></span>
<span>7. <a href="./verify.html" class="pq-checklist-link">Verify the event signatures</a></span>
</div>
</div>
<!-- ================================================================
STEP CONTENT (shown/hidden below checklist)
================================================================ -->
<!-- STEP 0: NOT AUTHENTICATED -->
<div id="pqNotAuthed" class="pq-card">
<div class="pq-card-title">Post-Quantum Nostr</div>
<div class="pq-info-text" style="font-size: 16px; color: var(--primary-color);">
Make your Nostr identity quantum-resistant
</div>
<div class="pq-info-text">
Sign in with your Nostr signer to begin. Your npub won't change, your followers stay,
and your social graph is preserved. We'll generate a quantum-safe seed phrase and link
it to your current identity.
</div>
<div class="pq-info-text" id="noSignerWarning" style="color: var(--accent-color); display: none;">
No Nostr signer detected. Install a NIP-07 browser extension (like nos2x or Alby) and reload.
</div>
<div id="pqSignInError"></div>
<button class="pq-button" id="pqSignInBtn">Sign In to Continue</button>
</div>
<!-- STEP 1: AUTHENTICATED - INTRO -->
<div id="pqAuthed" class="pq-card pq-hidden">
<div class="pq-card-title">Post-Quantum Migration</div>
<div class="pq-info-text">
<strong>Connected as:</strong>
<div class="pq-npub-display" id="pqNpub"></div>
</div>
<div class="pq-info-text">
<strong>Current Bitcoin block height:</strong>
<div class="pq-npub-display" id="pqBlockHeight">Loading...</div>
</div>
<div class="pq-info-text">
This tool will:
<ol>
<li>Generate a new <strong>seed phrase</strong> (your quantum-safe backup)</li>
<li>Derive <strong>post-quantum keys</strong> from that seed via BIP32 paths (ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768)</li>
<li>Sign a <strong>NIP-QR migration event</strong> linking your identity to the PQ keys</li>
<li>Publish the event to relays</li>
</ol>
</div>
<div class="pq-warning">
<strong>Important:</strong> Your seed phrase is generated and handled entirely in your browser.
It is never sent to any server. Write it down on paper — it's your quantum-safe backup.
</div>
<button class="pq-button" id="pqStartBtn">Start Migration</button>
</div>
<!-- STEP 2: SEED PHRASE GENERATION -->
<div id="pqSeedStep" class="pq-card pq-hidden">
<div class="pq-card-title">Your Quantum-Safe Seed Phrase</div>
<div class="pq-info-text">
Your seed phrase is your <strong>quantum-safe root of trust</strong>. All post-quantum keys
are derived from it. It is processed entirely in your browser and never sent to any server.
The code is open source and auditable.
</div>
<!-- Toggle: Generate vs Bring your own -->
<div class="pq-button-row" style="margin-bottom: 15px;">
<button class="pq-button pq-button-secondary pq-seed-toggle pq-seed-toggle-active" id="pqSeedToggleGenerate">Generate New Seed</button>
<button class="pq-button pq-button-secondary pq-seed-toggle" id="pqSeedToggleOwn">I Have a Seed Phrase</button>
</div>
<!-- Generate mode -->
<div id="pqSeedGeneratePanel">
<div class="pq-seed-display" id="pqSeedDisplay"></div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary" id="pqCopySeedBtn">Copy</button>
<button class="pq-button pq-button-secondary" id="pqRegenerateBtn">Generate New Seed</button>
</div>
<div class="pq-info-text" style="margin-top: 15px; font-size: 13px;">
<strong>Optional:</strong> Add extra entropy by moving your mouse and typing in the box below.
This mixes your input with the browser's random number generator, making the seed unpredictable
even if the browser's RNG is compromised. You can skip this — a seed has already been generated for you above.
</div>
<div id="pqEntropyBox" style="background: var(--primary-color); color: var(--secondary-color); border-radius: var(--border-radius); padding: 15px; font-size: 13px; min-height: 60px; margin: 10px 0; cursor: text; text-align: left;" tabindex="0">
<span id="pqEntropyHint">Optional: click here and type random characters, move your mouse to add entropy...</span>
<div style="margin-top: 8px; height: 6px; background: var(--secondary-color); border-radius: 3px; overflow: hidden;">
<div id="pqEntropyBar" style="height: 100%; width: 0%; background: var(--accent-color); transition: width 0.2s;"></div>
</div>
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary pq-hidden" id="pqRegenerateWithEntropyBtn" disabled>Generate with Extra Entropy</button>
</div>
<div class="pq-warning" style="font-size: 12px; margin: 10px 0;">
<strong>Note:</strong> This 12-word seed provides ~64-bit post-quantum security on the seed entropy itself
(under Grover's algorithm). For maximum post-quantum security, consider using a 24-word seed phrase
(available via "I Have a Seed Phrase" above). The bigger risk to seed phrases is classical (theft, phishing),
not quantum.
</div>
<label class="pq-checkbox-row">
<input type="checkbox" id="pqSeedConfirmed" />
I have written down my seed phrase
</label>
<button class="pq-button" id="pqSeedContinueBtn" disabled>Continue</button>
</div>
<!-- Bring your own mode -->
<div id="pqSeedOwnPanel" style="display: none;">
<div class="pq-info-text">
Paste your 12 or 24-word BIP39 seed phrase below. It will be validated and used to
derive your post-quantum keys. Make sure you generated it offline or with a trusted tool.
</div>
<textarea class="pq-textarea" id="pqSeedInput" style="min-height: 80px; width: 100%; background: var(--secondary-color); border: var(--border); border-radius: var(--border-radius); padding: 12px; font-size: 14px; color: var(--primary-color); font-family: monospace; margin: 10px 0;" placeholder="abandon ability able about above absent absorb abstract absurd abuse access accident..."></textarea>
<div id="pqSeedValidation" style="font-size: 13px; margin: 5px 0;"></div>
<button class="pq-button" id="pqSeedOwnContinueBtn" disabled>Continue</button>
</div>
</div>
<!-- STEP 3: DERIVING PQ KEYS -->
<div id="pqDeriveStep" class="pq-card pq-hidden">
<div class="pq-card-title">Deriving Post-Quantum Keys</div>
<div class="pq-info-text">
Deriving post-quantum keys from your seed phrase via BIP32 paths.
All keys are generated deterministically — the same seed will always produce the same keys.
</div>
<div class="pq-progress-bar">
<div class="pq-progress-fill" id="pqDeriveProgress"></div>
</div>
<div class="pq-key-list" id="pqKeyList">
<div class="pq-key-item" id="pqKeyMlDsa44">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">ML-DSA-44 (Dilithium)</div>
<div class="pq-key-meta">FIPS 204 · Cat 2 · 1312-byte pubkey · m/44'/1237'/0'/0/1</div>
<div class="pq-key-pubkey" id="pqKeyMlDsa44Pub"></div>
</div>
</div>
<div class="pq-key-item" id="pqKeyMlDsa65">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">ML-DSA-65 (Dilithium)</div>
<div class="pq-key-meta">FIPS 204 · Cat 3 · 1952-byte pubkey · m/44'/1237'/0'/0/2</div>
<div class="pq-key-pubkey" id="pqKeyMlDsa65Pub"></div>
</div>
</div>
<div class="pq-key-item" id="pqKeySlhDsa">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">SLH-DSA-128s (SPHINCS+)</div>
<div class="pq-key-meta">FIPS 205 · Cat 1 · 32-byte pubkey · m/44'/1237'/0'/0/3+4</div>
<div class="pq-key-pubkey" id="pqKeySlhDsaPub"></div>
</div>
</div>
<div class="pq-key-item" id="pqKeyFalcon">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">Falcon-512</div>
<div class="pq-key-meta">FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/1237'/0'/0/5+6</div>
<div class="pq-key-pubkey" id="pqKeyFalconPub"></div>
<div style="font-size: 11px; color: var(--accent-color); margin-top: 4px;">⚠ Draft standard — may need re-issuing if FIPS 206 changes</div>
</div>
</div>
<div class="pq-key-item" id="pqKeyMlKem">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">ML-KEM-768 (Kyber)</div>
<div class="pq-key-meta">FIPS 203 · Cat 3 · 1184-byte pubkey · m/44'/1237'/0'/0/7+8</div>
<div class="pq-key-pubkey" id="pqKeyMlKemPub"></div>
</div>
</div>
</div>
<div id="pqDeriveStatus"></div>
<button class="pq-button pq-hidden" id="pqDeriveContinueBtn">Continue to Signing</button>
</div>
<!-- STEP 4: SIGNING -->
<div id="pqSignStep" class="pq-card pq-hidden">
<div class="pq-card-title">Sign Migration Event</div>
<div class="pq-info-text">
We'll now create two events: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 11112 wrapper (carries the OpenTimestamps proof). Each PQ key signs the attestation
statement, and your signer signs both events with your secp256k1 key. The SHA-256 of the
full kind 1 event (including its secp256k1 signature) is submitted to OpenTimestamps.
</div>
<div id="pqSignStatus"></div>
<div class="pq-key-list" id="pqSignChecklist">
<div class="pq-key-item" id="pqSignPqMlDsa44">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">PQ signature: ML-DSA-44</div>
<div class="pq-key-meta">Done in browser (pure JS)</div>
</div>
</div>
<div class="pq-key-item" id="pqSignPqMlDsa65">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">PQ signature: ML-DSA-65</div>
<div class="pq-key-meta">Done in browser (pure JS)</div>
</div>
</div>
<div class="pq-key-item" id="pqSignPqSlhDsa">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">PQ signature: SLH-DSA-128s</div>
<div class="pq-key-meta">Done in browser (pure JS)</div>
</div>
</div>
<div class="pq-key-item" id="pqSignPqFalcon">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">PQ signature: Falcon-512</div>
<div class="pq-key-meta">Done in browser (pure JS)</div>
</div>
</div>
<div class="pq-key-item" id="pqSignSecp">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">secp256k1 signature: kind 1 announcement</div>
<div class="pq-key-meta">Requires approval from your signer</div>
</div>
</div>
<div class="pq-key-item" id="pqSignSecpWrapper">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">secp256k1 signature: kind 11112 wrapper</div>
<div class="pq-key-meta">Requires approval from your signer</div>
</div>
</div>
</div>
<button class="pq-button" id="pqSignBtn">Sign Event</button>
<div id="pqEventPreviewWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;">Event preview:</div>
<div class="pq-event-preview" id="pqEventPreview"></div>
</div>
</div>
<!-- STEP 5: REVIEW & PUBLISH -->
<div id="pqPublishStep" class="pq-card pq-hidden">
<div class="pq-card-title">Review & Publish Events</div>
<div class="pq-info-text">
Two events have been signed: a kind 1 announcement (public, visible in Nostr feeds) and
a kind 11112 wrapper (carries the OpenTimestamps proof). Review them below, then publish
both to relays when ready.
</div>
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Kind 1 announcement event:</strong></div>
<div id="pqKind1IdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqKind1Preview" style="max-height: 200px;"></div>
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;"><strong>Kind 11112 wrapper event:</strong></div>
<div id="pqEventIdDisplay" style="margin: 5px 0; font-size: 11px; color: var(--primary-color); word-break: break-all;"></div>
<div class="pq-event-preview" id="pqSuccessEventPreview"></div>
<div style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Relays to publish to:</strong></div>
<input type="text" class="pq-relay-input" id="pqRelaysInput"
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net"
placeholder="wss://relay1.com,wss://relay2.com" />
</div>
<div id="pqPublishStatus"></div>
<div class="pq-button-row" style="margin-top: 15px;">
<button class="pq-button" id="pqPublishBtn">Publish Both Events to Relays</button>
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary" id="pqDoneBtn">Done</button>
</div>
</div>
<!-- STEP 6: OPENTIMESTAMPS -->
<div id="pqOtsStep" class="pq-card pq-hidden">
<div class="pq-card-title">OpenTimestamps Attestation</div>
<div class="pq-info-text">
The NIP-QR event is being timestamped on the Bitcoin blockchain via OpenTimestamps.
This proves the event existed at this point in time, preventing a future quantum attacker
from backdating a fraudulent event. Bitcoin confirmation typically takes 10-30 minutes.
</div>
<div class="pq-button-row" id="pqOtsSubmitButtonRow">
<button class="pq-button" id="pqOtsStartBtn">Submit to OpenTimestamps</button>
</div>
<div id="pqOtsStatus"></div>
<!-- Polling log window -->
<div class="pq-info-text" style="margin-top: 10px; margin-bottom: 5px;">Polling log:</div>
<div id="pqOtsLog" style="background: var(--primary-color); color: var(--secondary-color); border-radius: var(--border-radius); padding: 10px; font-size: 11px; font-family: monospace; height: 120px; overflow-y: auto; text-align: left; margin-bottom: 15px;"></div>
<div class="pq-key-list">
<div class="pq-key-item" id="pqOtsSubmit">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">Submit hash to OpenTimestamps</div>
<div class="pq-key-meta">Creates a complete pending detached .ots proof</div>
</div>
</div>
<div class="pq-key-item" id="pqOtsPendingPublish">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">Pending proof embedded in kind 11112 event</div>
<div class="pq-key-meta">The pending .ots proof is embedded as a tag in the initial event</div>
</div>
</div>
<div class="pq-key-item" id="pqOtsConfirm">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">Wait for Bitcoin confirmation</div>
<div class="pq-key-meta" id="pqOtsConfirmDetail">Polling every 60 seconds...</div>
</div>
</div>
<div class="pq-key-item" id="pqOtsConfirmedPublish">
<div class="pq-key-icon"></div>
<div class="pq-key-details">
<div class="pq-key-name">Republish kind 11112 with confirmed proof</div>
<div class="pq-key-meta">Republishes the event with the upgraded Bitcoin-attested proof</div>
</div>
</div>
</div>
<div id="pqOtsProofWrap" class="pq-hidden" style="margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;">OTS proof file (base64):</div>
<div class="pq-event-preview" id="pqOtsProof" style="max-height: 150px;"></div>
<div class="pq-button-row" style="margin-top: 10px;">
<button class="pq-button pq-button-secondary" id="pqOtsDownloadBtn">Download .ots file</button>
</div>
</div>
<div class="pq-button-row" style="margin-top: 15px;">
<button class="pq-button pq-button-secondary" id="pqOtsDoneBtn">Done</button>
</div>
</div>
</div>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
</div>
<!-- SCRIPTS -->
<script src="/nostr-login-lite/nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
generateSeedPhrase,
generateSeedPhraseWithEntropy,
mnemonicToSeed,
isValidMnemonic,
deriveSecp256k1FromSeed,
derivePQKeysFromSeed,
buildKind1Announcement,
buildKind11112Wrapper,
computeEventId,
hashFullEvent,
verifyNIPQRContent,
verifyNostrEvent,
bytesToBase64,
base64ToBytes,
bytesToHex,
PQ_KEY_INFO,
NIP_QR_KIND,
buildUpgradedEvent,
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 11112 wrapper 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;
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || NIP_QR_KIND };
if (pendingOts.relayUrls) document.getElementById('pqRelaysInput').value = 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);
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);
const iconEl = el.querySelector('.pq-key-icon');
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 generateAndShowSeed() {
// Use user entropy if available, otherwise pure CSPRNG
if (userEntropyChunks.length > 0) {
const userEntropy = new TextEncoder().encode(userEntropyChunks.join(''));
pqMnemonic = generateSeedPhraseWithEntropy(userEntropy);
otsLog(`Seed generated with ${userEntropyChunks.length} entropy chunks from user input.`);
} else {
pqMnemonic = generateSeedPhrase();
}
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);
setKeyIcon('pqSignPqMlDsa44', 'Done');
setKeyIcon('pqSignPqMlDsa65', 'Done');
setKeyIcon('pqSignPqSlhDsa', 'Done');
setKeyIcon('pqSignPqFalcon', 'Done');
setStatus(pqSignStatus, 'info', 'PQ signatures complete. Requesting secp256k1 signature for kind 1 announcement...');
const kind1Signed = await window.nostr.signEvent(kind1Template);
// Ensure the signed event has all fields
kind1Event = {
id: kind1Signed.id,
pubkey: kind1Signed.pubkey,
created_at: kind1Template.created_at,
kind: 1,
content: kind1Template.content,
tags: kind1Template.tags,
sig: kind1Signed.sig
};
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 11112 event will not have OTS proof yet.`);
}
// ---- Phase 3: Build and sign the kind 11112 wrapper event ----
setStatus(pqSignStatus, 'info', ' Requesting secp256k1 signature for kind 11112 wrapper...');
let kind11112Template;
if (otsProof && otsProof.length > 0) {
kind11112Template = buildKind11112Wrapper(kind1Event, otsProof);
} else {
// No OTS proof — build wrapper without ots tag (will be added later)
kind11112Template = buildKind11112Wrapper(kind1Event, new Uint8Array(0));
// Remove the empty ots tag
kind11112Template.tags = kind11112Template.tags.filter(t => t[0] !== 'ots');
}
const kind11112Signed = await window.nostr.signEvent(kind11112Template);
setKeyIcon('pqSignSecp', 'Done');
setKeyIcon('pqSignSecpWrapper', 'Done');
pqEvent = {
id: kind11112Signed.id,
pubkey: kind11112Signed.pubkey,
created_at: kind11112Template.created_at,
kind: NIP_QR_KIND,
content: kind11112Template.content,
tags: kind11112Template.tags,
sig: kind11112Signed.sig
};
otsLog(`Kind 11112 wrapper 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(() => 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 }); }
});
}));
}
/* ================================================================
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());
// 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 New Seed" — generates a fresh seed using pure CSPRNG (no user entropy)
document.getElementById('pqRegenerateBtn').addEventListener('click', () => {
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; }
});
// "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 = document.getElementById('pqRelaysInput').value.split(',').map(s => s.trim()).filter(s => s);
// Publish kind 1 announcement first
const results1 = await publishToRelays(kind1Event, relayUrls);
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 11112 wrapper
publishStatus.innerHTML = '<div class="pq-status pq-status-info">Publishing kind 11112 wrapper to relays...</div>';
const results2 = await publishToRelays(pqEvent, relayUrls);
const success2 = results2.filter(r => r.success).length;
const fail2 = results2.filter(r => !r.success).length;
otsLog(`Kind 11112 wrapper published: ${success2} ok, ${fail2} failed.`);
setStatus(publishStatus, 'success', `Published! Kind 1: ${success1}/${relayUrls.length} relays. Kind 11112: ${success2}/${relayUrls.length} relays.`);
publishBtn.textContent = 'Published';
setStepDone(5);
// Present the OpenTimestamps approval step; submission starts on user approval.
prepareOtsStep();
} catch (error) {
setStatus(publishStatus, 'error', `Publish failed: ${error.message}`);
publishBtn.disabled = false;
}
});
document.getElementById('pqDoneBtn').addEventListener('click', () => { showAuthedView(); });
// 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;
// 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() {
showView('pqOtsStep');
setStepActive(6);
const otsStatus = document.getElementById('pqOtsStatus');
const otsLogEl = document.getElementById('pqOtsLog');
const startBtn = document.getElementById('pqOtsStartBtn');
if (otsLogEl) otsLogEl.innerHTML = '';
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Review this step, then submit the NIP-QR event hash to OpenTimestamps.</div>';
startBtn.disabled = false;
startBtn.textContent = 'Submit to OpenTimestamps';
document.getElementById('pqOtsSubmitButtonRow').classList.remove('pq-hidden');
setKeyIcon('pqOtsSubmit', '');
setKeyIcon('pqOtsPendingPublish', '');
setKeyIcon('pqOtsConfirm', '');
setKeyIcon('pqOtsConfirmedPublish', '');
otsLog('Waiting for user approval to submit the event hash.');
}
async function startOtsFlow() {
showView('pqOtsStep');
setStepActive(6);
const otsStatus = document.getElementById('pqOtsStatus');
const startBtn = document.getElementById('pqOtsStartBtn');
startBtn.disabled = true;
startBtn.textContent = 'Processing...';
setKeyIcon('pqOtsSubmit', '');
setKeyIcon('pqOtsPendingPublish', '');
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)}...`);
setKeyIcon('pqOtsSubmit', 'Done');
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);
startBtn.textContent = 'Completed';
return;
}
showOtsProof(pendingOtsBytes);
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 11112 event. Waiting for Bitcoin confirmation...</div>';
startBtn.textContent = 'Submitted';
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).`);
setKeyIcon('pqOtsSubmit', 'Done');
showOtsProof(pendingOtsBytes);
savePendingOts(pqEvent.id, pendingOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
relayUrls: document.getElementById('pqRelaysInput').value,
pendingPublished: true,
confirmedPublished: false
});
setKeyIcon('pqOtsPendingPublish', 'Done');
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof is embedded in the kind 11112 event. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
startBtn.textContent = 'Submitted';
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 republish the 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>';
startBtn.disabled = false;
startBtn.textContent = 'Retry';
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).`);
setKeyIcon('pqOtsSubmit', 'Done');
showOtsProof(otsBytes);
// Republish the event with the OTS proof embedded
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Republishing kind 11112 event with embedded OTS proof...</div>';
await publishUpgradedEvent(otsBytes);
setKeyIcon('pqOtsPendingPublish', 'Done');
savePendingOts(pqEvent.id, otsBytes, {
ownerPubkey: currentPubkey,
targetKind: pqEvent.kind,
relayUrls: document.getElementById('pqRelaysInput').value,
pendingPublished: true,
confirmedPublished: false
});
otsStatus.innerHTML = '<div class="pq-status pq-status-info">Pending proof embedded. Waiting for Bitcoin confirmation (polling every 60 seconds)...</div>';
startBtn.textContent = 'Submitted';
startOtsPolling();
} catch (error) {
console.error('[ots] Submission failed:', error);
otsLog(`ERROR: ${error.message}`);
setStatus(otsStatus, 'error', `OpenTimestamps workflow failed: ${error.message}`);
startBtn.disabled = false;
startBtn.textContent = 'Retry OpenTimestamps Submission';
}
}
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
});
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';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
confirmDetail.textContent = `Verified on Bitcoin blockchain (block ${att ? att.height : '?'})!`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
const saved = loadPendingOts();
if (!saved || !saved.confirmedPublished) {
setStatus(document.getElementById('pqOtsStatus'), 'success', 'Bitcoin attestation verified. Republishing kind 11112 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 failed.${errs} Will retry.`);
const detail = result.detail ? ` (${result.detail.replace(/\s+/g, ' ').slice(0, 180)})` : '';
confirmDetail.textContent = `Attestation found but unverified (attempt ${pollCount}). Retrying in 60s...`;
} else {
const detail = result.detail ? ` (${result.detail.replace(/\s+/g, ' ').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 kind 11112 event with confirmed OTS proof...');
const savedWorkflow = loadPendingOts();
const configuredRelays = document.getElementById('pqRelaysInput').value || (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 kind 11112 event...');
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
const relayUrls = configuredRelays.split(',').map(s => s.trim()).filter(Boolean);
const results = await publishToRelays(signedEvent, 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 kind 11112 event');
setKeyIcon('pqOtsConfirmedPublish', 'Done');
otsLog(`Upgraded kind 11112 event published. Successful: ${successCount}; failed: ${failCount}.`);
// Update pqEvent to the new event so future operations use it
pqEvent = signedEvent;
savePendingOts(pqEvent.id, upgradedOtsBytes, {
ownerPubkey: currentPubkey,
targetKind: NIP_QR_KIND,
relayUrls: configuredRelays,
pendingPublished: true,
confirmedPublished: true
});
setStatus(otsStatus, 'success', `Upgraded kind 11112 event published to ${successCount} relays. Timestamping complete.`);
setStepDone(6);
setStepActive(7);
return signedEvent;
} 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('pqOtsStartBtn').addEventListener('click', () => { startOtsFlow(); });
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);
}
});
document.getElementById('pqOtsDoneBtn').addEventListener('click', () => { showAuthedView(); });
/* ================================================================
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');
})();
</script>
</body>
</html>