Files
archy/neode-ui/public/entropy/index.html
T
archipelagoandClaude Fable 5 45fa8b6c6f
Demo images / Build & push demo images (push) Successful in 4m19s
feat(neode-ui): seed & entropy explainer page at /entropy/ + link from Backup settings
Standalone static guide (same pattern as /architecture/) covering how the
master seed entropy is drawn (explicit OsRng, sealed KeyGenRng allowlist,
degenerate-draw refusal, CSPRNG readiness ledger), how it is stored
(Argon2 + ChaCha20-Poly1305 envelope), the full derivation tree (HKDF
labels, NIP-06, LND aezeed one-way gate, second-order keys), what is NOT
seed-derived, every failure/fallback path, and the restore flow — in
paired layman/technical language. Linked from the Recovery-phrase card
in Settings → Backup. CSP-safe: no inline scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:56:34 -04:00

1020 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Archipelago — Seed &amp; Entropy Guide</title>
<style>
:root {
--bg: #000000;
--glass-card: rgba(0, 0, 0, 0.65);
--glass-dark: rgba(0, 0, 0, 0.35);
--glass-darker: rgba(0, 0, 0, 0.6);
--glass-border: rgba(255, 255, 255, 0.18);
--glass-highlight: rgba(255, 255, 255, 0.22);
--glass-blur: 18px;
--glass-blur-strong: 24px;
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
--text: rgba(255, 255, 255, 0.9);
--text-muted: rgba(255, 255, 255, 0.6);
--accent: #fb923c;
--accent-dim: rgba(251, 146, 60, 0.15);
--green: #4ade80;
--green-dim: rgba(74, 222, 128, 0.15);
--red: #ef4444;
--red-dim: rgba(239, 68, 68, 0.12);
--blue: #3b82f6;
--blue-dim: rgba(59, 130, 246, 0.12);
--yellow: #facc15;
--yellow-dim: rgba(250, 204, 21, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--radius: 16px;
--radius-sm: 12px;
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
font-size: 16px;
}
nav {
position: fixed;
top: 0; left: 0;
width: 280px;
height: 100vh;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border-right: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass);
overflow-y: auto;
padding: 24px 0;
z-index: 100;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
}
nav .logo { padding: 0 24px 20px; margin-bottom: 16px; }
nav .logo h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 18px; font-weight: 700;
color: var(--accent); letter-spacing: -0.02em;
}
nav .logo p { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
nav .nav-section {
padding: 12px 16px 4px;
font-size: 10px; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
nav a {
display: block;
padding: 6px 24px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
transition: all var(--transition);
border-left: 2px solid transparent;
}
nav a:hover, nav a.active {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
border-left-color: var(--accent);
}
main {
margin-left: 280px;
max-width: 960px;
padding: 48px 48px 120px;
}
h2 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 28px; font-weight: 700;
margin: 64px 0 8px;
padding-top: 24px;
color: var(--text);
letter-spacing: -0.02em;
}
h2:first-of-type { margin-top: 0; }
h3 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 20px; font-weight: 600;
margin: 40px 0 12px;
color: var(--text);
}
h4 {
font-size: 16px; font-weight: 600;
margin: 24px 0 8px;
color: var(--accent);
}
p { margin: 8px 0 16px; color: var(--text); }
ul, ol { margin: 8px 0 16px 24px; color: var(--text); }
li { margin: 4px 0; }
.subtitle {
font-size: 15px;
color: var(--text-muted);
margin-bottom: 32px;
}
.hero { text-align: center; padding: 48px 0 56px; margin-bottom: 24px; }
.hero h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 42px; font-weight: 800;
background: linear-gradient(135deg, var(--accent), #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.03em;
}
.hero .tagline {
font-size: 18px;
color: var(--text-muted);
margin: 12px auto 0;
max-width: 640px;
}
.hero .meta {
margin-top: 20px;
display: flex; gap: 16px;
justify-content: center; flex-wrap: wrap;
}
.hero .meta span {
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
background: var(--glass-dark);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
color: var(--text-muted);
}
.card {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 16px 0;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin: 16px 0;
}
.card-sm {
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px 20px;
transition: transform var(--transition), box-shadow var(--transition);
}
.card-sm:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
.card-sm p { font-size: 13px; color: var(--text-muted); margin: 0; }
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 2px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: var(--red); }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-blue { background: var(--blue-dim); color: var(--blue); }
.badge-purple { background: var(--purple-dim); color: var(--purple); }
.badge-accent { background: var(--accent-dim); color: var(--accent); }
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 16px 0;
font-size: 13px;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-glass);
}
th {
text-align: left;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.4);
color: var(--text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--glass-border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255, 255, 255, 0.04); }
code {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
background: rgba(0, 0, 0, 0.4);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent);
}
pre {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 20px;
overflow-x: auto;
margin: 16px 0;
font-size: 13px;
line-height: 1.6;
box-shadow: var(--shadow-glass);
}
pre code { background: none; padding: 0; color: var(--text); }
.diagram {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 20px 0;
overflow-x: auto;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
white-space: pre;
}
.diagram .highlight { color: var(--accent); font-weight: 600; }
.diagram .green { color: var(--green); }
.diagram .blue { color: var(--blue); }
.diagram .red { color: var(--red); }
.diagram .purple { color: var(--purple); }
.diagram .yellow { color: var(--yellow); }
.callout {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 16px 20px;
margin: 16px 0;
font-size: 14px;
border-left: 3px solid;
box-shadow: var(--shadow-glass);
}
.callout-info { border-color: var(--blue); }
.callout-warn { border-color: var(--yellow); }
.callout-danger { border-color: var(--red); }
.callout-success { border-color: var(--green); }
.callout-learn {
border-color: var(--purple);
background: rgba(167, 139, 250, 0.06);
position: relative;
padding-top: 32px;
}
.callout-learn::before {
content: 'In Plain Words';
position: absolute;
top: 10px; left: 20px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--purple);
}
.callout strong { display: block; margin-bottom: 4px; }
.score-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 20px 0;
}
.score-card {
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px;
text-align: center;
transition: transform var(--transition);
}
.score-card:hover { transform: translateY(-2px); }
.score-card .score {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 28px; font-weight: 800;
margin: 4px 0;
color: var(--accent);
}
.score-card .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.steps { counter-reset: step; margin: 20px 0; }
.step {
position: relative;
padding: 16px 20px 16px 64px;
margin: 12px 0;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-glass);
counter-increment: step;
}
.step::before {
content: counter(step);
position: absolute;
left: 18px; top: 50%;
transform: translateY(-50%);
width: 30px; height: 30px;
border-radius: 50%;
background: var(--accent-dim);
color: var(--accent);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
line-height: 30px;
text-align: center;
}
.step h4 { margin: 0 0 4px; }
.step p { margin: 0; font-size: 13px; color: var(--text-muted); }
hr {
border: none;
border-top: 1px solid var(--glass-border);
margin: 48px 0;
}
@media (max-width: 900px) {
nav { display: none; }
main { margin-left: 0; padding: 20px; }
.hero h1 { font-size: 32px; }
}
</style>
</head>
<body>
<nav>
<div class="logo">
<h1>Archipelago</h1>
<p>Seed &amp; Entropy Guide</p>
</div>
<div class="nav-section">Overview</div>
<a href="#intro">Introduction</a>
<a href="#big-picture">The Big Picture</a>
<div class="nav-section">Creation</div>
<a href="#randomness">Where Randomness Comes From</a>
<a href="#when">When the Seed Is Born</a>
<a href="#guardrails">The Five Guardrails</a>
<a href="#hardening">Recent Hardening</a>
<div class="nav-section">Storage</div>
<a href="#storage">What's Kept on Disk</a>
<a href="#envelope">The Encrypted Envelope</a>
<div class="nav-section">Derivation</div>
<a href="#tree">The Derivation Tree</a>
<a href="#lightning">The Lightning Special Case</a>
<a href="#second-order">Second-Order Keys</a>
<a href="#independent">What Is NOT Derived</a>
<div class="nav-section">Failure &amp; Recovery</div>
<a href="#failures">Failure Modes &amp; Fallbacks</a>
<a href="#restore">The Restore Flow</a>
<a href="#comes-back">What Comes Back (and What Doesn't)</a>
<a href="#seedqr">SeedQR</a>
<div class="nav-section">Assurance</div>
<a href="#limitations">Honest Edges</a>
<a href="#verify">Verify It Yourself</a>
</nav>
<main>
<div class="hero">
<h1>Your Node's Seed &amp; Entropy</h1>
<p class="tagline">How 32 random bytes become every key your node owns — where that randomness comes from, what protects it, and exactly what your 24 words can (and cannot) bring back.</p>
<div class="meta">
<span>256-bit entropy</span>
<span>BIP-39 · 24 words</span>
<span>HKDF-SHA256 derivation</span>
<span>Kernel CSPRNG only</span>
<span>KEY-05 hardened</span>
</div>
</div>
<h2 id="intro">Introduction</h2>
<p class="subtitle">One master secret, many keys — by design.</p>
<p>
Almost everything cryptographic on this node — its identity, its Nostr keys, its mesh
transport keys, its Lightning wallet — grows from a <strong>single master seed</strong>:
32 bytes (256 bits) of randomness drawn once, shown to you once as a 24-word recovery
phrase, and never stored in raw form anywhere.
</p>
<div class="callout callout-learn">
<strong>The tree and the acorn</strong>
Think of the seed as an acorn. Every branch of the tree — your identity, your wallet,
your mesh radio's name — grows from it in a fixed, repeatable pattern. Plant the same
acorn on new hardware (by typing your 24 words) and the <em>same tree</em> grows back,
branch for branch. That's why those words are the single most valuable thing your node
ever shows you — and why anyone who copies them owns your tree.
</div>
<p>
This page explains, in both plain and technical language: where those 32 bytes come from,
the guardrails that make sure they're genuinely random, how they're protected on disk,
everything that is derived from them, everything that deliberately <em>isn't</em>, and
what happens in every failure and recovery scenario.
</p>
<h2 id="big-picture">The Big Picture</h2>
<p class="subtitle">From kernel randomness to every key on the node.</p>
<div class="diagram">
<span class="blue">Linux kernel CSPRNG</span> (hardware noise: interrupts, timing jitter, CPU RNG)
│ getrandom(2) — via an explicitly named <span class="highlight">OsRng</span>, nothing else allowed
<span class="highlight">32 bytes of raw entropy</span> ──▶ degenerate-draw check ──▶ <span class="red">refuse &amp; wipe if suspicious</span>
│ BIP-39 encoding
<span class="green">24-word recovery phrase</span> ←── the only form you ever see or back up
│ PBKDF2-HMAC-SHA512 × 2048 (empty passphrase)
<span class="highlight">64-byte master seed</span> ←── lives only in RAM, never written to disk
├── HKDF "archipelago/node/ed25519/v1" ──▶ <span class="green">Node identity key + DID</span>
├── HKDF "archipelago/nostr-node/…/v1" ──▶ <span class="purple">Node Nostr key (npub)</span>
├── HKDF "archipelago/fips/secp256k1/v1" ──▶ <span class="blue">FIPS mesh transport key</span>
├── HKDF "archipelago/identity/{i}/…/v1" ──▶ <span class="green">Personal identities (Ed25519)</span>
├── BIP-32 m/44'/1237'/0'/0/{i} (NIP-06) ──▶ <span class="purple">Personal Nostr keys</span>
├── HKDF "archipelago/lnd/entropy/v1" ──▶ <span class="yellow">Lightning wallet entropy → aezeed</span>
└── BIP-32 m/84'/0'/0' ──▶ <span class="yellow">Bitcoin xprv (dormant, reserved)</span>
│ and from the node identity key, second-order:
├── <span class="blue">Reticulum / RNS mesh identity</span> (HKDF, salt "archipelago-reticulum-identity-v1")
├── <span class="green">Message-store encryption key</span> (SHA-256 domain-separated)
├── <span class="green">Mesh-contacts encryption key</span> (SHA-256 domain-separated)
└── <span class="green">Credential-store key</span> (SHA-256 domain-separated)
</div>
<div class="callout callout-info">
<strong>One rule to remember</strong>
If it's on the diagram above, your 24 words can rebuild it from scratch, on any hardware,
forever. If it's not on the diagram (session tokens, app passwords, WireGuard keys,
the Lightning channel state…), it's independent randomness — protected by other backups,
not by the words.
</div>
<h2 id="randomness">Where Randomness Comes From</h2>
<p class="subtitle">One named source. No mixing. No silent defaults.</p>
<div class="callout callout-learn">
<strong>Dice you can audit</strong>
Computers can't invent randomness — they collect it. The Linux kernel constantly harvests
unpredictable physical noise (the exact nanosecond your network card interrupts, timing
jitter between CPU cores, the CPU's built-in hardware random generator) and distils it
into a cryptographic random pool. Archipelago rolls its dice by asking that pool
directly — and <em>only</em> that pool. There is deliberately no "mixing" of other
sources, because a single, named, well-studied source is auditable; a blend of
sources is a place for bugs to hide.
</div>
<h3>Technically</h3>
<p>
The master seed is generated by <code>MasterSeed::generate()</code> in
<code>core/archipelago/src/seed.rs</code>. It fills a 32-byte buffer using
<code>rand::rngs::OsRng</code> — a thin wrapper around the
<code>getrandom(2)</code> system call, which reads the kernel's CSPRNG
(the same source as <code>/dev/urandom</code>, but immune to file-descriptor
exhaustion and chroot tricks).
</p>
<ul>
<li><strong>Exactly 32 bytes / 256 bits</strong> — the maximum BIP-39 strength, encoding to 24 words.</li>
<li><strong>The RNG is named at the call site.</strong> No function anywhere in the codebase generates key material with a "default" or implicit RNG anymore (see <a href="#hardening">Recent Hardening</a> for why this is stated so emphatically).</li>
<li><strong>The RNG type is compiler-enforced.</strong> Key-generation functions only accept RNGs on a <em>sealed allowlist</em> (<code>KeyGenRng</code> in <code>core/archipelago/src/entropy.rs</code>). The list has exactly one production member: <code>OsRng</code>. No other module — not even a future refactor — can add a weaker RNG without editing the allowlist file itself.</li>
<li><strong>The buffer is zeroized</strong> (securely wiped from memory) on every path, success or failure.</li>
</ul>
<pre><code>// core/archipelago/src/seed.rs — the actual draw
let mut entropy = [0u8; 32];
crate::entropy::draw_key_bytes(&amp;mut rand::rngs::OsRng, &amp;mut entropy)?; // guarded draw
let mnemonic = bip39::Mnemonic::from_entropy(&amp;entropy)?; // → 24 words
entropy.zeroize(); // wipe the raw bytes</code></pre>
<p>
The 24 words are then stretched into the 64-byte master seed using standard BIP-39:
<code>PBKDF2-HMAC-SHA512</code>, 2048 rounds, empty passphrase. (There is no "25th word"
passphrase — your login password protects the stored backup instead, see
<a href="#envelope">The Encrypted Envelope</a>.) That 64-byte seed exists
<strong>only in memory</strong>, is re-computed from the words whenever needed, and is
never written to disk in any form.
</p>
<h2 id="when">When the Seed Is Born</h2>
<p class="subtitle">At onboarding — not at first boot.</p>
<div class="steps">
<div class="step">
<h4>First boot: a placeholder key</h4>
<p>A freshly-flashed node boots with a random <em>temporary</em> identity key so services can start. This key is NOT seed-derived and is about to be thrown away.</p>
</div>
<div class="step">
<h4>Onboarding: the real draw</h4>
<p>When you reach the "Recovery phrase" step of setup, the <code>seed.generate</code> RPC performs the guarded 32-byte draw described above and shows you the 24 words — the only time they're ever displayed unprompted.</p>
</div>
<div class="step">
<h4>Derivation: keys are materialised</h4>
<p>The node identity key, DID, Nostr key, FIPS mesh key, and your first personal identity are all derived from the seed and written to <code>/var/lib/archipelago/identity/</code> (each file mode 0600). The placeholder key from step 1 is overwritten.</p>
</div>
<div class="step">
<h4>Password setup: the backup is sealed</h4>
<p>When you set your login password, the 24 words are encrypted under it (Argon2 + ChaCha20-Poly1305) and stored as <code>master_seed.enc</code> — so you can re-reveal them later from Settings → Backup.</p>
</div>
</div>
<div class="callout callout-info">
<strong>Refresh-proof by design</strong>
If the onboarding page is retried within 10 minutes (browser refresh, flaky connection),
the node returns the <em>same</em> words instead of minting a second seed. Generation is
serialised behind a lock — there is no window where two competing seeds can exist.
</div>
<h2 id="guardrails">The Five Guardrails</h2>
<p class="subtitle">Defence in depth around a single random draw.</p>
<div class="card-grid">
<div class="card-sm">
<h4><span class="badge badge-accent">1</span> &nbsp;Sealed RNG allowlist</h4>
<p>Key draws only compile against RNG types on a closed, private allowlist. Production allowlist: <code>OsRng</code>. Full stop. A refactor that swaps in a weak or deterministic RNG becomes a <em>compile error</em>, not a silent disaster.</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-accent">2</span> &nbsp;Degenerate-draw refusal</h4>
<p>Every draw is inspected for three tell-tale broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is <strong>refused and wiped — never retried</strong>, because retrying would mask a broken RNG instead of exposing it.</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-accent">3</span> &nbsp;CSPRNG readiness ledger</h4>
<p>Before generating the seed, the node probes whether the kernel's random pool is fully initialised, and appends the verdict to a tamper-evident, append-only log at <code>security/csprng-readiness.jsonl</code> (0600). You can audit, forever, the entropy conditions your seed was born under.</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-accent">4</span> &nbsp;Build-time lint bans</h4>
<p>The CI lint config bans <code>rand::random()</code> and <code>rand::thread_rng()</code> outright across the workspace — the two "convenient" RNG entry points that caused real-world wallet disasters elsewhere. Using either fails the build.</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-accent">5</span> &nbsp;Zeroization everywhere</h4>
<p>Raw entropy, mnemonics, and derived secrets are wiped from memory on every code path — including error paths — so key material doesn't linger in freed RAM or end up in crash dumps.</p>
</div>
</div>
<div class="callout callout-learn">
<strong>Why so paranoid about one function?</strong>
In 2026 a well-known hardware wallet shipped a bug where a code refactor quietly switched
seed generation to a <em>predictable</em> random source — with no error, no warning, and
seeds that looked perfectly normal. Predictable randomness is invisible: the words look
random, the wallet works, and months later someone who can predict the generator drains
it. Archipelago's answer is to make that entire class of bug <em>impossible to compile</em>,
and to log the health of the random pool at the exact moment your seed was created.
</div>
<h3>What the degenerate check does — and doesn't — do</h3>
<p>
The check (<code>is_degenerate</code> in <code>entropy.rs</code>) is deliberately
<em>closed-form</em>: it recognises exactly three catastrophic failure shapes
(all-zero, all-identical, ±1 counter). It is <strong>not</strong> a statistical entropy
estimator — those can't actually distinguish good randomness from a cleverly broken RNG,
and they introduce false positives. The real security load is carried by guardrails 1,
3 and 4; the degenerate check is a tripwire for total RNG failure (e.g. a zeroed buffer
that was never filled).
</p>
<h2 id="hardening">Recent Hardening (the KEY-05 work)</h2>
<p class="subtitle">This system was audited and rebuilt in early August 2026.</p>
<p>
Triggered by the COLDCARD-class of entropy defects, a full entropy &amp; seed-generation
audit (<code>docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md</code>) reviewed every random
draw in the codebase. The headline finding: the library used for mnemonic generation was
silently choosing its own RNG via a transitive default. It happened to be a secure one —
but nothing <em>guaranteed</em> that, and a dependency update could have changed it
without any diff in Archipelago's own code.
</p>
<table>
<tr><th>Date</th><th>Change</th></tr>
<tr><td>Jul 30</td><td>Kernel CSPRNG readiness probe added; RNG non-determinism regression test (64 consecutive mnemonics must all be unique).</td></tr>
<tr><td>Jul 31</td><td>Full entropy audit published (findings F-01…F-13).</td></tr>
<tr><td>Aug 1</td><td><strong>The pivotal fix:</strong> master-seed RNG made explicit — <code>OsRng</code> named at the call site, injected through a testable seam, pinned by a known-answer test.</td></tr>
<tr><td>Aug 2</td><td>Audit widened: 43 defaulted-RNG call sites across 15 files found and migrated to explicit <code>OsRng</code> — including AEAD nonces and ecash key material.</td></tr>
<tr><td>Aug 2</td><td>Onboarding RPCs gated: <code>seed.restore</code> and friends now refuse on an already-provisioned node (previously an unauthenticated restore call could hijack a live node — fixed before any release shipped it).</td></tr>
<tr><td>Aug 2</td><td>KEY-05 enforcement layer landed: sealed <code>KeyGenRng</code> allowlist, guarded draws, durable readiness ledger, clippy bans, supply-chain version pinning on the <code>rand</code> crate.</td></tr>
<tr><td>Aug 2</td><td>Legacy Bitcoin Core wallet-import path deleted — the master xprv is no longer handed to any external wallet process.</td></tr>
</table>
<h2 id="storage">What's Kept on Disk</h2>
<p class="subtitle">The words, encrypted — and the keys, materialised. Never the raw seed.</p>
<table>
<tr><th>File</th><th>Contents</th><th>Protection</th></tr>
<tr><td><code>identity/master_seed.enc</code></td><td>Your 24 words (as text), encrypted</td><td>Argon2(login password) + ChaCha20-Poly1305, mode 0600</td></tr>
<tr><td><code>identity/node_key</code> / <code>.pub</code></td><td>Node Ed25519 identity key</td><td>0600, seed-derived (recoverable from words)</td></tr>
<tr><td><code>identity/nostr_secret</code> / <code>nostr_pubkey</code></td><td>Node Nostr keypair</td><td>0600, seed-derived</td></tr>
<tr><td><code>identity/fips_key</code> / <code>.pub</code></td><td>FIPS mesh transport key (bech32 nsec)</td><td>0600, seed-derived</td></tr>
<tr><td><code>identity/identity_index</code></td><td>Next unused identity derivation index</td><td>Plain integer (not secret)</td></tr>
<tr><td><code>identities/&lt;uuid&gt;.json</code></td><td>Personal identity records (keys + metadata)</td><td>0600; keys seed-derived, <em>metadata is not</em></td></tr>
<tr><td><code>identity/lnd_aezeed.enc</code></td><td>Lightning wallet's own seed (see <a href="#lightning">below</a>)</td><td>Encrypted under the LND wallet password</td></tr>
<tr><td><code>security/csprng-readiness.jsonl</code></td><td>Append-only entropy audit trail</td><td>0600; deliberately outside <code>identity/</code> so restores never touch it</td></tr>
</table>
<div class="callout callout-success">
<strong>The raw seed never touches disk</strong>
What's stored is the <em>encrypted words</em> and the <em>derived keys</em>. The 64-byte
master seed itself is recomputed in RAM from the words when needed (e.g. during restore
or identity creation) and wiped afterwards.
</div>
<h2 id="envelope">The Encrypted Envelope</h2>
<p class="subtitle">How <code>master_seed.enc</code> is built.</p>
<div class="diagram">
your login password ──▶ <span class="highlight">Argon2id</span> (memory-hard KDF) ──▶ 256-bit file key
16-byte random salt
24 words ──▶ <span class="highlight">ChaCha20-Poly1305</span> (authenticated encryption, 12-byte random nonce)
┌───────────┬────────────┬──────────────────────────────┐
│ salt (16) │ nonce (12) │ ciphertext + auth tag │ = master_seed.enc
└───────────┴────────────┴──────────────────────────────┘
</div>
<div class="callout callout-learn">
<strong>A safe inside a safe</strong>
Your words are locked in a digital safe whose combination is your login password —
but run through a deliberately slow, memory-hungry grinder (Argon2) so that guessing
billions of passwords per second is physically impractical, even for someone who steals
the file. The "auth tag" means the safe also notices if anyone has tampered with its
contents: a corrupted or modified file fails loudly rather than yielding wrong words.
</div>
<p>
Revealing the words later (Settings → Backup → <em>Reveal</em>) requires an authenticated
session <em>plus</em> re-entering your password <em>plus</em> your 2FA code if enabled,
is rate-limited, and the words are returned only to your browser — never written to logs.
</p>
<h2 id="tree">The Derivation Tree</h2>
<p class="subtitle">Every key, its exact derivation, and where it lands.</p>
<div class="callout callout-learn">
<strong>One password, many doors — without reuse</strong>
The node never uses the master seed directly as a key. Instead it uses HKDF — think of it
as a locksmith who, given one master blank and a <em>label</em> ("node key", "mesh key",
"Lightning entropy"…), cuts a completely different, unrelated key for each label. Knowing
one cut key tells you nothing about the others or about the blank. The labels are fixed
strings baked into the code, which is what makes regrowing the identical tree on new
hardware possible.
</div>
<table>
<tr><th>Key</th><th>Derivation</th><th>Label / path</th><th>Materialised at</th></tr>
<tr><td><strong>Node identity (Ed25519)</strong> — signs everything, forms your DID</td><td>HKDF-SHA256</td><td><code>archipelago/node/ed25519/v1</code></td><td><code>identity/node_key</code></td></tr>
<tr><td><strong>Node Nostr key (secp256k1)</strong> — the node's npub</td><td>HKDF-SHA256</td><td><code>archipelago/nostr-node/secp256k1/v1</code></td><td><code>identity/nostr_secret</code></td></tr>
<tr><td><strong>FIPS mesh transport key</strong> — federation/mesh overlay identity</td><td>HKDF-SHA256</td><td><code>archipelago/fips/secp256k1/v1</code></td><td><code>identity/fips_key</code></td></tr>
<tr><td><strong>Personal identity #i (Ed25519)</strong></td><td>HKDF-SHA256</td><td><code>archipelago/identity/{i}/ed25519/v1</code></td><td><code>identities/&lt;uuid&gt;.json</code></td></tr>
<tr><td><strong>Personal Nostr key #i</strong> — standard NIP-06, importable into other Nostr apps</td><td>BIP-32</td><td><code>m/44'/1237'/0'/0/{i}</code></td><td><code>identities/&lt;uuid&gt;.json</code></td></tr>
<tr><td><strong>Lightning (LND) wallet entropy</strong> — 16 bytes</td><td>HKDF-SHA256</td><td><code>archipelago/lnd/entropy/v1</code></td><td>fed into LND at wallet init (see below)</td></tr>
<tr><td><strong>Bitcoin BIP-84 account xprv</strong> — native-segwit</td><td>BIP-32</td><td><code>m/84'/0'/0'</code></td><td><em>nowhere</em> — dormant, reserved for a future on-node cold vault</td></tr>
<tr><td><strong>Release-root signing key</strong> — fleet update signing</td><td>HKDF-SHA256</td><td><code>archipelago/release/root/ed25519/v1</code></td><td><em>never on a node</em> — derived offline by the publisher from a separate release mnemonic; nodes only pin the public key</td></tr>
</table>
<p>
All HKDF derivations are HKDF-SHA256 with a distinct, versioned info-label (the
<code>/v1</code> suffix means a future algorithm migration can introduce
<code>/v2</code> labels without ambiguity). Personal Nostr keys intentionally use the
NIP-06 standard path instead of HKDF so that <em>the same 24 words typed into any
NIP-06-compliant Nostr client reproduce the same npub</em> — your social identity is
portable beyond Archipelago.
</p>
<h2 id="lightning">The Lightning Special Case</h2>
<p class="subtitle">The one branch of the tree with a one-way gate in it.</p>
<div class="diagram">
master seed ──HKDF──▶ 16 bytes of entropy ──▶ <span class="yellow">LND generates its own "aezeed"</span> ──▶ Lightning wallet
│ ⚠ one-way: the aezeed cannot be
│ recomputed from your 24 words later
captured ONCE at wallet init and stored as
<span class="highlight">identity/lnd_aezeed.enc</span> (encrypted)
</div>
<p>
LND (the Lightning node) uses its own seed format called <em>aezeed</em>, which is not
BIP-39. Archipelago derives deterministic entropy from your master seed and hands it to
LND at wallet-creation time — but LND then wraps it with its own internal random salt,
so the resulting aezeed <strong>cannot be re-derived from your 24 words afterwards</strong>.
The node therefore captures the aezeed exactly once, at init, and stores it encrypted
alongside your other identity files. The Lightning seed backup screen shows it to you
with the same tap-to-reveal flow as the main phrase.
</p>
<div class="callout callout-warn">
<strong>Back up the Lightning seed separately</strong>
Your 24 words alone restore your node identity and on-chain derivations — but
<em>not</em> an already-initialised Lightning wallet, and never the off-chain channel
balances (those additionally need channel backups, which Lightning requires by design).
Treat the LND aezeed shown in the Lightning app's backup screen as a second phrase
worth writing down. Note it restores into LND-based wallets (Zeus, Blixt, another
Archipelago node) — hardware wallets can't import it.
</div>
<h2 id="second-order">Second-Order Keys</h2>
<p class="subtitle">Derived from the node key — so still fully recoverable from the words.</p>
<p>
Several subsystems derive their keys from the <em>node identity key</em> rather than the
master seed directly. Since the node key is itself seed-derived, these all regrow from
your 24 words too — the chain is <code>words → master seed → node key → subsystem key</code>.
</p>
<table>
<tr><th>Subsystem</th><th>Derivation from <code>node_key</code></th></tr>
<tr><td><strong>Reticulum / RNS mesh identity</strong> (LoRa long-range mesh)</td><td>HKDF-SHA256, salt <code>archipelago-reticulum-identity-v1</code>, two labels for the X25519 + Ed25519 halves — yields a stable LXMF address that survives reinstalls</td></tr>
<tr><td><strong>Message-store encryption</strong> (chats at rest)</td><td><code>SHA-256("archipelago-message-store-v1" ‖ node_key)</code></td></tr>
<tr><td><strong>Mesh contacts encryption</strong></td><td><code>SHA-256("archipelago-mesh-contacts-v1" ‖ node_key)</code></td></tr>
<tr><td><strong>Credential store</strong> (saved app credentials)</td><td>Same domain-separated SHA-256 pattern</td></tr>
</table>
<div class="callout callout-learn">
<strong>Domain separation, in one sentence</strong>
Each purpose prefixes a unique fixed string before hashing, so even though they start
from the same node key, every subsystem ends up with an unrelated key — compromising
one never exposes another.
</div>
<h2 id="independent">What Is NOT Derived From the Seed</h2>
<p class="subtitle">Independent randomness — deliberately outside the tree.</p>
<p>
Plenty of secrets are <em>freshly random</em> rather than seed-derived. That's intentional:
things that should die with a session, rotate freely, or belong to a third-party app must
not be recoverable from your words (and mostly you wouldn't want them to be).
</p>
<div class="card-grid">
<div class="card-sm">
<h4>Ephemeral by design</h4>
<p>Session tokens, device/companion pairing tokens, federation invites, TOTP/2FA secrets and backup codes, all encryption nonces, X3DH ephemeral mesh keys, anonymous marketplace/discovery Nostr keys.</p>
</div>
<div class="card-sm">
<h4>App-owned secrets</h4>
<p>Every manifest-declared <code>generated_secret</code> (app database passwords, API keys), Bitcoin RPC credentials, the LND wallet <em>password</em> (distinct from its seed), Home Assistant tokens.</p>
</div>
<div class="card-sm">
<h4>Host-level material</h4>
<p>WireGuard VPN keypairs (generated by <code>wg genkey</code>), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.</p>
</div>
<div class="card-sm">
<h4>Opt-outs from derivability</h4>
<p>Extra identities created with "new random key" instead of seed derivation, and a node key after an explicit <code>rotate-key</code> — rotation <em>deliberately</em> breaks the link to the words, and says so.</p>
</div>
</div>
<div class="callout callout-info">
<strong>Consequence for backups</strong>
A seed-only restore brings back everything in the derivation tree but none of the above.
App data, chat history and app secrets travel via the separate node backup/restore
feature; Lightning channels need channel backups; VPN peers re-pair. Your words are your
<em>identity's</em> lifeboat, not a full-system image.
</div>
<h2 id="failures">Failure Modes &amp; Fallbacks</h2>
<p class="subtitle">What happens when something goes wrong — at every stage.</p>
<table>
<tr><th>Scenario</th><th>Behaviour</th><th>Outcome</th></tr>
<tr>
<td>RNG returns a degenerate pattern (all-zero / repeated / counter)</td>
<td>Draw is <strong>refused and wiped, never retried</strong>; error logged; onboarding fails loudly</td>
<td><span class="badge badge-red">No seed created</span></td>
</tr>
<tr>
<td>Kernel random pool not yet initialised (exotic first-boot case)</td>
<td><code>getrandom(2)</code> <strong>blocks</strong> until the pool is seeded — an unseeded pool physically cannot produce a seed. The advisory probe logs a warning and records the verdict in the readiness ledger</td>
<td><span class="badge badge-yellow">Waits, then proceeds</span></td>
</tr>
<tr>
<td>Onboarding page refreshed / retried mid-generation</td>
<td>Same words returned for 10 minutes (mutex-serialised); no second seed can be minted</td>
<td><span class="badge badge-green">Idempotent</span></td>
</tr>
<tr>
<td><code>master_seed.enc</code> missing (e.g. backup write failed during setup)</td>
<td>Node runs normally — every derived key is already materialised on disk. Only the <em>Reveal</em> feature and future re-derivation are unavailable; the UI says so explicitly</td>
<td><span class="badge badge-yellow">Degraded, functional</span></td>
</tr>
<tr>
<td><code>master_seed.enc</code> corrupt, or wrong password at reveal</td>
<td>Authenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regeneration</td>
<td><span class="badge badge-red">Fails loudly</span></td>
</tr>
<tr>
<td>Restore attempted on an already-provisioned node</td>
<td>The onboarding gate refuses identity-mutating RPCs once the node is set up — a live node cannot be hijacked or accidentally re-seeded</td>
<td><span class="badge badge-green">Refused</span></td>
</tr>
<tr>
<td>Legacy / corrupt on-disk FIPS key format</td>
<td>Self-heals: legacy raw-byte format is detected and migrated in place to the current bech32 format</td>
<td><span class="badge badge-green">Auto-migrated</span></td>
</tr>
<tr>
<td>Readiness-ledger write fails (disk full, permissions)</td>
<td>Warns and continues — the audit trail is best-effort and can <em>never</em> block key generation</td>
<td><span class="badge badge-yellow">Non-blocking</span></td>
</tr>
</table>
<div class="callout callout-danger">
<strong>The one true single point of failure is you</strong>
Every software failure above fails <em>safe</em>. The only unrecoverable scenario is
losing the 24 words <em>and</em> the node's disk together. Write the words down, store
them offline, and never type them into anything except a node you're restoring.
Anyone holding them can rebuild your entire identity tree — that's precisely
what makes them a perfect backup, and a perfect target.
</div>
<h2 id="restore">The Restore Flow</h2>
<p class="subtitle">Typing 24 words into a fresh node, step by step.</p>
<div class="steps">
<div class="step">
<h4>Gate check</h4>
<p>Restore only proceeds on an un-onboarded node. A provisioned node refuses — this gate is load-bearing and runs before anything else.</p>
</div>
<div class="step">
<h4>Validation</h4>
<p>Exactly 24 words, checked against the BIP-39 wordlist and its built-in checksum — a typo is caught here, before anything is written.</p>
</div>
<div class="step">
<h4>Identity regrowth</h4>
<p>Node Ed25519 key + DID, node Nostr key, and FIPS mesh key are re-derived and written — byte-identical to the originals, because the HKDF labels are fixed.</p>
</div>
<div class="step">
<h4>Personal identity #0</h4>
<p>The identity index resets to 0 and your default "Personal" identity (Ed25519 + NIP-06 Nostr key) is recreated. If you had created more seed-derived identities, their keys re-derive on demand as the index walks forward — but their names, avatars and profiles were metadata, not key material, and come from a node backup instead.</p>
</div>
<div class="step">
<h4>Mesh reactivation</h4>
<p>FIPS federation auto-activation kicks off in the background; the Reticulum identity re-derives from the restored node key, so your LXMF mesh address comes back too.</p>
</div>
<div class="step">
<h4>Password &amp; re-seal</h4>
<p>When you set the (new) login password, the words are re-encrypted into a fresh <code>master_seed.enc</code> — the reveal feature works on the restored node just like the original.</p>
</div>
</div>
<h2 id="comes-back">What Comes Back — and What Doesn't</h2>
<div class="card-grid">
<div class="card-sm">
<h4><span class="badge badge-green">Restored by the words</span></h4>
<p>Node identity &amp; DID · node npub · FIPS mesh key · Reticulum/LXMF address · personal identity keys &amp; npubs · message-store / contacts / credential encryption keys · the (dormant) Bitcoin xprv · the ability to reveal the phrase again.</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-yellow">Needs its own backup</span></h4>
<p>Lightning wallet (aezeed — one-way gate, see above) and channel state · chat history &amp; app data (node backup) · identity names/avatars (metadata) · app secrets (regenerate on reinstall).</p>
</div>
<div class="card-sm">
<h4><span class="badge badge-red">Gone by design</span></h4>
<p>Sessions &amp; device pairings (re-login, re-pair) · 2FA secret (re-enrol) · WireGuard peers (re-pair) · rotated-away node keys · anonymous throwaway Nostr keys.</p>
</div>
</div>
<h2 id="seedqr">SeedQR</h2>
<p class="subtitle">Your words as a scannable code — using the open SeedSigner standard.</p>
<p>
Wherever the phrase is displayed, a <strong>QR code</strong> tab is offered alongside the
words. For the main (BIP-39) phrase the default format is <strong>SeedQR</strong>: each
word becomes its 4-digit position in the official wordlist (24 words → 96 digits),
encoded as a compact numeric QR. Hardware wallets like Passport, SeedSigner and Keystone
import this format directly — so you can move your on-chain identity into cold storage
without ever typing the words. A plain-text QR fallback exists for wallets that read the
phrase as text.
</p>
<ul>
<li>The QR contains <em>exactly the same secret</em> as the words — treat a printout or screenshot of it with identical care.</li>
<li>The Lightning aezeed is deliberately <em>never</em> SeedQR-encoded — it isn't BIP-39, hardware wallets can't import it, and pretending otherwise would be dishonest. It gets a plain-text QR with an explanation instead.</li>
<li>Restore is by typed/pasted words; there's no camera-based SeedQR scanner on the restore path today.</li>
</ul>
<h2 id="limitations">Honest Edges</h2>
<p class="subtitle">Known trade-offs and open hardening items, stated plainly.</p>
<p>
The audit that produced this system also tracked what it <em>didn't</em> fix. None of
these are secrets — honest security means naming the edges:
</p>
<ul>
<li><strong>The words cross the RPC boundary.</strong> During onboarding the phrase travels (over HTTPS/localhost) to your browser to be displayed, sits in the browser's session storage for the wizard's duration, and is held in server memory for the 10-minute idempotence window. This is the deliberate price of a refresh-proof, display-once flow.</li>
<li><strong>Argon2 parameters are the library defaults</strong> (≈19 MiB, 2 passes) rather than the heavier profile the design doc calls for. Still memory-hard and slow for attackers; scheduled for tightening.</li>
<li><strong>2FA backup codes carry a slight statistical bias</strong> from a modulo operation — cosmetically imperfect, cryptographically irrelevant at their length, and queued for cleanup.</li>
<li><strong>The lint ban covers the main workspace</strong>, but one small helper crate outside the workspace isn't reached by it yet.</li>
<li><strong>Best-effort backup sealing:</strong> if writing <code>master_seed.enc</code> fails during setup, the node continues (keys exist; only reveal is lost) — a loud failure might arguably be better, and this trade-off is under review.</li>
</ul>
<h2 id="verify">Verify It Yourself</h2>
<p class="subtitle">Don't trust — recompute.</p>
<p>
Because every derivation is deterministic and label-fixed, you can independently confirm
that your node's keys really do come from your words:
</p>
<ul>
<li><strong>Independent re-derivation script:</strong> <code>scripts/verify-seed-derivation.py</code> in the Archipelago source — pure-standard-library Python, no Archipelago code. Paste your mnemonic (on a trusted, offline machine) and it recomputes <code>node_key</code>, <code>nostr_secret</code> and <code>fips_key</code>, byte-comparing them against <code>/var/lib/archipelago/identity/</code>.</li>
<li><strong>Known-answer tests:</strong> the test suite pins the exact expected keys for a fixed test mnemonic; any change to the derivation math, however subtle, turns the build red.</li>
<li><strong>Non-determinism regression test:</strong> 64 consecutive generated mnemonics are asserted unique — a canary against the "predictable RNG" failure class.</li>
<li><strong>Your entropy audit trail:</strong> <code>/var/lib/archipelago/security/csprng-readiness.jsonl</code> records, append-only, the kernel randomness verdict at every key-generation event on <em>this</em> node — including the moment your seed was born.</li>
</ul>
<div class="callout callout-success">
<strong>The whole story in one paragraph</strong>
Your node asked the Linux kernel for 32 bytes of hardware-grade randomness through a
single, named, compiler-enforced channel; refused to proceed unless the bytes looked
alive; wrote down the health of the random pool as evidence; turned the bytes into 24
words it showed you exactly once; locked an encrypted copy behind your password; and
then grew every identity and key it owns from those words along fixed, versioned,
independently-verifiable paths — so that the words in your drawer are, and will remain,
a complete blueprint of who your node is.
</div>
<hr>
<p style="color: var(--text-muted); font-size: 13px; text-align: center;">
Archipelago — Seed &amp; Entropy Guide · reflects the KEY-05 hardened implementation (August 2026) ·
see also the <a href="/architecture/" style="color: var(--accent);">LoRa &amp; Mesh Guide</a>
</p>
</main>
</body>
</html>