From 53753687a564490a5b140cd653a0132153737295 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 09:30:41 -0400 Subject: [PATCH] style(entropy-guide): rebuild on the dashboard's own sidebar + glass-card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bespoke doc-page chrome with the app's real components: DashboardSidebar shell (256px, rgba(0,0,0,.25) + 18px blur, staggered nav-item entrance), the AnimatedLogo neode mark with its 20 staggered squares, sidebar-nav-item + nav-tab-active for section state, and the Settings wallpaper behind it all. Every bespoke container (.card/.card-sm/.step/.score-card/.callout-*/ .diagram) is gone — .glass-card from style.css is now the only box on the page, with layout-only grids inside it. Scroll-spy lives in nav.js rather than inline, since the node's CSP is script-src 'self'. Co-Authored-By: Claude Fable 5 --- neode-ui/public/entropy/index.html | 1779 +++++++++++++--------------- neode-ui/public/entropy/nav.js | 29 + 2 files changed, 857 insertions(+), 951 deletions(-) create mode 100644 neode-ui/public/entropy/nav.js diff --git a/neode-ui/public/entropy/index.html b/neode-ui/public/entropy/index.html index be063af9..0941670e 100644 --- a/neode-ui/public/entropy/index.html +++ b/neode-ui/public/entropy/index.html @@ -3,1048 +3,925 @@ -Archipelago — Seed & Entropy Guide +Archipelago — Seed & Entropy + - +
+
-
-

Your Node's Seed & Entropy

-

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.

-
- 256-bit entropy - BIP-39 · 24 words - HKDF-SHA256 derivation - Kernel CSPRNG only - KEY-05 hardened +

Your node's seed & entropy

+

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.

+
+ 256-bit entropy + BIP-39 · 24 words + HKDF-SHA256 + Kernel CSPRNG only + KEY-05 hardened
-
-

Introduction

-

One master secret, many keys — by design.

+

Overview

+

One master secret, many keys — by design.

-

-Almost everything cryptographic on this node — its identity, its Nostr keys, its mesh -transport keys, its Lightning wallet — grows from a single master seed: -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. -

+

+ Almost everything cryptographic on this node — its identity, its Nostr keys, its mesh + transport keys, its Lightning wallet — grows from a single master seed: + 32 bytes of randomness drawn once, shown to you once as a 24-word recovery phrase, and + never stored in raw form anywhere. +

-
- The tree and the acorn - 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 same tree 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. -
+
+ +

+ 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 same tree grows back, + branch for branch. That is why those words are the most valuable thing your node ever + shows you, and why anyone who copies them owns your tree. +

+
-

-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 isn't, and -what happens in every failure and recovery scenario. -

+
+
Linux kernel CSPRNG  (interrupt timing, jitter, CPU RNG)
+      │
+      │  getrandom(2) — via an explicitly named OsRng, nothing else allowed
+      ▼
+32 bytes raw entropy ──▶ degenerate-draw check ──▶ refuse & wipe if suspicious
+      │
+      │  BIP-39 encoding
+      ▼
+24-word recovery phrase  ←── the only form you ever see or back up
+      │
+      │  PBKDF2-HMAC-SHA512 × 2048
+      ▼
+64-byte master seed      ←── lives only in RAM, never written to disk
+      │
+      ├─ HKDF "archipelago/node/ed25519/v1"    ──▶ Node identity key + DID
+      ├─ HKDF "archipelago/nostr-node/…/v1"    ──▶ Node Nostr key (npub)
+      ├─ HKDF "archipelago/fips/secp256k1/v1"  ──▶ FIPS mesh transport key
+      ├─ HKDF "archipelago/identity/{i}/…/v1"  ──▶ Personal identities
+      ├─ BIP-32 m/44'/1237'/0'/0/{i} (NIP-06)  ──▶ Personal Nostr keys
+      ├─ HKDF "archipelago/lnd/entropy/v1"     ──▶ Lightning entropy → aezeed
+      └─ BIP-32 m/84'/0'/0'                    ──▶ Bitcoin xprv (dormant)
+                  │
+                  │  and from the node key, second-order:
+                  ▼
+      ├─ Reticulum / LXMF mesh identity
+      ├─ Message-store + contacts encryption
+      └─ Credential-store key
+
+
-

The Big Picture

-

From kernel randomness to every key on the node.

+
+

One rule to remember

+

+ If it is on the diagram above, your 24 words rebuild it from scratch, on any hardware, + forever. If it is not on the diagram — session tokens, app passwords, WireGuard keys, + Lightning channel state — it is independent randomness, protected by other backups. +

