<pclass="lede">How 32 random bytes become every key this node owns — where the randomness comes from, what protects it, and exactly what your 24 words can and cannot bring back.</p>
<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 generates key material with a default or implicit RNG.</li>
<li><strong>The RNG type is compiler-enforced.</strong> Key generation only accepts RNGs on a sealed allowlist (<code>KeyGenRng</code>) whose single production member is <code>OsRng</code>.</li>
<li><strong>The buffer is zeroized</strong> on every path, success or failure.</li>
<li><strong>First boot: a placeholder.</strong> A freshly flashed node boots with a random <em>temporary</em> identity key so services can start. It is not seed-derived and is about to be thrown away.</li>
<li><strong>Onboarding: the real draw.</strong> At the "Recovery phrase" step, the <code>seed.generate</code> RPC performs the guarded 32-byte draw and shows you the 24 words.</li>
<li><strong>Derivation.</strong> Node key, DID, Nostr key, FIPS mesh key and your first identity are derived and written to <code>/var/lib/archipelago/identity/</code> at mode 0600, overwriting the placeholder.</li>
<li><strong>Password setup: the backup is sealed.</strong> The words are encrypted under your login password and stored as <code>master_seed.enc</code>, so you can reveal them again later.</li>
</ol>
<p>
Generation is idempotent for 10 minutes and serialised behind a lock: a browser refresh
returns the <em>same</em> words rather than minting a second seed.
<p>Key draws only compile against RNG types on a closed, private allowlist. A refactor that swaps in a weak or deterministic RNG becomes a <em>compile error</em>, not a silent disaster.</p>
<p>Every draw is checked for three broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is refused and wiped — <strong>never retried</strong>, because retrying would mask a broken RNG instead of exposing it.</p>
<p>Before generating, the node probes whether the kernel pool is fully initialised and appends the verdict to an append-only log at <code>security/csprng-readiness.jsonl</code> (0600). You can audit the entropy conditions your seed was born under, forever.</p>
<p>CI bans <code>rand::random()</code> and <code>rand::thread_rng()</code> across the workspace — the two convenient entry points behind real-world wallet disasters. Using either fails the build.</p>
<p>Raw entropy, mnemonics and derived secrets are wiped from memory on every code path, including error paths, so key material does not linger in freed RAM or crash dumps.</p>
<h3>What the degenerate check does and doesn't do</h3>
<p>
It is deliberately closed-form: it recognises exactly three catastrophic shapes
(all-zero, all-identical, ±1 counter). It is <em>not</em> a statistical entropy estimator —
those cannot distinguish good randomness from a cleverly broken RNG and add false
positives. The security load is carried by guardrails 1, 3 and 4; this is a tripwire for
total RNG failure, such as a buffer that was never filled.
</p>
<h3>Recent hardening</h3>
<p>
This system was audited and rebuilt in early August 2026. The headline finding: the
mnemonic library was silently choosing its own RNG via a transitive default. It happened
to be a secure one, but nothing guaranteed that, and a dependency update could have
changed it with no diff in Archipelago's own code.
</p>
<divclass="glass-card">
<table>
<tr><th>Date</th><th>Change</th></tr>
<tr><td>Jul 30</td><td>Kernel CSPRNG readiness probe; non-determinism regression test (64 consecutive mnemonics must 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 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> now refuses on a provisioned node (previously an unauthenticated restore could hijack a live node; fixed before any release shipped it).</td></tr>
<tr><td>Aug 2</td><td>KEY-05 layer landed: sealed allowlist, guarded draws, readiness ledger, clippy bans, supply-chain pinning of 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>
<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></tr>
<tr><td><strong>Node Nostr key</strong> — the node's npub</td><td>HKDF-SHA256</td><td><code>archipelago/nostr-node/secp256k1/v1</code></td></tr>
<tr><td><strong>FIPS mesh transport key</strong></td><td>HKDF-SHA256</td><td><code>archipelago/fips/secp256k1/v1</code></td></tr>
<tr><td><strong>Bitcoin BIP-84 xprv</strong> — dormant, reserved for a future cold vault</td><td>BIP-32</td><td><code>m/84'/0'/0'</code></td></tr>
<tr><td><strong>Release-root signing key</strong> — never on a node; derived offline by the publisher</td><td>HKDF-SHA256</td><td><code>archipelago/release/root/ed25519/v1</code></td></tr>
</table>
</div>
<p>
All HKDF derivations are HKDF-SHA256 with a distinct, versioned label — the <code>/v1</code>
suffix means a future migration can introduce <code>/v2</code> without ambiguity. Personal
Nostr keys deliberately use the NIP-06 standard path instead of HKDF, so the same 24 words
typed into any NIP-06 Nostr client reproduce the same npub: your social identity is portable
beyond Archipelago.
</p>
<h3>The Lightning special case</h3>
<divclass="glass-card">
<pre>master seed ──HKDF──▶ 16 bytes ──▶ <spanclass="y">LND generates its own "aezeed"</span> ──▶ wallet
│
│ ⚠ one-way: the aezeed cannot be
│ recomputed from your 24 words
▼
captured ONCE at init, stored encrypted as
<spanclass="a">identity/lnd_aezeed.enc</span>
</pre>
</div>
<p>
LND uses its own seed format, <em>aezeed</em>, which is not BIP-39. Archipelago derives
deterministic entropy from your master seed and hands it to LND at wallet creation — but LND
wraps it with its own internal salt, so the resulting aezeed cannot be re-derived from your
24 words afterwards. The node captures it once and stores it encrypted alongside your other
identity files.
</p>
<divclass="glass-card">
<h4class="warn">Back up the Lightning seed separately</h4>
<p>
Your 24 words restore your node identity and on-chain derivations, but <em>not</em> an
already-initialised Lightning wallet, and never off-chain channel balances (those need
channel backups, as Lightning requires by design). Treat the aezeed in the Lightning
backup screen as a second phrase worth writing down. It restores into LND-based wallets
such as Zeus, Blixt or another Archipelago node — hardware wallets cannot import it.
</p>
</div>
<h3>Second-order keys</h3>
<p>
Some subsystems derive from the <em>node identity key</em> rather than the master seed
directly. Since the node key is itself seed-derived, these still regrow from your words:
<code>words → master seed → node key → subsystem key</code>. Each prefixes a unique fixed
string before hashing (domain separation), so compromising one never exposes another.
</p>
<divclass="glass-card">
<table>
<tr><th>Subsystem</th><th>Derivation from <code>node_key</code></th></tr>
<tr><td><strong>Reticulum / LXMF mesh identity</strong> (LoRa long-range mesh)</td><td>HKDF-SHA256, salt <code>archipelago-reticulum-identity-v1</code>, separate X25519 + Ed25519 labels — a stable address that survives reinstalls</td></tr>
<tr><td><strong>Message store</strong> (chats at rest)</td><td><code>SHA-256("archipelago-message-store-v1" ‖ node_key)</code></td></tr>
<h3>What is <em>not</em> derived from the seed</h3>
<p>
Plenty of secrets are freshly random instead. That is intentional: things that should die
with a session, rotate freely, or belong to a third-party app must not be recoverable from
your words.
</p>
<divclass="grid">
<divclass="glass-card">
<h4>Ephemeral by design</h4>
<p>Session tokens, device pairing tokens, federation invites, TOTP secrets and backup codes, all encryption nonces, X3DH ephemeral mesh keys, anonymous marketplace and discovery Nostr keys.</p>
</div>
<divclass="glass-card">
<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>
<divclass="glass-card">
<h4>Host-level material</h4>
<p>WireGuard keypairs (via <code>wg genkey</code>), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.</p>
</div>
<divclass="glass-card">
<h4>Opt-outs from derivability</h4>
<p>Identities created with "new random key" instead of seed derivation, and a node key after an explicit <code>rotate-key</code> — rotation deliberately breaks the link to your words, and says so.</p>
</div>
</div>
<h2id="failures">Failures</h2>
<pclass="lede">What happens when something goes wrong, at every stage.</p>
<td><code>getrandom(2)</code> blocks until seeded — an unseeded pool cannot produce a seed. The probe logs a warning and records the verdict</td>
<tdclass="warn">Waits, then proceeds</td>
</tr>
<tr>
<td>Onboarding page refreshed mid-generation</td>
<td>Same words returned for 10 minutes, mutex-serialised; no second seed can be minted</td>
<tdclass="ok">Idempotent</td>
</tr>
<tr>
<td><code>master_seed.enc</code> missing</td>
<td>Node runs normally — derived keys are already on disk. Only Reveal and future re-derivation are unavailable, and the UI says so</td>
<tdclass="warn">Degraded, functional</td>
</tr>
<tr>
<td>Seed file corrupt, or wrong password</td>
<td>Authenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regeneration</td>
<tdclass="bad">Fails loudly</td>
</tr>
<tr>
<td>Restore attempted on a provisioned node</td>
<td>The onboarding gate refuses identity-mutating RPCs once set up — a live node cannot be hijacked or accidentally re-seeded</td>
<tdclass="ok">Refused</td>
</tr>
<tr>
<td>Legacy or corrupt FIPS key format</td>
<td>Self-heals: the legacy raw-byte format is detected and migrated in place to bech32</td>
<tdclass="ok">Auto-migrated</td>
</tr>
<tr>
<td>Readiness-ledger write fails</td>
<td>Warns and continues — the audit trail is best-effort and can never block key generation</td>
<tdclass="warn">Non-blocking</td>
</tr>
</table>
</div>
<divclass="glass-card">
<h4class="bad">The one true single point of failure is you</h4>
<p>
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 are restoring. Anyone
holding them can rebuild your entire identity tree — which is exactly what makes them a
perfect backup and a perfect target.
</p>
</div>
<h2id="restore">Restore</h2>
<pclass="lede">Typing 24 words into a fresh node, step by step.</p>
<divclass="glass-card">
<olclass="steps">
<li><strong>Gate check.</strong> Restore only proceeds on an un-onboarded node. This gate is load-bearing and runs before anything else.</li>
<li><strong>Validation.</strong> Exactly 24 words, checked against the BIP-39 wordlist and its checksum — a typo is caught here, before anything is written.</li>
<li><strong>Identity regrowth.</strong> Node key, DID, node Nostr key and FIPS mesh key are re-derived byte-identically, because the HKDF labels are fixed.</li>
<li><strong>Personal identity #0.</strong> The index resets to 0 and your default identity (Ed25519 + NIP-06 Nostr key) is recreated. Further seed-derived identities re-derive as the index walks forward, but their names and avatars were metadata, not key material.</li>
<li><strong>Mesh reactivation.</strong> FIPS auto-activation starts in the background; the Reticulum identity re-derives from the restored node key, so your LXMF address returns too.</li>
<li><strong>Password and re-seal.</strong> Setting the new login password re-encrypts the words into a fresh <code>master_seed.enc</code>, so Reveal works on the restored node.</li>
</ol>
</div>
<h3>What comes back — and what doesn't</h3>
<divclass="grid">
<divclass="glass-card">
<h4class="ok">Restored by the words</h4>
<p>Node identity and DID · node npub · FIPS mesh key · Reticulum/LXMF address · personal identity keys and npubs · message-store, contacts and credential encryption keys · the dormant Bitcoin xprv · the ability to reveal the phrase again.</p>
</div>
<divclass="glass-card">
<h4class="warn">Needs its own backup</h4>
<p>Lightning wallet (aezeed — one-way gate) and channel state · chat history and app data (node backup) · identity names and avatars · app secrets, which regenerate on reinstall.</p>
Wherever the phrase is shown, a QR tab sits beside the words. For the BIP-39 phrase the
default is <strong>SeedQR</strong>: each word becomes its 4-digit position in the official
wordlist (24 words → 96 digits) as a compact numeric QR. Passport, SeedSigner and Keystone
import this directly, so you can move your on-chain identity to cold storage without typing.
A plain-text QR fallback exists for wallets that read the phrase as text.
</p>
<ul>
<li>The QR holds <em>exactly the same secret</em> as the words — treat a printout or screenshot identically.</li>
<li>The Lightning aezeed is never SeedQR-encoded: it is not BIP-39, hardware wallets cannot import it, and pretending otherwise would be dishonest. It gets a plain-text QR with an explanation.</li>
<li>Restore is by typed or pasted words; there is no camera-based SeedQR scanner on the restore path today.</li>
</ul>
<h2id="verify">Verify it yourself</h2>
<pclass="lede">Don't trust — recompute.</p>
<p>Because every derivation is deterministic and label-fixed, you can independently confirm that this node's keys really do come from your words:</p>
<ul>
<li><strong>Independent re-derivation:</strong><code>scripts/verify-seed-derivation.py</code> in the Archipelago source — pure standard-library Python, no Archipelago code. On a trusted offline machine it recomputes <code>node_key</code>, <code>nostr_secret</code> and <code>fips_key</code> from your mnemonic and byte-compares 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, so any change to the derivation math turns the build red.</li>
<li><strong>Non-determinism test:</strong> 64 consecutive generated mnemonics are asserted unique — a canary against the predictable-RNG failure class.</li>
<li><strong>Your own 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 this node — including the moment your seed was born.</li>
</ul>
<h3>Honest edges</h3>
<p>The audit that produced this system also tracked what it did not fix. Naming the edges is part of the point:</p>
<ul>
<li><strong>The words cross the RPC boundary.</strong> During onboarding the phrase travels to your browser to be displayed, sits in session storage for the wizard's duration, and is held in server memory for the 10-minute idempotence window — the price of a refresh-proof, display-once flow.</li>
<li><strong>Argon2 uses library defaults</strong> (≈19 MiB, 2 passes) rather than the heavier profile the design doc specifies. Still memory-hard; scheduled for tightening.</li>
<li><strong>2FA backup codes carry slight modulo bias</strong> — cosmetically imperfect, cryptographically irrelevant at their length, queued for cleanup.</li>
<li><strong>The lint ban covers the main workspace</strong>, but one small helper crate outside it is not reached yet.</li>
<li><strong>Best-effort sealing:</strong> if writing <code>master_seed.enc</code> fails during setup, the node continues (keys exist, only Reveal is lost). Whether that should fail loudly instead is under review.</li>
</ul>
<divclass="glass-card">
<h4class="ok">The whole story in one paragraph</h4>
<p>
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 the words in your drawer are, and will remain, a