security: remove all infrastructure and internal process material from the repo
Demo images / Build & push demo images (push) Failing after 2m13s

The repo is source code and guidelines only. Nothing about how Archipelago's
own fleet is run, or how the team works, stays in it.

Untracked (kept on disk, gitignored) — 250 files:
- .planning/ (199) and loop/ — internal development process
- fleet operations tooling that targets specific nodes: deploy-to-target,
  deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server,
  setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair,
  image-recipe/sync-from-live.sh
- image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of
  which are live-server workflow and fleet node inventories
- the Phase 10 on-node verification and evidence records, which cite .planning/
  as their evidence base

KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10
docs and then put back: it is cited as normative rationale from ten places in
the codebase, including core/clippy.toml, which bans rand::thread_rng and
points at it for the reason. That makes it a guideline, not an internal record.

Node names removed from source (48 occurrences across comments, manifests and
test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt,
zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the
date, which is what carried the meaning; the machine name did not.

Three of those were live test values rather than comments and were replaced
with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name.
An earlier pass substituted "a test node" into a hostname assertion, producing
an invalid hostname; caught and fixed as test-node.local.

Wipe mechanism: .local-only/manifest.txt inventories every local-only path and
.local-only/wipe.sh deletes them on one confirmation, refusing to touch
anything git still tracks. Both are themselves untracked, so the public repo
does not carry a map of internal filenames.

Verified: cargo check -p archipelago --all-features clean; archipelago-container
75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link
in tracked markdown resolves (0 broken).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 10:37:20 -04:00
co-authored by Claude Opus 5
parent cc00884b98
commit 6ba0599639
282 changed files with 119 additions and 58845 deletions
@@ -1,205 +0,0 @@
# HANDOFF — deploy companion APK 0.5.1 (vc21) to nodes
**For: the agent on archi-dev-box.** User-reported failure this evening:
pairing flow on Framework PT — downloaded the companion from the node's
QR, then the pairing scan didn't work. The APK the node serves predates
today's scanner fixes; the pipeline below gets the fixed build into the
user's hands.
## What changed on main today (all merged)
- **Pairing-scanner fix** (`QrScannerOverlay.kt`): ZXing decode attempts are
frame-gated (~7/s, was every frame — the CPU contention made the preview
stutter badly enough to never decode) and PreviewView uses TextureView (no
more black flash on open). This is the likely fix for "doesn't scan".
- Three-finger menu gesture (was two-finger, collided with scroll) + one-time
teaching overlay ~2 min after login.
- Native wallet QR scanner behind `window.ArchipelagoQr` + WebView file-chooser
support; web scan modal hands live scanning to it.
- npub-keyed saved servers (pairing contract item 1, PR #106).
- Served APK refreshed: `neode-ui/public/packages/archipelago-companion.apk`
is now **0.5.3 / versionCode 23**. On top of the 0.5.1 scanner fixes it
guarantees dual-path peering — the node's LAN endpoint (direct p2p, npub-
keyed dial hints) AND the Archipelago public anchor (vps2, baked into the
app so even an old node's QR can't leave the phone LAN-only) — and fixes
the two field failures from the user's 5G test (screenshots, 21:54):
- **Mesh VPN no longer kills the phone's internet** — the IPv6-only TUN
never called `allowFamily(AF_INET)`, so Android blocked all IPv4 while
the mesh was up. Now allowed (+ `allowBypass`).
- **Off-LAN connect works** — `connect()` no longer hard-fails when the
scanned LAN IP doesn't answer; it brings the mesh up and probes the
node's ULA (`meshIp`) with retries before reporting failure.
## What to do
1. Redeploy the web-ui bundle from current main to the active nodes —
web root `/opt/archipelago/web-ui/` (NOT a neode-ui/ subfolder), all
nodes the user pairs against, at minimum the one Framework PT scans.
2. Verify the served artifact really updated:
`curl -sI http://<node>/packages/archipelago-companion.apk` — size should
change (~27 MB build of 2026-07-23), or pull it and check
`aapt dump badging` shows `versionCode='21' versionName='0.5.1'`.
3. The demo stack gets its images from CI (run 100 pushed today with the new
web bundle) — confirm the Portainer stack re-pulled, or trigger its
redeploy, so the demo QR also serves vc21.
4. **Node side is half the 5G story**: away-from-home reachability needs the
NODE connected to the public anchor too. On Framework PT (and any test
node): deploy current main (node-side npub-first `fips.pair-info`), then
verify `sudo -n fipsctl show status` reports the anchor connected —
`fips.reconnect` RPC if not. A phone can dial the anchor perfectly and
still fail if the node never enrolled with it.
5. Re-test the user's exact flows with **vc24** (updates any older install in
place): (a) pair ON the LAN, then switch the phone to 5G — the UI must
come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the
node's LAN) — scan, VPN consent, and the connect must succeed through
the anchor.
## Live diagnosis update (22:3022:50, phone on adb — Mac agent)
vc23 on-device testing found and fixed the phone-side blocker, and narrowed
what remains to the node side. State as of vc24:
- **Fixed: TUN reader died at startup.** Android hands the VpnService fd over
non-blocking; the fips fork's blocking reader thread treats EAGAIN as fatal
("TUN read error … Try again (os error 11)") — so mesh sessions came up but
NO packet ever entered the tunnel. archy-fips-core now forces the fd
blocking before `start_with_tun_fd`. Verified on-device: reader survives,
and the 30s anchor-link flap disappeared with it (stable 8+ min on 5G).
- **Fixed: VPN marked not-metered** (`setMetered(false)`) — Android 10+
defaults VPNs to metered, putting the phone into data-restricted behaviour
while the mesh is up. Note the user's phone also has system **always-on
VPN** enabled for the app (`always_on_vpn_app`), a Settings-side toggle.
- **Verified good on-device**: peer store has node (LAN udp/tcp hints) + vps2
anchor; saved server is npub-keyed with ULA; anchor session establishes
from 5G in ~6s; VPN is bypassable, VALIDATED, only fd00::/8 routed.
- **REMAINING BLOCKER (node side)**: from the phone (app uid), ping6 and
HTTP to the node's ULA `fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824` get no
reply — packets enter the mesh, nothing returns. Phone↔anchor works, so
suspect phone-fork ↔ node-daemon session/routing mismatch (FIPS wire
format is not stable across revs; phone pins fips-native fork 46494a74).
From Framework PT please capture:
- `fipsctl show status` (daemon version + anchor state)
- `fipsctl show sessions` and `show bloom` while the phone pings
- `ping6 <phone ULA fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586>` from the
node (tests the reverse path)
- `ip addr show fips0` + confirm the web server listens on `[::]:80`
Report whether the node ever sees a session attempt from
`npub132c5whrsa6ccs0eylcpzaejq9uxul5ldvczz0axq78dh7fxkqj9st4uvzu`.
## Node-side diagnosis complete (23:0023:30, archi-dev-box agent)
Chain of findings, each verified live:
1. **FIXED: nginx had no IPv6 listener anywhere** — every shipped config
listened on 0.0.0.0 only, so `http://[<ULA>]` could NEVER connect on any
node, ever. Live-fixed on framework-pt + .116, canonical conf + bootstrap
self-heal shipped (`1e89362e`), heal binary deployed. ULA HTTP verified
answering on both nodes (local + over-mesh).
2. Firewall clean, fd00::/8 routes correct both ends, wire compat proven
(node + vps2 anchor both run fips 0.4.1 rev 15db6471db — the latest
upstream stable; nothing newer exists).
3. **THE REMAINING PROBLEM IS MESH SESSION PATH QUALITY.** From the vps2
anchor — a DIRECT connected peer — `GET /health` on the node's ULA takes
**1517 s per request and intermittently fails outright** (nginx logs
show 499 client-gave-up then 200; TCP SYN-retransmit backoff signature).
Session MMP is wildly asymmetric: node→vps2 srtt 204 ms, **vps2→node
srtt 4270 ms** — on a direct link whose raw RTT is 4 ms. Session traffic
is not riding the direct link; it appears to route through the ~1271-node
public tree (node's tree root is the public 00001a8c, depth 8; the node's
log also shows chronic "Discovery lookup timed out" for other targets).
4. The phone's npub never appears in the node's sessions — consistent with
discovery/handshake dying on the same degraded tree path, and the app's
~8 s probe window being far smaller than the observed 15 s+ first-request
latency even on the GOOD path.
### Recommendations
- **App side (Mac agent):** widen the ULA probe/connect window to ≥30 s
with retransmit-friendly pacing, and PRE-WARM the mesh session (start
pinging the node ULA as soon as the VPN is up, decoupled from the UI
probe) so the WebView hits a warm session.
- **Infra decision (user):** consider detaching the fleet from the public
v0l mesh — private tree rooted at the vps2 anchor (drop the legacy
185.18.221.160 seed anchor fleet-wide AND vps2's public peering). A
2-hop private tree would make session paths ride the direct links and
should collapse latency to ms. Trade-off: no reachability to/from the
broader public mesh.
- **Upstream:** report the direct-peer session-path asymmetry to
jmcorgan/fips (0.4.1).
## App-side recommendations implemented (23:3023:50, Mac agent — 0.5.5/vc25)
- Connect probe: mesh ULA now probed inside a **60s budget** with 15s
per-phase timeouts (rides out TCP retransmit backoff), replacing the old
~8s window.
- **Session pre-warm**: the VPN service starts probing every saved node ULA
the moment the tunnel is up (5s cadence for the first minute, then a 60s
keep-warm tick) — discovery/handshake cost is paid in the background, and
the session never idles out while the mesh is connected.
- (A phone-side ping test in this window still showed zero replies — that
measurement predated the vps2 daemon restart below and is superseded.)
## RESOLVED — root cause was vps2's degraded daemon, NOT the public tree (23:45)
The privatize-the-mesh recommendation above is WITHDRAWN. Final diagnosis:
vps2's fips daemon (3 days uptime, 0.2% CPU, idle box) had internally
degraded — EVERY link it carried showed ~4.5 s RTT (even to peers 30 ms
away), and since the anchor sits on the phone↔node path, everything through
it inherited that. `systemctl restart fips` on vps2 restored link RTTs to
40340 ms, and anchor→node mesh HTTP went from 1417 s (intermittent hard
fails) to a steady **165275 ms**. Node↔node direct sessions were always
fine (.116→framework-pt ULA HTTP: 894 ms cold, sub-second warm) — the
user's read was correct.
Actions taken: dead legacy anchor (185.18.221.160) removed from
framework-pt + .116 seed files (fleet keeps vps2 + public-mesh membership
via vps2 — we stay in the open mesh); fresh daemons on both nodes;
**vps2 fips now has RuntimeMaxSec=1d + Restart=always** so a wedged anchor
daemon can never rot for days again. Report the slow-degradation behaviour
upstream (jmcorgan/fips, 0.4.1): long-running daemon in a ~1400-node mesh
accumulates multi-second link latency at idle CPU, cleared by restart.
Phone side: vc25's 60 s probe + pre-warm now has a millisecond-latency mesh
to work with. Ready for the user's 5G test.
## NEXT (00:05, Mac agent → dev-box agent): app direct ports are IPv4-only over the mesh
The kiosk loads over the ULA now — but opening any APP dies with
`ERR_CONNECTION_REFUSED` at `http://[<ULA>]:<port>/`. User-hit first on
**Bitcoin Knots (:8334)**, and it will be every catalog app: the web UI
builds app URLs from the current host + the app's DIRECT port (Direct Port
Rule), and container-published ports only bind 0.0.0.0. Verified:
`192.168.63.249:8334` → HTTP 200 (nginx), ULA:8334 → refused. Same disease
as your :80 nginx fix, one layer down.
Fix must cover EVERY catalog app port and survive app install/remove. Two
shapes; pick what fits the container layer best:
1. **IPv6 publish at the container layer** — publish on `[::]` too
(pasta/rootless podman support address-specific `-p`), wired into the
container manager so new apps inherit it; or
2. **Host-side v6→v4 forwarders** — generated nginx `stream {}` (or
systemd-socket) units: `listen [::]:<port>``127.0.0.1:<port>`, one per
catalog app port, regenerated on app install/remove, boot-time
self-healed like the :80 fix. Keeps the Direct Port Rule URL contract
without touching containers.
Either way: extend the bootstrap self-heal, and verify from the MESH side
(curl the ULA on 23 app ports incl. :8334 from vps2 or .116) — not just
from the LAN.
## DONE (00:30, dev-box agent): app direct ports live over the mesh
Shape 2-variant implemented INSIDE the backend (`mesh_ports.rs`, `2ad57c63`):
a reconcile loop mirrors every public IPv4 listener (>=1024, bound 0.0.0.0,
no existing IPv6 any-listener) as a v6-ONLY `[::]:<port>` forwarder to
`127.0.0.1:<port>`, following `/proc/net/tcp*` every 15s — so app
install/remove and hardcoded companion ports (bitcoin-ui :8334) are covered
with zero container changes and no generated units; self-healing because it
lives in the binary. Strictly ADDITIVE: IPv4/LAN/Tor paths untouched, v6only
cannot intercept v4, foreign IPv6 listeners win.
Verified FROM THE MESH (vps2 → node ULA): :8334 HTTP 200 (466ms),
:18083 200 (306ms), :50002 200 (239ms); LAN :8334 still 200. Deployed to
framework-pt + .116 (binary sha 52ac0d8a…). Direct-port apps should now
open in the companion over 5G.
+8 -14
View File
@@ -54,7 +54,6 @@ archy/
│ ├── vite.config.ts
│ └── package.json
├── scripts/ # Deployment and utility scripts
│ ├── deploy-to-target.sh # Main deploy script
│ ├── first-boot-containers.sh # ISO first-boot setup
│ └── run-tests.sh # CI test runner
├── image-recipe/ # ISO build configuration
@@ -91,19 +90,16 @@ The dev server at `http://localhost:8100` uses a mock backend.
### Deploying Changes
Release and host-integration builds should run on Linux. The deploy script rsyncs
source to a configured Linux target and builds there.
Release and host-integration builds should run on Linux. Build the backend and
frontend on the target, or cross-build and copy the artifacts across:
```bash
# Deploy to the configured primary target (builds backend + frontend, restarts services)
./scripts/deploy-to-target.sh --live
# Deploy to both configured targets
./scripts/deploy-to-target.sh --both
cd core && cargo build --release
cd neode-ui && npm ci && npm run build
```
The deploy script:
1. Rsyncs source to the server
A deploy then:
1. Copies the build output to the node
2. Builds Rust backend on the server (`cargo build --release`)
3. Builds Vue frontend (`npm run build`)
4. Copies artifacts to production paths
@@ -203,7 +199,6 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string
### 5. Deploy and Test
```bash
./scripts/deploy-to-target.sh --live
curl -X POST http://<node-host>/rpc/v1 \
-H "Content-Type: application/json" \
-b "archipelago_session=YOUR_SESSION" \
@@ -312,6 +307,5 @@ mod tests {
1. Create a feature branch: `git checkout -b feature/my-feature`
2. Make changes following the standards above
3. Test locally: `cd neode-ui && npm test`
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
5. Verify on your configured development target
6. Commit with conventional format: `feat: add my feature`
4. Verify on an Archipelago node
5. Commit with conventional format: `feat: add my feature`
-69
View File
@@ -1,69 +0,0 @@
# Multinode / Fleet Testing Plan (separate from the single-node gate)
> **Scope split (2026-06-22):** the production test gate (
> `tests/lifecycle/TESTING.md`) is now a **single-node criterion on .228**. Verifying the same
> lifecycle matrix across the rest of the fleet (.198 and the other testers) lives HERE and is run
> **after** the .228 single-node gate is green. This is intentionally NOT a blocker on the .228 gate.
## Why split it out
The lifecycle gate must be **run ON the node under test** — its bitcoin/companion/orphan/endpoint
checks use local `podman`/`systemctl`/`bitcoin-cli`/`curl`, not RPC to a remote host. Running it from
one host against another silently tests the *runner*. So "multinode" isn't "point the harness at N
hosts" — it's "run the on-node gate on each host," plus the genuinely cross-node concerns (federation,
mesh, transport, sync) that a single node can't exercise.
## How to run the gate on another node
Bats + jq usually aren't installed on ISO nodes. Bootstrap (one-time per node):
```
# from a host that has them (e.g. .116):
dpkg -L bats | grep -E '^/usr/(bin|lib|libexec)' | tar czf /tmp/bats.tgz -P -T - $(which jq)
tar czf /tmp/tests.tgz -C <repo> tests/lifecycle
scp /tmp/bats.tgz /tmp/tests.tgz <node>:/tmp/
# on the node:
sudo tar xzf /tmp/bats.tgz -P -C / # bats (jq here is dynamically linked — may need libs)
sudo curl -fsSL -o /usr/local/bin/jq \
https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 && sudo chmod +x /usr/local/bin/jq
mkdir -p /tmp/lifecycle-run && tar xzf /tmp/tests.tgz -C /tmp/lifecycle-run
cd /tmp/lifecycle-run/tests/lifecycle
ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=https ARCHY_PASSWORD=<node pw> \
ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 nohup ./run-gate.sh > /tmp/gate.log 2>&1 &
```
## Per-node preconditions (learned on .228)
- **Bitcoin must be fully synced + archival** (`initialblockdownload:false`, `pruned:false`).
test 83 reads the *real* `getblockchaininfo`, not the UI's headers-height. A node mid-IBD will
cascade-fail electrumx/lnd/btcpay/mempool even though the apps run.
- **Backends should be proper installs** (in `manifest_ids`), not adopted plain-podman left over
from ad-hoc `package.start`/cascade churn — otherwise companion self-heal and quadlet checks skew.
- **No stale per-app nginx proxy targets.** e.g. `/app/lnd/` must point at the lnd-ui port (18083),
not a stale `8081`. Repo code is correct; old node configs may be stale — re-check + regenerate.
- **No orphan quadlet units** (e.g. a `home-assistant.container` whose ContainerName ≠ the real
`homeassistant` container) — these wedge `systemctl --user` "activating" and fail the quadlet checks.
## Node roster (carry-over)
| Node | Role | Notes |
|------|------|-------|
| .228 | **single-node gate** (primary) | 14-app resilience node; bitcoin synced archival; gate GREEN. |
| .198 | fleet verify | was weak/loaded (load ~35) + **bitcoin mid-IBD** at split time → must finish syncing first; sshd wedges under concurrent SSH (use ONE session; gate uses HTTPS RPC so fine). |
| .5 / .120 | x250 testers (Tailscale) | flaky cellular; SSH via `tailscale nc` ProxyCommand. |
| .116 | dev/validation | local repo; its own bitcoin may be mid-IBD — do NOT treat as a gate target unless synced. |
## Cross-node concerns (only a multinode setup can test)
- Federation sync (Tor/FIPS transports), DID/contact federation, peer file fetch.
- Mesh (Meshtastic/MeshCore) + mesh-AI gating.
- Dual-ecash federation validation + networking-sats routing.
- DHT / iroh swarm distribution (origin-always-wins) once that dep lands.
## Sequence
1. Get the **.228 single-node gate green 5×** (master plan §5/§6) — DONE/in progress.
2. THEN: bring each fleet node to the preconditions above; run the on-node gate 5× per node.
3. THEN: the cross-node suites (federation/mesh/transport), tracked here.
This plan does not gate the v1.7.x single-node criterion; it is the next layer.
-82
View File
@@ -1,82 +0,0 @@
# QR scanner snappiness — research + companion-dev handover
*2026-07-29. Owner: web side = node repo (this doc's "web" items); native side =
companion app dev (Mac). Backlog origin: "optimise
companion QR scan (quicker start/decode, low-light)".*
## Where scanning happens today
| Path | Stack | Used when |
|---|---|---|
| Web live scan | nimiq `qr-scanner` 1.4.x over `getUserMedia`, in `WalletScanModal.vue` | HTTPS browsers / secure contexts |
| Photo fallback | `<input capture>` photo → `BarcodeDetector` if present, else `qr-scanner.scanImage` multi-pass (`decodePhotoRobust`) | Plain-http (LAN) where `getUserMedia` doesn't exist |
| Native scan | `ArchipelagoQr` JS bridge → companion's native scanner (0.5.22 fixed dense invoice QRs) | Inside the companion app |
## What makes it feel slow (ranked)
1. **Camera cold-start** — the stream starts only after the user reaches the
scan pane; on phones `getUserMedia` + first frame is routinely 6001500ms,
and the native path pays a similar CameraX bind + ML Kit model cold-start.
2. **Decode cadence** — web live scan was capped at 4 scans/sec (WebView
preview lagged at 10/s when decoding on the JS worker). A hand-held code
therefore waits up to 250ms *after* it's already sharp and centered.
3. **Low light / focus hunting** — no torch control anywhere; no explicit
continuous-focus request. Dense LN invoices need sharpness more than
resolution.
4. **Dense-QR decode budget** — big bolt11/catalog QRs push the JS decoder
hard; the native ML Kit path is far better at these (proven by 0.5.22).
## Web side (node repo — can be done here)
- ✅ DONE (2026-07-29): scan at **10/s when `BarcodeDetector` exists** (Chrome/
Android WebView decode natively — cheap), keep 4/s only for the JS-worker
fallback.
- **Pre-warm the camera**: start `getUserMedia` the moment the modal opens
(action pane), not when the scan pane is reached — hide the preview until
needed. Saves the entire cold-start from the user's perceived timeline.
- **Torch toggle**: `qr-scanner` exposes `hasFlash()/turnFlashOn()` — add a 🔦
button on the scan pane (it silently no-ops where unsupported).
- **Continuous focus + modest resolution**: pass constraints
`{ focusMode: 'continuous', width: { ideal: 1280 } }` — 720p-class frames
start faster AND decode faster than 1080p+, with no loss for QR density
that matters to us.
- **Don't stop/start between panes**: returning from amount → scan currently
re-inits the scanner; keep the (paused) stream alive while the modal lives.
## Native side (companion dev handover)
The `ArchipelagoQr` bridge overlay is the right architecture — these are
tuning items inside the native scanner activity:
1. **Pre-warm CameraX + ML Kit**: bind the camera provider and instantiate
`BarcodeScanning.getClient(...)` when the WebView *requests* the overlay —
or even when the wallet modal opens (add a `ArchipelagoQr.prewarm()` bridge
method; the web side will call it if present). ML Kit's first-inference
model load is 100300ms — pay it before the user aims.
2. **Restrict formats**: `BarcodeScannerOptions` with `FORMAT_QR_CODE` only —
skipping the other symbologies measurably cuts per-frame latency.
3. **Analysis resolution ≈ 1280×720** with
`ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` — never queue stale frames;
decode the newest one only.
4. **Continuous autofocus + tap-to-focus** on the preview, and a **torch
toggle** (low-light was an explicit user complaint).
5. **`zoomRatio` nudge for small codes**: if no hit after ~2s, step zoom to
1.5× — helps distant/small printed codes without user action.
6. **Success haptic + instant dismiss**: vibrate on decode and close the
overlay immediately; perceived speed is heavily back-loaded.
7. Optional: **ML Kit `enableAllPotentialBarcodes` off** and skip inverted
scans unless first pass fails (inverted QRs are rare; halves work).
## Acceptance criteria
- Cold open → first successful scan of a normal invoice QR in **< 2s** on the
companion app, **< 3s** in a mobile browser.
- Dense (700+ char) bolt11 QR decodes in **< 1.5s** once framed, both paths.
- Dim-room scan succeeds with the torch toggle without leaving the scanner.
## Verification notes for whoever implements
- Measure with a timestamp log: overlay-requested → camera-first-frame →
decode-success. The three deltas map 1:1 onto items above.
- Web `BarcodeDetector` presence differs per WebView/Play-Services build —
keep the JS-worker fallback path intact.
+3 -3
View File
@@ -1,7 +1,7 @@
# The Bitcoin RPC proxy that stayed open after it was fixed
**Status:** code fix committed (`f6b5245b`); on-node verification recorded below.
**Found:** 2026-08-02, archi-dev-box, while verifying `a05956c4` instead of assuming it.
**Found:** 2026-08-02, a test node, while verifying `a05956c4` instead of assuming it.
**Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC
through a proxy that injects the node's own credentials.
@@ -79,7 +79,7 @@ Deliberately narrow:
survived. A new regression test pins the whole chain: stale conf in, gate present out,
container restarted, nothing created.
## What actually closed it on archi-dev-box — and what that does NOT prove
## What actually closed it on a test node — and what that does NOT prove
Sequence, from file mtimes, container start times and the daemon journal:
@@ -104,7 +104,7 @@ Tracked as broken window 15 — **since closed by the controlled test below.**
## Proving the delivery path on real hardware
Run on archi-dev-box, 2026-08-02 20:0020:03 EDT, with operator approval. The point was to
Run on a test node, 2026-08-02 20:0020:03 EDT, with operator approval. The point was to
prove the thing the incidental rebuild had made unprovable: that **reconcile itself**
repairs this state, unaided.
File diff suppressed because it is too large Load Diff
@@ -1,224 +0,0 @@
# KEY-01 on-node verification — audit item C-6 and the F-01 refusal proof
**Status: INCOMPLETE — C-6 is NOT yet verified.**
**Opened:** 2026-08-02 · **Phase:** 10 (key-material hardening) · **Plan:** 10-02
**Probe:** `scripts/security/rpc-exposure-probe.sh`
This document records on-node evidence for
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §6 item **C-6** ("Is the RPC endpoint
reachable unauthenticated from the LAN?") and for the KEY-01 / F-01 refusal shipped by
plan 10-01 (`core/archipelago/src/api/rpc/onboarding_gate.rs`, commit `879de59e`).
Nothing below is recorded unless it was actually executed and its output observed. Rows
marked **NOT MEASURED** are open work, not assumptions. Per threat T-10-13 this document
records node **labels** and status codes only — never raw LAN addresses, onion addresses
or mesh ULAs, because this repository is being prepared for open-sourcing.
---
## Probe-method correction
**The audit's own C-6 command cannot detect the condition it claims to test. Do not
re-derive this; it has now been checked against the code twice.**
`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901` probes with `seed.status` and declares
`200` a failure. But `seed.status` is **not** in `UNAUTHENTICATED_METHODS`
(`core/archipelago/src/api/rpc/middleware.rs:5-38`, which lists `seed.generate`,
`seed.verify`, `seed.restore` and `seed.save-encrypted` — not `seed.status`). An
unauthenticated `seed.status` is therefore rejected at
`core/archipelago/src/api/rpc/mod.rs:293` with a **401 by design**. The audit's "Fail:
200" criterion can never fire, so the probe would report the unauthenticated surface as
closed while F-01's actual door stands open.
`scripts/security/rpc-exposure-probe.sh` measures the two facts separately:
| Signal | Method | Why | Reading |
|---|---|---|---|
| **Exposure** | `auth.isOnboardingComplete` | genuinely unauthenticated (`middleware.rs:9`), read-only, no side effects | `200` = the unauthenticated RPC surface is reachable from this vantage point. This is the honest C-6 result. |
| **Session enforcement** | `seed.status` | deliberately *not* allowlisted | `401` = the session check is working. Anything else is a worse finding than C-6 and halts the phase. |
The probe reports a reachable unauthenticated surface as `EXPOSED`, not `FAIL`: on the LAN
this is the current expected posture, and the purpose of C-6 is to **measure** the surface,
not to assert it is already closed.
---
## C-6 — unauthenticated RPC reachability
### Result table
| Transport | Label | `health` | `auth.isOnboardingComplete` (exposure) | `seed.status` (enforcement) | Status |
|---|---|---|---|---|---|
| Loopback | `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 |
| Node's own LAN address, probed *from the node itself* | `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 |
| LAN, from a second machine | `lan` | — | — | — | **NOT MEASURED** |
| Tor onion | `tor` | — | — | — | **NOT MEASURED** |
| FIPS mesh ULA, from a peer node | `mesh` | — | — | — | **NOT MEASURED** |
**`seed.status` returned `401` on every vantage point actually tested.** No
stop-the-plan condition was observed.
### Why the two measured rows are NOT a C-6 result
Both runs originated **on the node under test**. Packets to the node's own addresses are
delivered by the local stack and never traverse the LAN, so neither run exercises the
external path an attacker would use, and neither run passes through any host or upstream
filtering that applies only to foreign packets. They are recorded because they establish
two real facts — the probe works against a live daemon, and session enforcement is intact
— but C-6 asks specifically whether a **different machine** can reach the surface, and
that question is still open.
### Verbatim probe output (measured rows)
```
$ bash scripts/security/rpc-exposure-probe.sh --target 127.0.0.1 --scheme http --port 80 --label loopback
RPC exposure probe — label=loopback endpoint=http://127.0.0.1:80
audit item C-6 · KEY-01 (F-01) · read-only mode
[loopback] health 200 REACHABLE endpoint answers from this vantage point
[loopback] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result)
[loopback] seed.status 401 PASS session enforcement active for non-allowlisted methods
[loopback] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200
exit=0
```
```
$ bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label self-lan-ip
RPC exposure probe — label=self-lan-ip endpoint=http://<node-lan-ip>:80
audit item C-6 · KEY-01 (F-01) · read-only mode
[self-lan-ip] health 200 REACHABLE endpoint answers from this vantage point
[self-lan-ip] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result)
[self-lan-ip] seed.status 401 PASS session enforcement active for non-allowlisted methods
[self-lan-ip] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200
exit=0
```
### Corroborating host state (observed, but NOT a substitute for the LAN measurement)
Recorded because it predicts the LAN result and tells the operator what to expect:
- nginx listens on **`0.0.0.0:80` and `[::]:80`** (`ss -ltn`), i.e. on every interface,
not on loopback only. The daemon itself is bound loopback-only on `127.0.0.1:5678`, so
all external reachability is via nginx.
- The host packet filter does **not** block port 80: `iptables -S INPUT` is
`-P INPUT ACCEPT` with a single jump into Tailscale's chain, and the `nft` ruleset
contains only Tailscale's `ts-input`/`ts-forward` chains — no rule matching tcp/80.
Together these make an `EXPOSED` LAN result very likely. **That is a prediction, not a
measurement, and C-6 stays open until a second machine produces the status code.**
### Incidental finding — `/rpc/` is not a second door
`auth.isOnboardingComplete` on nginx's `location /rpc/` block
(`image-recipe/configs/nginx-archipelago.conf:192`) returned **404** from both vantage
points. The block proxies the full URI to the backend, which only routes `/rpc/v1`, so
the unauthenticated surface is reachable through exactly one path. This narrows F-01's
exposure surface by one path and should be re-checked if the nginx config changes.
---
## KEY-01 refusal check — NOT PERFORMED
**Requirement:** on a node running 10-01's gate, an unauthenticated `seed.restore`
carrying attacker-supplied words is refused, and `identity/node_key` and
`identity/nostr_secret` are byte-identical afterwards.
**Blocker — no node in the fleet is running 10-01's gate yet.** Verified on the dev-box
rather than assumed:
```
$ ls -l /usr/local/bin/archipelago
-rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago
$ git log -1 --format='%H %ci' 879de59e
879de59eccb489d590c8e0fca6ae79098df68200 2026-08-02 13:05:35 -0400
$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago \
&& echo PRESENT || echo ABSENT
ABSENT
```
The installed binary was built at 06:37; 10-01 landed at 13:05 the same day, and the
gate's refusal string is absent from the running binary. A `--destructive` run against
this node would therefore **not** be refused — it would replace the node's identity. The
dev-box is a live dev-pair deploy target in real use, so the run was not made.
**This check is blocked on deployment, which the phase brief explicitly excludes from
this plan.** It cannot be closed by any amount of work inside the repository.
---
## Fresh-node onboarding non-regression — NOT PERFORMED
**Requirement:** a genuinely un-onboarded instance completes the whole wizard with 10-01's
gate in place (the anti-brick proof for correctness trap 1 and the D-03a signal
correction), then refuses `seed.restore` immediately afterwards.
**Blocker — no un-onboarded instance exists.** The intended harness is shape (A) of
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`
(a second daemon under its own `ARCHIPELAGO_DATA_DIR`/`ARCHIPELAGO_BIND`/
`ARCHIPELAGO_PORT_OFFSET`), and that todo is still **pending** — the harness has not been
built. It would additionally need a binary built from `879de59e` or later, which the
running daemon is not.
Note for whoever builds it: that todo records that several constants ignore
`ARCHIPELAGO_DATA_DIR` and point at `/var/lib/archipelago` literally
(`bitcoin_rpc.rs:10`, `container/lnd.rs:131`, `electrs_status.rs:15`,
`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242`, `disk_monitor.rs:41`), so a shape-A
instance must not install Bitcoin, LND, electrumx or Pine/HA — it would read and write the
live node's files. The onboarding walkthrough this check needs does not install apps, so
the hazard is avoidable, not blocking.
---
## Pre-OTA fleet check carried over from 10-01
10-01's summary records a state that its gate makes unrecoverable: a node with
`onboarding.json = {"complete": true}` but **no** `user.json` can no longer call
`auth.setup`, and the recovery path needs a session it cannot create. Recovery is one SSH
command (`rm /var/lib/archipelago/onboarding.json`), but the fleet must be checked
**before** the OTA ships (D-10).
| Node label | `user.json` | `onboarding.json` | Verdict |
|---|---|---|---|
| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally; the gate refuses re-keying, which is the intent |
| rest of fleet | — | — | **NOT CHECKED** |
Command to run per node:
```bash
ls -l /var/lib/archipelago/user.json /var/lib/archipelago/onboarding.json 2>&1
cat /var/lib/archipelago/onboarding.json 2>/dev/null
```
A node is at risk only if `onboarding.json` says `complete: true` **and** `user.json` is
absent.
---
## What is still required to close C-6 and KEY-01
Every item below needs an operator with fleet access; none can be done from the repository.
1. **LAN exposure.** From a second machine on the node's LAN:
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan`
2. **Tor exposure.** `torsocks bash scripts/security/rpc-exposure-probe.sh --target <onion> --scheme http --port 80 --label tor`
3. **Mesh exposure.** From a peer node over the FIPS mesh ULA:
`bash scripts/security/rpc-exposure-probe.sh --target <fips-ula> --scheme http --port 80 --label mesh`
(the peer listener allows `/rpc/v1``core/archipelago/src/server.rs:1270-1296` — so a
`200` confirms the mesh half of F-01's reachability claim). An unreachable transport is
recorded as `UNREACHABLE` with its error, never omitted.
4. **Deploy 10-01 to a disposable node**, then, from a second machine:
`bash scripts/security/rpc-exposure-probe.sh --target <disposable-node> --destructive --label refusal`
with `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret`
captured on the node immediately before and after. The response must carry the
`Not supported:` prefix and the two digests must match character for character.
5. **Build shape (A)** and walk the wizard end to end on a 10-01 binary
(intro → options → path → seed → seed-verify → did → identity → backup → verify → done,
then set the password), reloading once on the seed screen to confirm the same 24 words
return. No `Not supported:` and no `Rate limit exceeded` may appear at any point. Then
re-run step 4 against that same instance to confirm the door closed behind onboarding.
6. **Check the remaining fleet** for the `onboarding.json`-without-`user.json` state above.
Until items 13 are done, audit item **C-6 remains UNVERIFIED**. Until item 4 is done, the
KEY-01 refusal is proven only by 10-01's unit tests against temp directories, never against
a running daemon over HTTP.
-208
View File
@@ -1,208 +0,0 @@
# KEY-02 — build-host evidence for the rootfs identity strip
**Audit item:** C-4 of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (§868), which
belongs to finding **F-03** (fail-open, never-retried first-boot secret regeneration over
a fleet-shared rootfs).
**Status: ⛔ UNVERIFIED — awaiting a run on a real ISO build host.**
The code change is committed and unit-tested; the tar listing that proves its effect on a
real build has not been produced yet, because it requires a build host with podman/docker
and enough disk for a full rootfs rebuild. Do not read anything below the "Result" heading
as a passing check until it is filled in.
| Field | Value |
|---|---|
| Builder commit (Task 1) | `21043096` — fail-closed first-boot regeneration |
| Builder commit (Task 2) | `408b328c` — rootfs identity strip |
| Builder commit (follow-up) | single-producer unification, build-time generator assertion, self-heal timer |
| Builder file | `image-recipe/_archived/build-auto-installer-iso.sh` (LIVE; `image-recipe/build-debian-iso.sh` execs it) |
| Build host | _to be recorded_ |
| Date run | _to be recorded_ |
| RECIPE_HASH observed | _to be recorded — read it from the stamp file, see the caveat below_ |
---
## The expectation is deliberately INVERTED relative to the audit
This is the single most important thing to understand when comparing this document with the
audit, and the reason it is stated before the commands rather than after.
The audit's C-4 entry says:
> **Expected:** SSH host keys and the TLS key **present** (they are baked — see
> `build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id`
> absent or zero-length. Anything else changes F-03's severity.
That expectation described the **broken** state the audit found, and recording it was how the
audit measured the size of F-03. Phase 10 plan 10-03 Task 2 removed that material. So:
**After this change, the audit's stated expectation is the FAILURE condition.** If SSH host
keys or the TLS private key still appear in the tar, the strip layer did not run — most
likely because a cached `archipelago-rootfs.tar` was reused. That is not a regression in the
check; it is the check working.
The two negative findings the audit recorded are unchanged and must still hold:
`var/lib/systemd/random-seed` absent, `etc/machine-id` absent or zero-length.
---
## Commands to run
Run all of these **on the build host**, from the repo root, on a checkout that contains
commits `21043096` and `408b328c`.
### 1. Force a full rebuild
The strip layer lives inside the `RECIPE_HASH` region (between the `# STEP 1: Build complete
root filesystem` and `# STEP 2: Build minimal installer` markers), so the hash changes and the
cached tar is invalidated automatically. `--rebuild` is passed anyway so that a stale tar
cannot mask the result for any reason:
```bash
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild
```
`UNBUNDLED=1` is mandatory per `CLAUDE.md` and project memory — the default env silently
builds the wrong full-bundle variant.
### 2. List the identity artefacts in the shipped tar
`WORK_DIR` is `image-recipe/build/auto-installer`, so:
```bash
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \
| grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago'
```
### 3. Expected result after this plan
- **no** `etc/ssh/ssh_host_*` entries at all
- **no** `etc/archipelago/ssl/archipelago.key` and **no** `archipelago.crt`
(the `etc/archipelago/ssl/` **directory** must still be present — the first-boot staging
swap needs somewhere to land)
- **no** `var/lib/systemd/random-seed`
- `etc/machine-id` present with size **0**, or absent. Either satisfies "not shared"; record
which one was actually observed rather than generalising.
Note on the TLS keypair specifically: it is now absent for two independent reasons, not one.
The Dockerfile no longer generates it at all (that layer was removed so there is a single
producer), *and* the strip layer still deletes it as belt-and-braces in case a future layer
starts baking one. Seeing it present therefore means both defences were bypassed.
### 4. Confirm the provenance file rode along
```bash
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar | grep rootfs-identity-stripped
```
Expected: one entry, `opt/archipelago/rootfs-identity-stripped`. Its absence means the strip
layer did not execute and the whole check is void.
### 5. Confirm the regeneration path and its self-heal timer are still shipped
This is the brick check, and it is not optional. A stripped rootfs whose first-boot
generation script failed to ship would leave every flashed node with no SSH host key and
nothing to create one. The timer is part of the same check: without it, a node whose
generators fail every in-boot retry has no unattended way back.
```bash
ls -l image-recipe/build/auto-installer/installer-iso/archipelago/scripts/first-boot-secrets.sh \
image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service \
image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.timer
```
Expected: all three present, `first-boot-secrets.sh` executable.
### 5b. Confirm the build-time generator assertion actually ran
The rootfs build fails outright if `openssl` or `ssh-keygen` is missing or non-executable,
because that is the one way first-boot generation can fail deterministically — retries and
reboots would never fix it, so it must never reach a node. A successful build therefore
already proves the generators are present, and the build log says so:
```bash
grep 'first-boot secret generators present' <build log>
```
If you did not capture the log, assert it against the tar instead:
```bash
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \
| grep -E 'usr/bin/(openssl|ssh-keygen)$'
```
Expected: both present and mode `-rwxr-xr-x`.
### 6. Record the RECIPE_HASH the builder actually used
```bash
cat image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256
```
**Caveat — do not compute this hash from the repo file.** `image-recipe/build-debian-iso.sh`
copies the archived builder to a temp path and rewrites its relative paths before exec'ing it,
and `RECIPE_HASH` hashes `"$0"` — the rewritten copy. The hashed region contains 35 such
rewritten path expressions, and `SCRIPT_DIR` is substituted with an absolute path, so the hash
is specific to the build host and checkout location. For reference, hashing the region of the
committed repo file directly gives `d2dc4df5427fe73d48227aab08cdf6debfe8dd554e6b18e3718f8d37ea9d675c`,
which is **expected to differ** from the stamp above.
---
## Result
_Paste the raw output of steps 2, 4, 5 and 6 here, then set the status at the top of this
document to VERIFIED with the date and build-host label._
```text
(pending — not yet run on a build host)
```
**Verdict:** _pending_
---
## What this does and does not prove
**Proves (once run):** the rootfs tar extracted verbatim onto every disk flashed from the ISO
carries no SSH host key, no TLS private key and no populated machine-id — so a first-boot
regeneration failure degrades to "no key, the service refuses to start" rather than
"fleet-shared key, silently", which is the substance of F-03.
**Does not prove:** that two nodes flashed from the same ISO actually end up with different
keys. That is audit item **C-3** (§779) and needs two physical machines; it remains
separately UNVERIFIED. C-4 is a build-host check only.
### Guidance for C-3: SSH and TLS are now equally sharp signals
An earlier revision of this document said SSH host keys were the sharper divergence signal for
C-3, because the installer had a per-install TLS fallback that would produce a differing cert
even if first-boot generation had failed. **That asymmetry no longer exists.**
There is now exactly one producer of each secret — `gen_tls()` and `gen_ssh()` inside
`first-boot-secrets.sh` — and no other code in the ISO build creates either. The Dockerfile no
longer bakes a TLS keypair and the installer's "ensure SSL cert exists" block is gone. So for
C-3, treat both the same way:
```bash
# on each node
ssh-keyscan -t ed25519 localhost 2>/dev/null | ssh-keygen -lf -
openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256
```
**Pass:** both fingerprints differ between the two nodes. **Fail:** either matches — a matching
TLS fingerprint is now exactly as damning as a matching host key, whereas before it could have
been explained away by the fallback.
Also check, on each node, that the run actually succeeded rather than merely being quiet:
```bash
ls -l /var/lib/archipelago/.secrets-regenerated # present on a healthy node
cat /var/lib/archipelago/first-boot-secrets.failed 2>&1 # absent on a healthy node
systemctl status archipelago-first-boot-secrets.timer # enabled; the self-heal path
```
The audit's original C-3 fail condition — a `WARNING:` line in the log alongside an existing
marker — can no longer occur by construction: the marker is only written when both generators
succeeded. If you ever see that combination, the fix has been reverted.
+2 -2
View File
@@ -1,6 +1,6 @@
# KEY-05 — Entropy enforcement: per-site classification and mechanism record
**Requirement:** ROADMAP `KEY-05`. **Plan:** `.planning/phases/10-key-material-hardening/10-06-PLAN.md`.
**Requirement:** ROADMAP `KEY-05`.
**Supersedes:** backlog `R-13`. **Absorbs:** `R-05` (duplicate-`rand` visibility) and `R-09`
(CSPRNG-readiness record). **Resolves:** `F-10a` in
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`, which recorded raw match counts and
@@ -44,7 +44,7 @@ they are enabled wrongly. Both are gated behind Task 5, a `gate="blocking-human"
## Source precedence
`.planning/phases/10-key-material-hardening/10-CONTEXT.md` (2026-08-01) lists **F-07 / R-05**
The Phase 10 hardening work lists **F-07 / R-05**
and **F-10 / R-13** under `## Deferred Ideas`. KEY-05 was added to the ROADMAP on
**2026-08-02**, after that context was gathered, and explicitly absorbs R-05 and supersedes
R-13. The ROADMAP requirement is the later and governing artifact.
@@ -1,267 +0,0 @@
# Phase 10 — Independent Verification Guide
**Audience:** third-party security auditors, and the Archipelago team.
**Purpose:** verify the Phase 10 security claims *independently*, without trusting the
project's own test harness.
**Status:** LIVING — sections are marked ✅ verifiable now, ⏳ pending a plan still in
execution, or 🔒 hardware-gated. Do not read an unmarked absence as a passing result.
---
## 0. How to use this document
Every claim below follows the same four-part structure, and **all four parts matter**:
| Part | Why it exists |
|---|---|
| **Claim** | Stated so it can be falsified. A claim you cannot disprove is not a security claim. |
| **Reproduce the defect** | Check out the parent commit and demonstrate the bug. *A test that passes on both the fixed and unfixed code proves nothing.* |
| **Verify the fix** | Command + expected output, runnable without our harness wherever possible. |
| **Negative control** | Break the fix deliberately; confirm the check goes red on **exactly** that and nothing else. This is what separates verification from demonstration. |
**Do not skip "Reproduce the defect".** It is the only step that proves the fix addresses
something real, and it is the step most often omitted in security theatre.
### Trust posture
Where a claim can be checked from *outside* the codebase — an HTTP request from another host,
a `tar` listing, a file comparison across two machines — **prefer that over running our tests.**
Our tests are offered as convenience and as evidence of intent, not as proof. Every claim below
that can be externally checked says so explicitly.
---
## 1. Scope
### In scope — what Phase 10 claims
| ID | Claim | Severity | Status |
|---|---|---|---|
| KEY-01 | An already-provisioned node refuses every unauthenticated RPC that can mutate identity or credentials | **Critical** | ⏳ `10-01` in execution |
| KEY-02 | First-boot per-device secret generation is fail-closed, retried, self-healing, and has exactly one producer; the shipped rootfs contains no fleet-shared identity material | **High** | ✅ partially landed (`21043096`, `408b328c`), ⏳ single-producer + self-heal in progress |
| KEY-03 | The BIP-84 account private key is never imported into Bitcoin Core; the dead import path is deleted | **High** | ⏳ `10-05` in execution |
| KEY-04 | On-node evidence for C-3 / C-4 / C-6 | — | 🔒 hardware-gated |
| KEY-05 | A defaulted RNG cannot be inherited anywhere in the crate | Medium | ⏳ `10-06` not started |
### Explicitly NOT claimed
State these plainly so an auditor is not left inferring them:
- **Lightning custody is not air-gappable.** Channel, revocation and HTLC keys must sign in real
time to answer counterparty commitments. LND remote signing *relocates* those keys; it does not
make them cold. Any document implying otherwise is wrong.
- **No claim against a compromised kernel CSPRNG**, a malicious dependency in the supply chain,
memory disclosure on a running node, or physical access.
- **KEY-05 fixes a structural risk, not a live vulnerability.** `rand::random()`/`thread_rng()`
are ChaCha12 seeded from `getrandom(2)`; nothing in that finding is exploitable today. The
mitigation targets *future silent rebinding* of the entropy source.
- **Findings F-04 through F-12 are out of scope** for this phase and remain open. See
`ENTROPY-SEED-AUDIT-2026-07-31.md` remediation register (R-05..R-15) and
the issue tracker.
---
## 2. Provenance
```bash
# The audit that motivated this phase
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md # 103 file:line references
# The entropy fix that preceded the phase
git show 8b51b7e2 # seed.rs — explicit OsRng at the call site
# Phase 10 plans and locked decisions
.planning/phases/10-key-material-hardening/
```
`.planning/` is committed deliberately: an auditor can read *why* each decision was made,
including the ones that were reversed. `10-CONTEXT.md` records D-01..D-11 plus three
in-flight corrections (D-03a, D-07a/b/c) where our own earlier reasoning was wrong.
---
## 3. Tier 0 — verifiable on any checkout, no node required ✅
No hardware, no deploy. Start here.
### 3.1 First-boot secrets are fail-closed (KEY-02)
**Claim.** If per-device secret generation fails, the completion marker is **not** written and
the boot does not proceed as if it had succeeded.
**Reproduce the defect:**
```bash
git log --oneline -1 21043096 # the fix commit
git show 21043096^:image-recipe/_archived/build-auto-installer-iso.sh > /tmp/pre-fix.sh
grep -n 'touch .*MARKER' /tmp/pre-fix.sh
# Observe: the marker write is NOT inside the success branch — it runs regardless of outcome.
```
**Verify the fix:**
```bash
bash tests/first-boot-secrets/run-tests.sh
# Expect: passed: 3 failed: 0 (more cases once the self-heal work lands)
```
The harness extracts the heredoc body **from the builder itself**, so it exercises the bytes
that ship rather than a copy. Confirm that for yourself:
```bash
grep -n 'extracted .* lines from the builder' tests/first-boot-secrets/run-tests.sh
```
**Negative control:**
```bash
# Move `touch "$MARKER"` outside the success branch in the builder, then:
bash tests/first-boot-secrets/run-tests.sh
# Expect: FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE
# passed: 2 failed: 1 EXIT=1
git checkout image-recipe/_archived/build-auto-installer-iso.sh
```
It must fail on **that case only**. A negative control that reddens everything is measuring
nothing.
### 3.2 Master-seed entropy is explicit (F-02, shipped)
**Claim.** Mnemonic generation draws from an explicitly-passed `OsRng`, not a
transitive-dependency default, and a test proves the injected RNG is the one consumed.
```bash
git show 8b51b7e2 -- core/archipelago/src/seed.rs # ~6 lines of production change
cd core && cargo test -p archipelago seed:: # expect 25 passed; 0 failed
```
**Reproduce the defect:** on `8b51b7e2^`, `MasterSeed::generate` calls
`bip39::Mnemonic::generate(24)`, which resolves to `&mut rand::thread_rng()` *inside* the bip39
crate — there is no seam to inject through, so the proving test cannot be written at all.
**Note for auditors:** the test module implements `rand::CryptoRng` for a counter RNG. That is a
deliberately false marker-trait promise, confined to `#[cfg(test)]` (`seed.rs:502`). KEY-05
retires it. Confirm containment:
```bash
grep -n 'CountingRng' core/archipelago/src/seed.rs # all hits must be after the cfg(test) at :502
```
### 3.3 Unauthenticated method inventory (KEY-01 context)
Read the authoritative list rather than trusting prose:
```bash
sed -n '/UNAUTHENTICATED_METHODS/,/];/p' core/archipelago/src/api/rpc/middleware.rs
```
Every entry is reachable without a session, RBAC check, or CSRF token. KEY-01's claim is that
those which can mutate identity or credentials refuse once the node is provisioned.
---
## 4. Tier 1 — requires a running node ⏳
Pending `10-01` and `10-02`. `10-02` produces `scripts/security/rpc-exposure-probe.sh` and
`docs/security/KEY-01-ON-NODE-VERIFICATION.md`.
**The external check that matters most (C-6).** From a *different host* on the same network,
against a node that has completed onboarding:
```bash
curl -sS -X POST http://<node>/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"seed.restore","params":{"words":["<24 words>"]}}'
```
- **Before the fix:** the node accepts attacker-supplied words and overwrites `node_key`,
`nostr_secret` and the FIPS mesh key. This is the Critical finding.
- **After the fix:** refused, and the node's identity is byte-identical afterwards.
Verify byte-identity yourself rather than trusting a log line:
```bash
sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
# run before and after the request; the hashes must be unchanged
```
> ⚠️ **Do not run the "before" case against a node you care about.** It really does overwrite the
> identity. Use a disposable node — see `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`
> for standing up an isolated instance without flashing an ISO.
**Do not probe with `seed.status`.** The original audit's C-6 command used it; `seed.status` is
**not** in `UNAUTHENTICATED_METHODS`, so it returns 401 by design and would report the surface
closed while the real door stands open. Probe with a method that is genuinely on the
unauthenticated list.
**Non-regression, equally important:** a *fresh, un-onboarded* node must still complete
onboarding. The gate distinguishes provisioned from fresh; a fix that refuses on a fresh node
bricks first boot fleet-wide.
---
## 5. Tier 2 — ISO build host 🔒
Full procedure: `docs/security/KEY-02-ROOTFS-EVIDENCE.md` (C-4).
```bash
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild
# then follow steps 2/4/5/6 in KEY-02-ROOTFS-EVIDENCE.md
```
**Claim.** The shipped rootfs tar contains no SSH host keys, no TLS private key, and no
machine-id — so no two nodes flashed from one image can share them.
**Gotcha, recorded because it will waste your afternoon:** read `RECIPE_HASH` from
`image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256`, **not** by hashing the
repo file. The wrapper rewrites 35 path expressions and absolutises `SCRIPT_DIR` before exec,
so the hash is host- and checkout-specific.
**Note the inverted expectation.** The original audit expected these artefacts to be *present*.
This check passes when they are *absent*.
---
## 6. Tier 3 — two physical nodes 🔒
**C-3 — host-key uniqueness.** Flash two machines from the *same* ISO, then compare:
```bash
# on each node
sha256sum /etc/ssh/ssh_host_*_key.pub
sha256sum /etc/ssl/private/<tls-key> # path per the nginx config
cat /etc/machine-id
```
Every value must differ between the two nodes. Any match is a finding.
SSH host keys and the TLS key are equally sharp signals once the single-producer work lands
(before it, TLS had an installer fallback and SSH did not — see `KEY-02-ROOTFS-EVIDENCE.md`).
---
## 7. Tier 4 — pre-release gate
```bash
# ON the node, not over RPC — it uses local podman/systemctl/bitcoin probes
ARCHY_ITERATIONS=5 bash tests/lifecycle/run-gate.sh
```
Install / UI / stop / start / restart / reinstall / reboot-survive /
archipelago-restart-survive / uninstall, 5× green. See `tests/lifecycle/TESTING.md`.
Frontend: `cd neode-ui && npm run test` (vitest) and `npm run build`.
Rust: `cd core && cargo test -p archipelago`.
---
## 8. Known-accepted risks
Recorded so an auditor does not have to discover them by reading commit messages.
| Risk | Decision | Where |
|---|---|---|
| A node whose first-boot secret generation can never succeed will not serve TLS | Accepted. Mitigated by a build-time assertion on generator binaries, retry-with-backoff, and self-heal on subsequent boots — leaving genuinely-broken hardware as the residual | `10-03` |
| Rotating host keys on already-deployed nodes invalidates `known_hosts` fleet-wide | Accepted, rated one-way, gated behind a decision checkpoint | D-06, `10-04` |
| KEY-01's fix ships on the next scheduled OTA, not an emergency release | Deliberate. The Critical finding stays live on the fleet until that OTA | D-10 |
| `#[cfg(test)]` code implements `rand::CryptoRng` falsely | Accepted until KEY-05 retires it; contained to test builds | `seed.rs:656` |
---
## 9. Reporting a finding
If any check above fails, or you find something not covered: the audit format that produced this
work is `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — evidence as `file:line`, an explicit
severity, and a stated confidence. Findings that cannot be verified without hardware belong in an
UNVERIFIED section rather than being asserted.
Two corrections in that document are worth reading as calibration, because both were ours: F-10
**understated** its scope by a factor of 20, and the correction to it then **overstated** the
severity of two files within a day. Both are struck in place rather than rewritten.
+1 -3
View File
@@ -39,8 +39,7 @@
**Provenance rules used throughout.** Every architectural claim is grounded in either (a) a
`file:line` from this tree, or (b) RESEARCH.md Part C
(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
(which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from
neither is marked `[UNVERIFIED]`.
@@ -615,6 +614,5 @@ current security posture and should not wait for the rest.
- `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above
answers two of its open items.
- `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses.
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
format comparison.