+
-
-Linux kernel CSPRNG (hardware noise: interrupts, timing jitter, CPU RNG) - │ - │ getrandom(2) — via an explicitly named OsRng, nothing else allowed - ▼ -32 bytes of raw entropy ──▶ degenerate-draw check ──▶ refuse & wipe if suspicious - │ - │ BIP-39 encoding - ▼ -24-word recovery phrase ←── the only form you ever see or back up - │ - │ PBKDF2-HMAC-SHA512 × 2048 (empty passphrase) - ▼ -64-byte master seed ←── lives only in RAM, never written to disk - │ - ├── HKDF "archipelago/node/ed25519/v1" ──▶ Node identity key + DID - ├── HKDF "archipelago/nostr-node/…/v1" ──▶ Node Nostr key (npub) - ├── HKDF "archipelago/fips/secp256k1/v1" ──▶ FIPS mesh transport key - ├── HKDF "archipelago/identity/{i}/…/v1" ──▶ Personal identities (Ed25519) - ├── BIP-32 m/44'/1237'/0'/0/{i} (NIP-06) ──▶ Personal Nostr keys - ├── HKDF "archipelago/lnd/entropy/v1" ──▶ Lightning wallet entropy → aezeed - └── BIP-32 m/84'/0'/0' ──▶ Bitcoin xprv (dormant, reserved) - │ - │ and from the node identity key, second-order: - ▼ - ├── Reticulum / RNS mesh identity (HKDF, salt "archipelago-reticulum-identity-v1") - ├── Message-store encryption key (SHA-256 domain-separated) - ├── Mesh-contacts encryption key (SHA-256 domain-separated) - └── Credential-store key (SHA-256 domain-separated) -
+

How it's created

+

One named source. No mixing. No silent defaults.

-
- One rule to remember - 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. -
+
+ +

