1862 lines
82 KiB
HTML
1862 lines
82 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>
|
|
#divHeaderText {
|
|
font-size: calc(max(var(--header-height), var(--header-min-height)) * 0.5) !important;
|
|
}
|
|
|
|
#divHeaderFlexRight {
|
|
padding-right: 20px;
|
|
}
|
|
|
|
#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(--secondary-color);
|
|
color: var(--primary-color);
|
|
border: var(--border);
|
|
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;
|
|
appearance: none; -webkit-appearance: none;
|
|
border: var(--border-width) solid var(--primary-color);
|
|
background: transparent;
|
|
display: inline-grid;
|
|
place-content: center;
|
|
box-sizing: border-box;
|
|
}
|
|
.pq-checkbox-row input::before {
|
|
content: "";
|
|
width: 100%; height: 100%;
|
|
transform: scale(0);
|
|
transition: transform 0.1s ease-in-out;
|
|
background: #d00;
|
|
border-radius: 1px;
|
|
}
|
|
.pq-checkbox-row input:checked::before {
|
|
transform: scale(1);
|
|
}
|
|
.pq-checkbox-row input:checked {
|
|
border-color: #d00;
|
|
background: #d00;
|
|
}
|
|
|
|
.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(--secondary-color); color: var(--primary-color); border: var(--border);
|
|
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" id="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 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);">
|
|
Create an experimental pre-quantum PQ key commitment
|
|
</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 seed phrase and link
|
|
it to your current identity.
|
|
</div>
|
|
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
|
|
<strong>Research prototype.</strong> Do not enter a valuable existing mnemonic. This tool
|
|
does not make your Nostr identity post-quantum secure on its own; it creates a commitment
|
|
that future PQ protocols can build on.
|
|
</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 recovery 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>
|
|
<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 Seed Phrase</div>
|
|
<div class="pq-info-text">
|
|
Your seed phrase is your <strong>recovery root</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>
|
|
<div class="pq-info-text" style="font-size: 13px; color: var(--accent-color);">
|
|
<strong>Back this up offline.</strong> Anyone with this phrase can derive your PQ private keys.
|
|
24 words (256 bits) is recommended; 12 words (128 bits) is offered for testing only and is
|
|
weaker under a quantum brute-force model.
|
|
</div>
|
|
|
|
<!-- Mode toggle + copy row (always visible) -->
|
|
<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>
|
|
<button class="pq-button pq-button-secondary" id="pqCopySeedBtn">Copy</button>
|
|
</div>
|
|
|
|
<!-- Generate mode -->
|
|
<div id="pqSeedGeneratePanel">
|
|
<!-- Word-count selector -->
|
|
<div class="pq-info-text" style="margin-bottom: 10px; font-size: 13px;">
|
|
<label style="margin-right: 12px;"><input type="radio" name="pqWordCount" value="256" id="pqWordCount24" checked> 24 words (recommended)</label>
|
|
<label><input type="radio" name="pqWordCount" value="128" id="pqWordCount12"> 12 words (testing only)</label>
|
|
</div>
|
|
<div class="pq-seed-display" id="pqSeedDisplay"></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(--secondary-color); color: var(--primary-color); border: var(--border); 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(--muted-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>
|
|
<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-checklist" id="pqKeyList" style="margin-bottom: 15px;">
|
|
<div class="pq-checklist-item" id="pqKeyMlDsa44">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>ML-DSA-44 (Dilithium) — FIPS 204 · Cat 2 · 1312-byte pubkey · m/44'/1237'/0'/0/1
|
|
<div class="pq-key-pubkey" id="pqKeyMlDsa44Pub" style="margin-top: 2px;"></div>
|
|
</span>
|
|
</div>
|
|
<div class="pq-checklist-item" id="pqKeyMlDsa65">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>ML-DSA-65 (Dilithium) — FIPS 204 · Cat 3 · 1952-byte pubkey · m/44'/1237'/0'/0/2
|
|
<div class="pq-key-pubkey" id="pqKeyMlDsa65Pub" style="margin-top: 2px;"></div>
|
|
</span>
|
|
</div>
|
|
<div class="pq-checklist-item" id="pqKeySlhDsa">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>SLH-DSA-128s (SPHINCS+) — FIPS 205 · Cat 1 · 32-byte pubkey · m/44'/1237'/0'/0/3+4
|
|
<div class="pq-key-pubkey" id="pqKeySlhDsaPub" style="margin-top: 2px;"></div>
|
|
</span>
|
|
</div>
|
|
<div class="pq-checklist-item" id="pqKeyFalcon">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>Falcon-512 — FIPS 206 (draft) · Cat 1 · 897-byte pubkey · m/44'/1237'/0'/0/5+6
|
|
<div class="pq-key-pubkey" id="pqKeyFalconPub" style="margin-top: 2px;"></div>
|
|
<div style="font-size: 11px; color: var(--accent-color); margin-top: 4px;">⚠ Draft standard — may need re-issuing if FIPS 206 changes</div>
|
|
</span>
|
|
</div>
|
|
<div class="pq-checklist-item" id="pqKeyMlKem">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>ML-KEM-768 (Kyber) — FIPS 203 · Cat 3 · 1184-byte pubkey · m/44'/1237'/0'/0/7+8
|
|
<div class="pq-key-pubkey" id="pqKeyMlKemPub" style="margin-top: 2px;"></div>
|
|
</span>
|
|
</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 9999 proof carrier (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>
|
|
<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 9999 proof carrier (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 9999 proof carrier 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 class="pq-button-row" style="margin-top: 10px;">
|
|
<button class="pq-button pq-button-secondary" id="pqCopyProofCarrierBtn">Copy Proof Carrier Event</button>
|
|
</div>
|
|
|
|
<div style="margin-top: 15px;">
|
|
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Relays to publish to:</strong></div>
|
|
<div id="pqRelayList" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px;"></div>
|
|
<div class="pq-button-row">
|
|
<input type="text" class="pq-relay-input" id="pqRelayAddInput" style="flex: 1; margin: 0;" placeholder="wss://relay.example.com" />
|
|
<button class="pq-button pq-button-secondary" id="pqRelayAddBtn" style="flex: 0 0 auto; width: auto; padding: 12px 20px;">Add Relay</button>
|
|
</div>
|
|
</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 pq-hidden" id="pqOtsContinueBtn">Continue to OpenTimestamps</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- STEP 6: OPENTIMESTAMPS -->
|
|
<div id="pqOtsStep" class="pq-card pq-hidden">
|
|
<div class="pq-card-title">OpenTimestamps Attestation - Leave This Page Open</div>
|
|
<div class="pq-info-text">
|
|
Your event hash has already been submitted to OpenTimestamps in the previous step, and a
|
|
<strong>pending</strong> timestamp proof is embedded in the kind 9999 event you just published.
|
|
OpenTimestamps anchors that hash into the Bitcoin blockchain, proving your event existed at this
|
|
point in time — preventing a future quantum attacker from backdating a fraudulent event.
|
|
</div>
|
|
<div class="pq-info-text">
|
|
Bitcoin confirmation typically takes <strong>10-30 minutes</strong>. You can
|
|
<strong style="text-decoration: underline;">leave this page open</strong>
|
|
to watch the progress below — the polling log updates every minute. Once the Bitcoin attestation is
|
|
confirmed, this page will automatically sign and publish a <strong>new</strong> kind 9999 event carrying
|
|
the upgraded (confirmed) proof, referencing the original via an <code>upgrade_of</code> tag. The original
|
|
event remains on relays permanently. You're free to close the page; the workflow resumes on your next visit.
|
|
</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(--secondary-color); color: var(--primary-color); border: var(--border); 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-checklist" style="margin-bottom: 15px;">
|
|
<div class="pq-checklist-item pq-done" id="pqOtsSubmit">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>Submit hash to OpenTimestamps — pending .ots proof created</span>
|
|
</div>
|
|
<div class="pq-checklist-item pq-done" id="pqOtsPendingPublish">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>Pending proof embedded in kind 9999 event</span>
|
|
</div>
|
|
<div class="pq-checklist-item pq-active" id="pqOtsConfirm">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>Wait for Bitcoin confirmation
|
|
<div class="pq-key-meta" id="pqOtsConfirmDetail" style="margin-top: 2px;">Polling every 60 seconds...</div>
|
|
</span>
|
|
</div>
|
|
<div class="pq-checklist-item" id="pqOtsConfirmedPublish">
|
|
<span class="pq-checklist-box"></span>
|
|
<span>Publish new kind 9999 with confirmed proof — automatic on confirmation</span>
|
|
</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>
|
|
|
|
</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>
|
|
|
|
<!-- Version display: fetch version.json and update title + header -->
|
|
<script>
|
|
fetch('./js/version.json')
|
|
.then(r => r.json())
|
|
.then(v => {
|
|
const versionText = v.VERSION || '';
|
|
document.title = 'Post-Quantum Nostr ' + versionText;
|
|
const header = document.getElementById('divHeaderText');
|
|
if (header) header.textContent = 'Post-Quantum Nostr ' + versionText;
|
|
})
|
|
.catch(() => { /* version.json not found — keep default title */ });
|
|
</script>
|
|
|
|
<script type="module">
|
|
import {
|
|
generateSeedPhrase,
|
|
generateSeedPhraseWithEntropy,
|
|
mnemonicToSeed,
|
|
isValidMnemonic,
|
|
deriveSecp256k1FromSeed,
|
|
derivePQKeysFromSeed,
|
|
buildKind1Announcement,
|
|
buildProofCarrier,
|
|
computeEventId,
|
|
hashFullEvent,
|
|
verifyNIPQRContent,
|
|
verifyNostrEvent,
|
|
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;
|
|
pqEvent = { id: pendingOts.eventId, kind: pendingOts.targetKind || NIP_QR_KIND };
|
|
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) {
|
|
// Try to find the one matching our persisted event ID
|
|
const matching = allCandidates.find(e => e.id === pqEvent.id);
|
|
if (matching) {
|
|
pqEvent = matching;
|
|
otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays (${allCandidates.length} total candidates).`);
|
|
} else {
|
|
// Use the first candidate as fallback
|
|
pqEvent = allCandidates[0];
|
|
otsLog(`Resuming OTS workflow: persisted event not found, using ${pqEvent.id.substring(0, 16)}... (${allCandidates.length} total candidates).`);
|
|
}
|
|
} 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);
|
|
// 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 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);
|
|
|
|
pqEvent = {
|
|
id: proofCarrierSigned.id,
|
|
pubkey: proofCarrierSigned.pubkey,
|
|
created_at: proofCarrierTemplate.created_at,
|
|
kind: NIP_QR_KIND,
|
|
content: proofCarrierTemplate.content,
|
|
tags: proofCarrierTemplate.tags,
|
|
sig: proofCarrierSigned.sig
|
|
};
|
|
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.`);
|
|
|
|
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
|
|
});
|
|
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
|
|
});
|
|
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
|
|
});
|
|
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. 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);
|
|
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 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 = signedEvent;
|
|
savePendingOts(pqEvent.id, upgradedOtsBytes, {
|
|
ownerPubkey: currentPubkey,
|
|
targetKind: NIP_QR_KIND,
|
|
relayUrls: configuredRelays,
|
|
pendingPublished: true,
|
|
confirmedPublished: true
|
|
});
|
|
|
|
setStatus(otsStatus, 'success', `Upgraded proof carrier 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('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');
|
|
})();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|