Demo images / Build & push demo images (push) Successful in 4m19s
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>
1020 lines
49 KiB
HTML
1020 lines
49 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Archipelago — Seed & 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 & 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 & Recovery</div>
|
||
<a href="#failures">Failure Modes & 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 & 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 & 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(&mut rand::rngs::OsRng, &mut entropy)?; // guarded draw
|
||
let mnemonic = bip39::Mnemonic::from_entropy(&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> 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> 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> 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> 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> 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 & 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/<uuid>.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/<uuid>.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/<uuid>.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 & 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 & 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 & DID · node npub · FIPS mesh key · Reticulum/LXMF address · personal identity keys & 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 & 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 & 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 & Entropy Guide · reflects the KEY-05 hardened implementation (August 2026) ·
|
||
see also the <a href="/architecture/" style="color: var(--accent);">LoRa & Mesh Guide</a>
|
||
</p>
|
||
|
||
</main>
|
||
</body>
|
||
</html>
|