+ Computers cannot invent randomness — they collect it. The Linux kernel constantly + harvests unpredictable physical noise (the exact nanosecond a network card interrupts, + timing jitter between CPU cores, the CPU's hardware random generator) into a + cryptographic pool. Archipelago rolls its dice by asking that pool directly, and only + that pool. There is deliberately no blending of other sources: a single, named, + well-studied source is auditable, whereas a blend is a place for bugs to hide. +

+
-

Where Randomness Comes From

-

One named source. No mixing. No silent defaults.

+

Technically

+

+ The master seed is generated by MasterSeed::generate() in + core/archipelago/src/seed.rs. It fills a 32-byte buffer using + rand::rngs::OsRng — a thin wrapper around the getrandom(2) + system call, which reads the kernel CSPRNG (same source as /dev/urandom, + but immune to file-descriptor exhaustion and chroot tricks). +

-
- Dice you can audit - 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 only 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. -
- -

Technically

-

-The master seed is generated by MasterSeed::generate() in -core/archipelago/src/seed.rs. It fills a 32-byte buffer using -rand::rngs::OsRng — a thin wrapper around the -getrandom(2) system call, which reads the kernel's CSPRNG -(the same source as /dev/urandom, but immune to file-descriptor -exhaustion and chroot tricks). -

- -
    -
  • Exactly 32 bytes / 256 bits — the maximum BIP-39 strength, encoding to 24 words.
  • -
  • The RNG is named at the call site. No function anywhere in the codebase generates key material with a "default" or implicit RNG anymore (see Recent Hardening for why this is stated so emphatically).
  • -
  • The RNG type is compiler-enforced. Key-generation functions only accept RNGs on a sealed allowlist (KeyGenRng in core/archipelago/src/entropy.rs). The list has exactly one production member: OsRng. No other module — not even a future refactor — can add a weaker RNG without editing the allowlist file itself.
  • -
  • The buffer is zeroized (securely wiped from memory) on every path, success or failure.
  • -
- -
// core/archipelago/src/seed.rs — the actual draw
-let mut entropy = [0u8; 32];
+  
+
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
+let mnemonic = bip39::Mnemonic::from_entropy(&entropy)?; // → 24 words +entropy.zeroize(); // wipe raw bytes
+
-

-The 24 words are then stretched into the 64-byte master seed using standard BIP-39: -PBKDF2-HMAC-SHA512, 2048 rounds, empty passphrase. (There is no "25th word" -passphrase — your login password protects the stored backup instead, see -The Encrypted Envelope.) That 64-byte seed exists -only in memory, is re-computed from the words whenever needed, and is -never written to disk in any form. -

+
    +
  • Exactly 32 bytes / 256 bits — the maximum BIP-39 strength, encoding to 24 words.
  • +
  • The RNG is named at the call site. No function anywhere generates key material with a default or implicit RNG.
  • +
  • The RNG type is compiler-enforced. Key generation only accepts RNGs on a sealed allowlist (KeyGenRng) whose single production member is OsRng.
  • +
  • The buffer is zeroized on every path, success or failure.
  • +
-

When the Seed Is Born

-

At onboarding — not at first boot.

+

+ The words are then stretched into the 64-byte master seed by standard BIP-39: + PBKDF2-HMAC-SHA512, 2048 rounds, empty passphrase. That 64-byte value is a 512-bit + expansion of the same 256 bits of entropy — not extra randomness. It exists only in + memory, is recomputed from the words when needed, and never touches disk. +

-
-
-

First boot: a placeholder key

-

A freshly-flashed node boots with a random temporary identity key so services can start. This key is NOT seed-derived and is about to be thrown away.

+

When the seed is born

+

At onboarding — not at first boot.

+
+
    +
  1. First boot: a placeholder. A freshly flashed node boots with a random temporary identity key so services can start. It is not seed-derived and is about to be thrown away.
  2. +
  3. Onboarding: the real draw. At the "Recovery phrase" step, the seed.generate RPC performs the guarded 32-byte draw and shows you the 24 words.
  4. +
  5. Derivation. Node key, DID, Nostr key, FIPS mesh key and your first identity are derived and written to /var/lib/archipelago/identity/ at mode 0600, overwriting the placeholder.
  6. +
  7. Password setup: the backup is sealed. The words are encrypted under your login password and stored as master_seed.enc, so you can reveal them again later.
  8. +
+

+ Generation is idempotent for 10 minutes and serialised behind a lock: a browser refresh + returns the same words rather than minting a second seed. +

-
-

Onboarding: the real draw

-

When you reach the "Recovery phrase" step of setup, the seed.generate RPC performs the guarded 32-byte draw described above and shows you the 24 words — the only time they're ever displayed unprompted.

+ +

Guardrails

+

Defence in depth around a single random draw.

+ +
+
+

1 Sealed RNG allowlist

+

Key draws only compile against RNG types on a closed, private allowlist. A refactor that swaps in a weak or deterministic RNG becomes a compile error, not a silent disaster.

+
+
+

2 Degenerate-draw refusal

+

Every draw is checked for three broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is refused and wiped — never retried, because retrying would mask a broken RNG instead of exposing it.

+
+
+

3 CSPRNG readiness ledger

+

Before generating, the node probes whether the kernel pool is fully initialised and appends the verdict to an append-only log at security/csprng-readiness.jsonl (0600). You can audit the entropy conditions your seed was born under, forever.

+
+
+

4 Build-time lint bans

+

CI bans rand::random() and rand::thread_rng() across the workspace — the two convenient entry points behind real-world wallet disasters. Using either fails the build.

+
+
+

5 Zeroization everywhere

+

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.

+
-
-

Derivation: keys are materialised

-

The node identity key, DID, Nostr key, FIPS mesh key, and your first personal identity are all derived from the seed and written to /var/lib/archipelago/identity/ (each file mode 0600). The placeholder key from step 1 is overwritten.

+ +
+ +

Why so paranoid about one function?

+

+ In 2026 a well-known hardware wallet shipped a bug where a refactor quietly switched + seed generation to a predictable random source — 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 impossible to compile, and to + log the health of the random pool at the moment your seed was created. +

-
-

Password setup: the backup is sealed

-

When you set your login password, the 24 words are encrypted under it (Argon2 + ChaCha20-Poly1305) and stored as master_seed.enc — so you can re-reveal them later from Settings → Backup.

+ +

What the degenerate check does and doesn't do

+

+ It is deliberately closed-form: it recognises exactly three catastrophic shapes + (all-zero, all-identical, ±1 counter). It is not 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. +

+ +

Recent hardening

+

+ 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. +

+
+ + + + + + + + + +
DateChange
Jul 30Kernel CSPRNG readiness probe; non-determinism regression test (64 consecutive mnemonics must be unique).
Jul 31Full entropy audit published (findings F-01…F-13).
Aug 1The pivotal fix: master-seed RNG made explicit — OsRng named at the call site, injected through a testable seam, pinned by a known-answer test.
Aug 2Audit widened: 43 defaulted-RNG call sites across 15 files migrated to explicit OsRng, including AEAD nonces and ecash key material.
Aug 2Onboarding RPCs gated — seed.restore now refuses on a provisioned node (previously an unauthenticated restore could hijack a live node; fixed before any release shipped it).
Aug 2KEY-05 layer landed: sealed allowlist, guarded draws, readiness ledger, clippy bans, supply-chain pinning of the rand crate.
Aug 2Legacy Bitcoin Core wallet-import path deleted — the master xprv is no longer handed to any external wallet process.
+ +

Stored on disk

+

The words, encrypted — and the derived keys. Never the raw seed.

+ +
+ + + + + + + + + + +
FileContentsProtection
identity/master_seed.encYour 24 words, encryptedArgon2(login password) + ChaCha20-Poly1305, 0600
identity/node_keyNode Ed25519 identity key0600, seed-derived
identity/nostr_secretNode Nostr keypair0600, seed-derived
identity/fips_keyFIPS mesh transport key (bech32 nsec)0600, seed-derived
identity/identity_indexNext unused derivation indexPlain integer, not secret
identities/<uuid>.jsonIdentity records: keys + metadata0600; keys seed-derived, metadata is not
identity/lnd_aezeed.encLightning wallet's own seedEncrypted under the LND wallet password
security/csprng-readiness.jsonlAppend-only entropy audit trail0600; outside identity/ so restores never touch it
+
+ +
+

The raw seed never touches disk

+

+ What is stored is the encrypted words and the derived keys. The 64-byte + master seed is recomputed in RAM from the words when needed and wiped afterwards. +

+
+ +

The encrypted envelope

+
+
login password ──▶ Argon2id (memory-hard) ──▶ 256-bit file key
+                       ▲
+               16-byte random salt
+
+24 words ──▶ ChaCha20-Poly1305 (authenticated, 12-byte random nonce)
+                       │
+                       ▼
+     ┌───────────┬────────────┬───────────────────────────┐
+     │ salt (16) │ nonce (12) │ ciphertext + auth tag     │  = master_seed.enc
+     └───────────┴────────────┴───────────────────────────┘
+
+
+ +
+ +

+ Your words are locked in a digital safe whose combination is your login password, run + through a deliberately slow, memory-hungry grinder (Argon2) so guessing billions of + passwords per second is impractical even for someone who steals the file. The + authentication tag means the safe also notices tampering: a modified file fails loudly + rather than yielding wrong words. +

+
+ +

+ Revealing the words later (Settings → Backup → Reveal) requires an authenticated session, + re-entering your password, and your 2FA code if enabled. It is rate-limited, and the words + go only to your browser — never to logs. +

+ +

Derivation tree

+

Every key, its exact derivation, and where it lands.

+ +
+ +

+ The node never uses the master seed directly as a key. It uses HKDF — think of a + locksmith who, given one master blank and a label ("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 lets the identical tree regrow on new hardware. +

+
+ +
+ + + + + + + + + + +
KeyMethodLabel / path
Node identity (Ed25519) — signs everything, forms your DIDHKDF-SHA256archipelago/node/ed25519/v1
Node Nostr key — the node's npubHKDF-SHA256archipelago/nostr-node/secp256k1/v1
FIPS mesh transport keyHKDF-SHA256archipelago/fips/secp256k1/v1
Personal identity #i (Ed25519)HKDF-SHA256archipelago/identity/{i}/ed25519/v1
Personal Nostr key #i — NIP-06 standard, portable to other Nostr appsBIP-32m/44'/1237'/0'/0/{i}
Lightning wallet entropy — 16 bytesHKDF-SHA256archipelago/lnd/entropy/v1
Bitcoin BIP-84 xprv — dormant, reserved for a future cold vaultBIP-32m/84'/0'/0'
Release-root signing key — never on a node; derived offline by the publisherHKDF-SHA256archipelago/release/root/ed25519/v1
+
+ +

+ All HKDF derivations are HKDF-SHA256 with a distinct, versioned label — the /v1 + suffix means a future migration can introduce /v2 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. +

+ +

The Lightning special case

+
+
master seed ──HKDF──▶ 16 bytes ──▶ LND generates its own "aezeed" ──▶ wallet
+                                              │
+                                              │  ⚠ one-way: the aezeed cannot be
+                                              │    recomputed from your 24 words
+                                              ▼
+                              captured ONCE at init, stored encrypted as
+                              identity/lnd_aezeed.enc
+
+
+

+ LND uses its own seed format, aezeed, 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. +

+
+

Back up the Lightning seed separately

+

+ Your 24 words restore your node identity and on-chain derivations, but not 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. +

+
+ +

Second-order keys

+

+ Some subsystems derive from the node identity key rather than the master seed + directly. Since the node key is itself seed-derived, these still regrow from your words: + words → master seed → node key → subsystem key. Each prefixes a unique fixed + string before hashing (domain separation), so compromising one never exposes another. +

+
+ + + + + + +
SubsystemDerivation from node_key
Reticulum / LXMF mesh identity (LoRa long-range mesh)HKDF-SHA256, salt archipelago-reticulum-identity-v1, separate X25519 + Ed25519 labels — a stable address that survives reinstalls
Message store (chats at rest)SHA-256("archipelago-message-store-v1" ‖ node_key)
Mesh contactsSHA-256("archipelago-mesh-contacts-v1" ‖ node_key)
Credential store (saved app credentials)Same domain-separated SHA-256 pattern
+
+ +

What is not derived from the seed

+

+ 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. +

+
+
+

Ephemeral by design

+

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.

+
+
+

App-owned secrets

+

Every manifest-declared generated_secret (app database passwords, API keys), Bitcoin RPC credentials, the LND wallet password (distinct from its seed), Home Assistant tokens.

+
+
+

Host-level material

+

WireGuard keypairs (via wg genkey), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.

+
+
+

Opt-outs from derivability

+

Identities created with "new random key" instead of seed derivation, and a node key after an explicit rotate-key — rotation deliberately breaks the link to your words, and says so.

+
+
+ +

Failures

+

What happens when something goes wrong, at every stage.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioBehaviourOutcome
RNG returns a degenerate patternDraw refused and wiped, never retried; error logged; onboarding fails loudlyNo seed created
Kernel pool not yet initialisedgetrandom(2) blocks until seeded — an unseeded pool cannot produce a seed. The probe logs a warning and records the verdictWaits, then proceeds
Onboarding page refreshed mid-generationSame words returned for 10 minutes, mutex-serialised; no second seed can be mintedIdempotent
master_seed.enc missingNode runs normally — derived keys are already on disk. Only Reveal and future re-derivation are unavailable, and the UI says soDegraded, functional
Seed file corrupt, or wrong passwordAuthenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regenerationFails loudly
Restore attempted on a provisioned nodeThe onboarding gate refuses identity-mutating RPCs once set up — a live node cannot be hijacked or accidentally re-seededRefused
Legacy or corrupt FIPS key formatSelf-heals: the legacy raw-byte format is detected and migrated in place to bech32Auto-migrated
Readiness-ledger write failsWarns and continues — the audit trail is best-effort and can never block key generationNon-blocking
+
+ +
+

The one true single point of failure is you

+

+ Every software failure above fails safe. The only unrecoverable scenario is + losing the 24 words and 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. +

+
+ +

Restore

+

Typing 24 words into a fresh node, step by step.

+ +
+
    +
  1. Gate check. Restore only proceeds on an un-onboarded node. This gate is load-bearing and runs before anything else.
  2. +
  3. Validation. Exactly 24 words, checked against the BIP-39 wordlist and its checksum — a typo is caught here, before anything is written.
  4. +
  5. Identity regrowth. Node key, DID, node Nostr key and FIPS mesh key are re-derived byte-identically, because the HKDF labels are fixed.
  6. +
  7. Personal identity #0. 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.
  8. +
  9. Mesh reactivation. FIPS auto-activation starts in the background; the Reticulum identity re-derives from the restored node key, so your LXMF address returns too.
  10. +
  11. Password and re-seal. Setting the new login password re-encrypts the words into a fresh master_seed.enc, so Reveal works on the restored node.
  12. +
+
+ +

What comes back — and what doesn't

+
+
+

Restored by the words

+

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.

+
+
+

Needs its own backup

+

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.

+
+
+

Gone by design

+

Sessions and device pairings (log in, re-pair) · 2FA secret (re-enrol) · WireGuard peers (re-pair) · rotated-away node keys · anonymous throwaway Nostr keys.

+
+
+ +

SeedQR

+

+ Wherever the phrase is shown, a QR tab sits beside the words. For the BIP-39 phrase the + default is SeedQR: 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. +

+
    +
  • The QR holds exactly the same secret as the words — treat a printout or screenshot identically.
  • +
  • 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.
  • +
  • Restore is by typed or pasted words; there is no camera-based SeedQR scanner on the restore path today.
  • +
+ +

Verify it yourself

+

Don't trust — recompute.

+ +

Because every derivation is deterministic and label-fixed, you can independently confirm that this node's keys really do come from your words:

+
    +
  • Independent re-derivation: scripts/verify-seed-derivation.py in the Archipelago source — pure standard-library Python, no Archipelago code. On a trusted offline machine it recomputes node_key, nostr_secret and fips_key from your mnemonic and byte-compares them against /var/lib/archipelago/identity/.
  • +
  • Known-answer tests: the test suite pins the exact expected keys for a fixed test mnemonic, so any change to the derivation math turns the build red.
  • +
  • Non-determinism test: 64 consecutive generated mnemonics are asserted unique — a canary against the predictable-RNG failure class.
  • +
  • Your own audit trail: /var/lib/archipelago/security/csprng-readiness.jsonl records, append-only, the kernel randomness verdict at every key-generation event on this node — including the moment your seed was born.
  • +
+ +

Honest edges

+

The audit that produced this system also tracked what it did not fix. Naming the edges is part of the point:

+
    +
  • The words cross the RPC boundary. 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.
  • +
  • Argon2 uses library defaults (≈19 MiB, 2 passes) rather than the heavier profile the design doc specifies. Still memory-hard; scheduled for tightening.
  • +
  • 2FA backup codes carry slight modulo bias — cosmetically imperfect, cryptographically irrelevant at their length, queued for cleanup.
  • +
  • The lint ban covers the main workspace, but one small helper crate outside it is not reached yet.
  • +
  • Best-effort sealing: if writing master_seed.enc fails during setup, the node continues (keys exist, only Reveal is lost). Whether that should fail loudly instead is under review.
  • +
+ +
+

The whole story in one paragraph

+

+ 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 + complete blueprint of who your node is. +

+
+
- -
- Refresh-proof by design - If the onboarding page is retried within 10 minutes (browser refresh, flaky connection), - the node returns the same words instead of minting a second seed. Generation is - serialised behind a lock — there is no window where two competing seeds can exist. -
- -

The Five Guardrails

-

Defence in depth around a single random draw.

- -
-
-

1  Sealed RNG allowlist

-

Key draws only compile against RNG types on a closed, private allowlist. Production allowlist: OsRng. Full stop. A refactor that swaps in a weak or deterministic RNG becomes a compile error, not a silent disaster.

-
-
-

2  Degenerate-draw refusal

-

Every draw is inspected for three tell-tale broken-RNG shapes: all zeros, all bytes identical, or a counting pattern. A match is refused and wiped — never retried, because retrying would mask a broken RNG instead of exposing it.

-
-
-

3  CSPRNG readiness ledger

-

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 security/csprng-readiness.jsonl (0600). You can audit, forever, the entropy conditions your seed was born under.

-
-
-

4  Build-time lint bans

-

The CI lint config bans rand::random() and rand::thread_rng() outright across the workspace — the two "convenient" RNG entry points that caused real-world wallet disasters elsewhere. Using either fails the build.

-
-
-

5  Zeroization everywhere

-

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.

-
-
- -
- Why so paranoid about one function? - In 2026 a well-known hardware wallet shipped a bug where a code refactor quietly switched - seed generation to a predictable 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 impossible to compile, - and to log the health of the random pool at the exact moment your seed was created. -
- -

What the degenerate check does — and doesn't — do

-

-The check (is_degenerate in entropy.rs) is deliberately -closed-form: it recognises exactly three catastrophic failure shapes -(all-zero, all-identical, ±1 counter). It is not 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). -

- -

Recent Hardening (the KEY-05 work)

-

This system was audited and rebuilt in early August 2026.

- -

-Triggered by the COLDCARD-class of entropy defects, a full entropy & seed-generation -audit (docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md) 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 guaranteed that, and a dependency update could have changed it -without any diff in Archipelago's own code. -

- - - - - - - - - - -
DateChange
Jul 30Kernel CSPRNG readiness probe added; RNG non-determinism regression test (64 consecutive mnemonics must all be unique).
Jul 31Full entropy audit published (findings F-01…F-13).
Aug 1The pivotal fix: master-seed RNG made explicit — OsRng named at the call site, injected through a testable seam, pinned by a known-answer test.
Aug 2Audit widened: 43 defaulted-RNG call sites across 15 files found and migrated to explicit OsRng — including AEAD nonces and ecash key material.
Aug 2Onboarding RPCs gated: seed.restore 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).
Aug 2KEY-05 enforcement layer landed: sealed KeyGenRng allowlist, guarded draws, durable readiness ledger, clippy bans, supply-chain version pinning on the rand crate.
Aug 2Legacy Bitcoin Core wallet-import path deleted — the master xprv is no longer handed to any external wallet process.
- -

What's Kept on Disk

-

The words, encrypted — and the keys, materialised. Never the raw seed.

- - - - - - - - - - - -
FileContentsProtection
identity/master_seed.encYour 24 words (as text), encryptedArgon2(login password) + ChaCha20-Poly1305, mode 0600
identity/node_key / .pubNode Ed25519 identity key0600, seed-derived (recoverable from words)
identity/nostr_secret / nostr_pubkeyNode Nostr keypair0600, seed-derived
identity/fips_key / .pubFIPS mesh transport key (bech32 nsec)0600, seed-derived
identity/identity_indexNext unused identity derivation indexPlain integer (not secret)
identities/<uuid>.jsonPersonal identity records (keys + metadata)0600; keys seed-derived, metadata is not
identity/lnd_aezeed.encLightning wallet's own seed (see below)Encrypted under the LND wallet password
security/csprng-readiness.jsonlAppend-only entropy audit trail0600; deliberately outside identity/ so restores never touch it
- -
- The raw seed never touches disk - What's stored is the encrypted words and the derived keys. 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. -
- -

The Encrypted Envelope

-

How master_seed.enc is built.

- -
-your login password ──▶ Argon2id (memory-hard KDF) ──▶ 256-bit file key - ▲ - 16-byte random salt - -24 words ──▶ ChaCha20-Poly1305 (authenticated encryption, 12-byte random nonce) - │ - ▼ - ┌───────────┬────────────┬──────────────────────────────┐ - │ salt (16) │ nonce (12) │ ciphertext + auth tag │ = master_seed.enc - └───────────┴────────────┴──────────────────────────────┘ -
- -
- A safe inside a safe - 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. -
- -

-Revealing the words later (Settings → Backup → Reveal) requires an authenticated -session plus re-entering your password plus your 2FA code if enabled, -is rate-limited, and the words are returned only to your browser — never written to logs. -

- -

The Derivation Tree

-

Every key, its exact derivation, and where it lands.

- -
- One password, many doors — without reuse - 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 label ("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. -
- - - - - - - - - - - -
KeyDerivationLabel / pathMaterialised at
Node identity (Ed25519) — signs everything, forms your DIDHKDF-SHA256archipelago/node/ed25519/v1identity/node_key
Node Nostr key (secp256k1) — the node's npubHKDF-SHA256archipelago/nostr-node/secp256k1/v1identity/nostr_secret
FIPS mesh transport key — federation/mesh overlay identityHKDF-SHA256archipelago/fips/secp256k1/v1identity/fips_key
Personal identity #i (Ed25519)HKDF-SHA256archipelago/identity/{i}/ed25519/v1identities/<uuid>.json
Personal Nostr key #i — standard NIP-06, importable into other Nostr appsBIP-32m/44'/1237'/0'/0/{i}identities/<uuid>.json
Lightning (LND) wallet entropy — 16 bytesHKDF-SHA256archipelago/lnd/entropy/v1fed into LND at wallet init (see below)
Bitcoin BIP-84 account xprv — native-segwitBIP-32m/84'/0'/0'nowhere — dormant, reserved for a future on-node cold vault
Release-root signing key — fleet update signingHKDF-SHA256archipelago/release/root/ed25519/v1never on a node — derived offline by the publisher from a separate release mnemonic; nodes only pin the public key
- -

-All HKDF derivations are HKDF-SHA256 with a distinct, versioned info-label (the -/v1 suffix means a future algorithm migration can introduce -/v2 labels without ambiguity). Personal Nostr keys intentionally use the -NIP-06 standard path instead of HKDF so that the same 24 words typed into any -NIP-06-compliant Nostr client reproduce the same npub — your social identity is -portable beyond Archipelago. -

- -

The Lightning Special Case

-

The one branch of the tree with a one-way gate in it.

- -
-master seed ──HKDF──▶ 16 bytes of entropy ──▶ LND generates its own "aezeed" ──▶ Lightning wallet - │ - │ ⚠ one-way: the aezeed cannot be - │ recomputed from your 24 words later - ▼ - captured ONCE at wallet init and stored as - identity/lnd_aezeed.enc (encrypted) -
- -

-LND (the Lightning node) uses its own seed format called aezeed, 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 cannot be re-derived from your 24 words afterwards. -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. -

- -
- Back up the Lightning seed separately - Your 24 words alone restore your node identity and on-chain derivations — but - not 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. -
- -

Second-Order Keys

-

Derived from the node key — so still fully recoverable from the words.

- -

-Several subsystems derive their keys from the node identity key 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 words → master seed → node key → subsystem key. -

- - - - - - - -
SubsystemDerivation from node_key
Reticulum / RNS mesh identity (LoRa long-range mesh)HKDF-SHA256, salt archipelago-reticulum-identity-v1, two labels for the X25519 + Ed25519 halves — yields a stable LXMF address that survives reinstalls
Message-store encryption (chats at rest)SHA-256("archipelago-message-store-v1" ‖ node_key)
Mesh contacts encryptionSHA-256("archipelago-mesh-contacts-v1" ‖ node_key)
Credential store (saved app credentials)Same domain-separated SHA-256 pattern
- -
- Domain separation, in one sentence - 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. -
- -

What Is NOT Derived From the Seed

-

Independent randomness — deliberately outside the tree.

- -

-Plenty of secrets are freshly random 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). -

- -
-
-

Ephemeral by design

-

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.

-
-
-

App-owned secrets

-

Every manifest-declared generated_secret (app database passwords, API keys), Bitcoin RPC credentials, the LND wallet password (distinct from its seed), Home Assistant tokens.

-
-
-

Host-level material

-

WireGuard VPN keypairs (generated by wg genkey), SSH host keys and the TLS certificate (created by the installer image at first boot), the machine-id.

-
-
-

Opt-outs from derivability

-

Extra identities created with "new random key" instead of seed derivation, and a node key after an explicit rotate-key — rotation deliberately breaks the link to the words, and says so.

-
-
- -
- Consequence for backups - 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 - identity's lifeboat, not a full-system image. -
- -

Failure Modes & Fallbacks

-

What happens when something goes wrong — at every stage.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ScenarioBehaviourOutcome
RNG returns a degenerate pattern (all-zero / repeated / counter)Draw is refused and wiped, never retried; error logged; onboarding fails loudlyNo seed created
Kernel random pool not yet initialised (exotic first-boot case)getrandom(2) blocks 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 ledgerWaits, then proceeds
Onboarding page refreshed / retried mid-generationSame words returned for 10 minutes (mutex-serialised); no second seed can be mintedIdempotent
master_seed.enc missing (e.g. backup write failed during setup)Node runs normally — every derived key is already materialised on disk. Only the Reveal feature and future re-derivation are unavailable; the UI says so explicitlyDegraded, functional
master_seed.enc corrupt, or wrong password at revealAuthenticated decryption fails closed with an explicit error — no fallback, no partial output, no auto-regenerationFails loudly
Restore attempted on an already-provisioned nodeThe onboarding gate refuses identity-mutating RPCs once the node is set up — a live node cannot be hijacked or accidentally re-seededRefused
Legacy / corrupt on-disk FIPS key formatSelf-heals: legacy raw-byte format is detected and migrated in place to the current bech32 formatAuto-migrated
Readiness-ledger write fails (disk full, permissions)Warns and continues — the audit trail is best-effort and can never block key generationNon-blocking
- -
- The one true single point of failure is you - Every software failure above fails safe. The only unrecoverable scenario is - losing the 24 words and 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. -
- -

The Restore Flow

-

Typing 24 words into a fresh node, step by step.

- -
-
-

Gate check

-

Restore only proceeds on an un-onboarded node. A provisioned node refuses — this gate is load-bearing and runs before anything else.

-
-
-

Validation

-

Exactly 24 words, checked against the BIP-39 wordlist and its built-in checksum — a typo is caught here, before anything is written.

-
-
-

Identity regrowth

-

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.

-
-
-

Personal identity #0

-

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.

-
-
-

Mesh reactivation

-

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.

-
-
-

Password & re-seal

-

When you set the (new) login password, the words are re-encrypted into a fresh master_seed.enc — the reveal feature works on the restored node just like the original.

-
-
- -

What Comes Back — and What Doesn't

- -
-
-

Restored by the words

-

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.

-
-
-

Needs its own backup

-

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).

-
-
-

Gone by design

-

Sessions & device pairings (re-login, re-pair) · 2FA secret (re-enrol) · WireGuard peers (re-pair) · rotated-away node keys · anonymous throwaway Nostr keys.

-
-
- -

SeedQR

-

Your words as a scannable code — using the open SeedSigner standard.

- -

-Wherever the phrase is displayed, a QR code tab is offered alongside the -words. For the main (BIP-39) phrase the default format is SeedQR: 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. -

- -
    -
  • The QR contains exactly the same secret as the words — treat a printout or screenshot of it with identical care.
  • -
  • The Lightning aezeed is deliberately never 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.
  • -
  • Restore is by typed/pasted words; there's no camera-based SeedQR scanner on the restore path today.
  • -
- -

Honest Edges

-

Known trade-offs and open hardening items, stated plainly.

- -

-The audit that produced this system also tracked what it didn't fix. None of -these are secrets — honest security means naming the edges: -

- -
    -
  • The words cross the RPC boundary. 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.
  • -
  • Argon2 parameters are the library defaults (≈19 MiB, 2 passes) rather than the heavier profile the design doc calls for. Still memory-hard and slow for attackers; scheduled for tightening.
  • -
  • 2FA backup codes carry a slight statistical bias from a modulo operation — cosmetically imperfect, cryptographically irrelevant at their length, and queued for cleanup.
  • -
  • The lint ban covers the main workspace, but one small helper crate outside the workspace isn't reached by it yet.
  • -
  • Best-effort backup sealing: if writing master_seed.enc 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.
  • -
- -

Verify It Yourself

-

Don't trust — recompute.

- -

-Because every derivation is deterministic and label-fixed, you can independently confirm -that your node's keys really do come from your words: -

- -
    -
  • Independent re-derivation script: scripts/verify-seed-derivation.py in the Archipelago source — pure-standard-library Python, no Archipelago code. Paste your mnemonic (on a trusted, offline machine) and it recomputes node_key, nostr_secret and fips_key, byte-comparing them against /var/lib/archipelago/identity/.
  • -
  • Known-answer tests: 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.
  • -
  • Non-determinism regression test: 64 consecutive generated mnemonics are asserted unique — a canary against the "predictable RNG" failure class.
  • -
  • Your entropy audit trail: /var/lib/archipelago/security/csprng-readiness.jsonl records, append-only, the kernel randomness verdict at every key-generation event on this node — including the moment your seed was born.
  • -
- -
- The whole story in one paragraph - 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. -
- -
- -

- Archipelago — Seed & Entropy Guide · reflects the KEY-05 hardened implementation (August 2026) · - see also the LoRa & Mesh Guide -

-
+ + + + diff --git a/neode-ui/public/entropy/nav.js b/neode-ui/public/entropy/nav.js new file mode 100644 index 00000000..37987665 --- /dev/null +++ b/neode-ui/public/entropy/nav.js @@ -0,0 +1,29 @@ +// Scroll-spy for the sidebar, mirroring the dashboard's nav-tab-active state. +// External file (not inline) because the node's CSP is script-src 'self'. +(function () { + var links = Array.prototype.slice.call( + document.querySelectorAll('.sidebar-nav .sidebar-nav-item[href^="#"]') + ) + if (!links.length || !('IntersectionObserver' in window)) return + + var sections = links + .map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)) }) + .filter(Boolean) + + function activate(id) { + links.forEach(function (a) { + a.classList.toggle('nav-tab-active', a.getAttribute('href') === '#' + id) + }) + } + + var visible = {} + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting }) + // Topmost section currently on screen wins, so the highlight tracks reading position. + for (var i = 0; i < sections.length; i++) { + if (visible[sections[i].id]) { activate(sections[i].id); return } + } + }, { rootMargin: '-10% 0px -70% 0px', threshold: 0 }) + + sections.forEach(function (s) { observer.observe(s) }) +})()