Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 25d938cd1a
2315 changed files with 510085 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
# 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.
**Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC
through a proxy that injects the node's own credentials.
## Why this document exists
`a05956c4` closed two unauthenticated endpoints on the wallet UI ports. Its commit message
stated:
> The nginx template is `include_str!`'d and re-rendered on every reconcile pass, so this
> ships atomically with the binary.
That is true for most nodes and false for a specific, silent, and not-rare state. The half
that landed correctly (LND) made the half that did not (Bitcoin RPC) *harder* to notice,
because a spot check of the LND endpoint returns a clean `401` and reads as "patched".
## What was observed
Node running the fixed binary (installed 17:21, contains the new template — `auth_request`
present in the binary at 4 occurrences). All probes from the node's own LAN address, no
cookies, no credentials:
| Probe | Result |
|---|---|
| `GET http://192.168.63.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}`**closed** |
| `POST http://192.168.63.240:8334/bitcoin-rpc/` (`getblockcount`) | `200``{"result":960774,"error":null}`**OPEN** |
| `OPTIONS http://192.168.63.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *`**OPEN** |
The rendered config on disk, `/var/lib/archipelago/bitcoin-ui/nginx.conf`, was dated
**2026-06-30** — the pre-fix version, with no `auth_request` and with the wildcard CORS
header the fix removes.
## Root cause
Three facts have to be true at once, and on this node they were:
1. `bitcoin-ui` is listed in the node's durable `user-uninstalled` marker
(`/var/lib/archipelago/user-uninstalled.json`).
2. `reconcile_app` returns on that marker (`prod_orchestrator.rs:1956`) **before** reaching
`run_pre_start_hooks`, which is the only thing that renders the nginx config.
3. The container keeps running anyway, because it is owned by **systemd via a Quadlet
unit** — `archy-bitcoin-ui.service`, `active`, restarted 17:25 after the daemon restart —
not by the reconciler that is refusing to touch it.
So: *a container systemd keeps alive, that the orchestrator has stopped reconciling, never
receives a config fix shipped inside the binary.* The marker means "must stay removed", but
nothing enforces removal against systemd, and the orchestrator treats the marker as
permission to stop looking.
This is not a one-app accident. On the same node `archy-electrs-ui` is in the identical
state (uninstalled marker + active Quadlet unit + `Up 10 days`). It serves only a static
page with no credential-injecting proxy, so its exposure is low — but it would miss any
future config fix the same way.
## Why it matters beyond this node
An OTA carrying `a05956c4` would have closed the LND leak everywhere and silently failed to
close the Bitcoin RPC proxy on every node in this state — while making those nodes *look*
patched to exactly the check an operator would run first. That is the most misleading
possible outcome of shipping a security fix.
## The fix
`f6b5245b`: a container that is actually running is a live attack surface whatever a marker
says about it, so its security-relevant config is reconciled even behind the marker, and the
container is restarted so nginx loads it.
Deliberately narrow:
- Nothing is created, pulled, built, started or resurrected. The "must stay removed"
contract can only weaken for a container that is **already running**, which by definition
means it was never removed.
- A hook error is swallowed, not propagated — an app the user uninstalled must not be able
to fail the reconcile pass for every app after it.
- The pre-existing marker test passes unchanged; that is what proves the removal contract
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
Sequence, from file mtimes, container start times and the daemon journal:
| Time (EDT) | Event |
|---|---|
| 18:33 | Probe: `POST /bitcoin-rpc/``200` with a real block height. Exposure confirmed live. |
| 18:36 | A **separate rebuild of bitcoin-ui**, done outside this work, rendered the fixed conf and recreated `archy-bitcoin-ui`. `:8334` closes here. |
| 19:06 | The binary carrying `f6b5245b` is installed and the daemon restarted. |
| 19:12 | Probe: `POST /bitcoin-rpc/``401`. `OPTIONS` now returns `Access-Control-Allow-Origin: http://192.168.63.240:8334`, not `*`. |
So the node is closed, and the fixed template is proven to work end to end on real
hardware — but **the reconcile fix itself was never exercised.** By the time it was
deployed, the state it repairs had already been cleared by the unrelated rebuild. The
`401` proves `a05956c4`'s template; it does not prove the delivery path `f6b5245b` adds.
That distinction is the whole point of this document, so it is recorded rather than
rounded off: `bitcoin-ui` is *still* in the node's `user-uninstalled` marker, meaning the
next time its config needs to change, this node depends on `f6b5245b` — untested — or on
someone happening to rebuild the app again.
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
prove the thing the incidental rebuild had made unprovable: that **reconcile itself**
repairs this state, unaided.
The daemon was stopped first, so the reconciler could not repair the state before the
re-exposure had been confirmed — otherwise a passing probe would prove nothing about
which mechanism produced it.
| Step | Action | Observed |
|---|---|---|
| 1 | Install a faithfully stale conf (no `auth_request`, credential-injecting `proxy_pass`, `Allow-Origin: *`) and restart the container | — |
| 2 | Probe with no cookies | `POST /bitcoin-rpc/`**`200`**, `{"result":960790}`; `Allow-Origin: *`. **Genuinely re-exposed** |
| 3 | Start the daemon (20:00:36) and touch nothing further | — |
| 4 | Reconcile pass at **20:02:19** | `bitcoin_ui: nginx.conf rendered auth_hash=51f2b5af`, then `WARN prod_orchestrator: rewrote config for a user-uninstalled app whose container is still RUNNING (systemd/Quadlet keeps it alive independently of reconcile) — restarting so it picks the new config up app_id=bitcoin-ui container=archy-bitcoin-ui` |
| 5 | Probe again | `POST /bitcoin-rpc/`**`401`**; `Allow-Origin: http://192.168.63.240:8334` |
| 6 | Compare state | Conf **byte-identical** to the pre-test known-good; container healthy |
Step 2 is what makes steps 46 mean anything: without a confirmed `200`, the later `401`
would be consistent with the state never having been broken at all.
Both halves are now proven on hardware: `a05956c4`'s template (the gate works) and
`f6b5245b`'s delivery path (the gate arrives at a container the reconciler had been
skipping).
## Credential rotation — decided against, 2026-08-02
The operator's call, recorded here so it is not silently re-litigated: **no LND macaroon
rotation, and no Bitcoin RPC password rotation.** The reasoning was that there is no
evidence of exploitation and the vulnerability is being closed rather than lived with.
`scripts/security/rotate-lnd-macaroon.sh` stays in the tree as a tool. It has been
exercised in detect mode only, and has never rotated anything on any node. Its ordering
guard (refuses to rotate on a binary lacking the fix) remains the right shape for whenever
rotation is wanted — including for the Bitcoin RPC password, which has no equivalent tool
yet.
What this decision accepts: any macaroon or RPC password read through either hole before
it was closed stays valid. That is a deliberate, informed trade, not an oversight.
## Operator note
Deploying the fix rewrites the config and restarts `archy-bitcoin-ui` (a brief Bitcoin UI
interruption, nothing else). Any node that ever had `bitcoin-ui` uninstalled while its
Quadlet unit stayed active should be re-probed with the `POST /bitcoin-rpc/` check above —
a `401` is the pass condition. Treat the Bitcoin RPC password on any node that answered
`200` as known to anyone who could reach that port, and rotate it **after** the fix is
deployed, never before.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
# 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.
+244
View File
@@ -0,0 +1,244 @@
# KEY-02 — fleet host-secret detection and rotation (F-03, deployed half)
Phase 10 plan 10-04. Companion to `docs/security/KEY-02-ROOTFS-EVIDENCE.md`, which covers the
build half (10-03).
10-03 stopped the exposure growing: the ISO no longer bakes SSH host keys or a TLS keypair into
the shared rootfs, and first-boot regeneration now fails closed instead of setting its completion
marker on a failed run. That does **nothing** for nodes already in the field, which is exactly
where the exposure sits — a node that hit the old fail-open path is running the SSH host key and
TLS private key that every downloader of that ISO also holds, and it will never try again.
This document records the two human decisions that govern the deployed half.
---
## D-06 rotation trigger
**Chosen option: `detect-report-then-apply`** — recorded 2026-08-02.
Verbatim option id as written in `10-04-PLAN.md`: **`detect-report-then-apply`**
("Detect and report on boot; rotate only when an operator runs the script with an explicit apply
flag").
### Why
Rotating an SSH host key is one-way. Every `known_hosts` entry for that node breaks, on every
machine that has ever connected to it, and the old private key is destroyed by the swap. The
fleet is reached over Tailscale for day-to-day work and several nodes are remote — `.228` is at
a remote site and is in real use (CLAUDE.md). `auto-on-boot` would fire that rotation on many
nodes simultaneously during an OTA rollout, with no advance notice and no operator holding the
new fingerprints. A node whose only access path is SSH and whose tooling pins the host key
becomes unreachable until someone clears the entry; a rotation that fails partway on a remote
node needs physical console access to recover, which for `.228` means a site visit.
Against that, the cost of `detect-report-then-apply` is that exposure persists on any node whose
operator does not act. That cost is bounded by making the verdict **visible**: detection runs at
boot on every node and the verdict reaches `system.stats`, so an exposed node shows up in the
dashboard without shell access. The exposure becomes measured rather than assumed, and the list
of nodes still to rotate is a fact on a screen rather than a guess.
This also matches the project's standing policy that changes are verified on the dev pair
(archi-dev-box + x250-dev) before they reach the fleet (CLAUDE.md, `feedback_dev_pair_before_ota`).
A rotation that fires unattended on first boot after an OTA cannot be dev-paired — by the time it
has been observed on the dev pair it has already run everywhere.
### What this decision binds
- `scripts/security/host-secrets-audit.sh` defaults to `--detect`, which is read-only.
- `--apply` **without** `--yes` prints its plan and exits 0 having touched nothing, so a mistyped
invocation is inert.
- `image-recipe/configs/archipelago-host-secrets-audit.service` ships in **detect-only** mode.
It contains no apply path. Making the boot unit rotate would require editing the unit, which is
a deliberate act, not a default.
- `--apply --yes` refuses to do anything unless the detect pass returned `shared`. A node whose
verdict is `per-node` cannot have its keys rotated by this script even by explicit command —
the guard against "operator runs it on the wrong node" is structural, not procedural.
### Consequence recorded honestly
Any node whose verdict comes back `shared` and which is never revisited stays exposed
indefinitely. The mitigation is the visibility, not the automation. The list under
"Nodes with a `shared` verdict, deliberately not rotated" below exists so that no such node is
quietly forgotten, and it is part of this plan's acceptance criteria that the list is kept.
---
## How a node decides
Four on-disk signals, evaluated in this precedence order by
`scripts/security/host-secrets-audit.sh --detect`. Every verdict carries the evidence strings
that produced it, and each evidence string names the file it was read from.
| # | Signal | Source |
|---|---|---|
| 1 | mtime of each host key / the TLS key against the first-boot anchor | `/var/lib/archipelago/.secrets-regenerated`, falling back to `/root/.luks-archipelago.key` then `/etc/machine-id` |
| 2 | The fail-open fingerprint: marker present **and** a `WARNING:` line in the first-boot log | `/var/log/archipelago-first-boot-secrets.log` |
| 3 | 10-03's durable failure record | `/var/lib/archipelago/first-boot-secrets.failed` |
| 4 | Rootfs provenance | `/opt/archipelago/rootfs-identity-stripped` |
Verdicts: `per-node`, `shared`, `fail-closed-missing`, `unknown`.
**`per-node` is never reported on the strength of an absent signal.** With no anchor at all the
verdict is `unknown`, and while a durable failure record stands the verdict is `unknown` rather
than `per-node` — the node's own generator most recently reported failure, so a clean-looking
mtime is not evidence of success.
Signal 4 changes the meaning of missing material rather than adding to the shared/per-node
question: on a node flashed from a 10-03-or-later ISO the rootfs shipped identity-free, so an
absent host key is a **fail-closed** state (generation never succeeded), not a shared one.
---
## C-3 — per-node host key and TLS uniqueness
Audit checklist item C-3 (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §855), described
there as "the highest-value check here".
### Status: **FAILED — with finding.** Recorded 2026-08-02.
> **This section names live fleet nodes that are still running shared key material.
> Review it before this repository is made public** (`docs/OPEN-SOURCE-READINESS-PLAN.md`).
> Digests below are truncated; the fingerprints of public keys are public data — every SSH
> handshake offers them — but there is no reason to make a target list convenient.
**Three distinct live fleet nodes share all three of their SSH host keys. Two of those three
also share their TLS certificate, and therefore their TLS private key.** This is not a
theoretical exposure: it is F-03 in production, today.
#### Method
Gathered **remotely and read-only** — no node was logged into, nothing was written to any node,
nothing was rotated. Host keys came from `ssh-keyscan`, which is what every SSH client does
before it decides whether to trust a host, and certificates from an anonymous TLS handshake:
```bash
ssh-keyscan -T 6 <node> | ssh-keygen -lf -
openssl s_client -connect <node>:443 </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256 -subject
```
This is a deliberately weaker instrument than the checklist's on-node commands, and it was chosen
because it needs no access and can therefore cover the whole reachable fleet rather than two
nodes. What it can prove is exactly the FAIL condition: *any fingerprint appearing on two nodes*.
#### Result
| Node label | SSH host keys (ECDSA/ED25519/RSA, truncated) | TLS cert sha256 (truncated) | Cert CN |
|---|---|---|---|
| `archipelago-1` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` |
| `archy-x250-beta` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` |
| `archipelago` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `7C:6B:CD:98…` | `austin-sapien` |
| `archipelago-5` | `/bmgd6jS…` / `SpaNfLLf…` / `hhVFABi3…` | `95:FE:EB:C7…` | `archipelago.local` |
| `archi-dev-box` | `8hFU7QGM…` / `GAxNAcgX…` / `Tv7AfaVp…` | (no :443 listener) | — |
| `archy-dev-pa` | `JtD/RM0a…` / `XD2A5OVL…` / `esIBpbWk…` | not probed | — |
| `framework-pt` | `oicpsj3Y…` / `zxA1/kRU…` / `oxi+tMli…` | `88:85:CE:CC…` | `framework-pt` |
| `shorty-s` (`.228`) | `YVsgrv8M…` / `D/5n851i…` / `YMFLUerk…` | `4D:98:D4:9B…` | `shorty-s` |
Unreachable at scan time, so **UNVERIFIED**: `archy-x250-dev`, `archy-x250-pa`, `archy-x250-r2`,
`quantumterminal`.
#### That the three are genuinely different machines, not one host seen three times
The obvious alternative explanation for identical host keys is a single machine registered on the
tailnet more than once. Ruled out:
- All three answered a live TCP connection on port 22 within the same minute. One `tailscaled`
instance serves one tailnet identity, so three simultaneously-live addresses are three hosts.
- `tailscale ping` resolves them to **different physical endpoints**: `archy-x250-beta` answers
from `178.38.147.13` (and over the Frankfurt DERP), while `archipelago-1` and `archipelago`
answer from `45.20.199.86` on different source ports — a different continent for the first,
and two distinct machines behind one NAT for the other two.
- They are owned by different tailnet accounts.
#### Why `archipelago` has a different TLS cert but the same SSH keys
Its cert CN is `austin-sapien`, not the image default `archipelago`. That is the signature of a
node that was **renamed** through `server.set-name`, which re-mints the TLS cert via
`regenerate_tls_cert()` so the SAN matches the new hostname — and touches nothing else.
This is worth stating plainly because it is a trap: **TLS uniqueness alone is not evidence that
a node's key material is per-node.** Any renamed node gets a unique certificate for free while
its SSH host keys stay exactly as the image shipped them. Had C-3 been checked on TLS
fingerprints only, `archipelago` would have looked clean. The SSH host key is the reliable
signal, and this is why the audit script treats the two classes separately and reports which one
is shared rather than issuing a single node-level verdict.
#### What this does NOT establish — UNVERIFIED
| Claim | Status | Evidence still needed |
|---|---|---|
| The three nodes were flashed from the **same ISO** | UNVERIFIED | Not required for the FAIL — shared host keys are the exposure however they got there — but the ISO build id would tell us how many other downloads carry the same keys. Needs on-node `/opt/archipelago/` provenance. |
| The audit script's verdict on those three nodes | UNVERIFIED | `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect` on each. Requires the OTA carrying this plan's runtime payload to land, or the script to be hand-staged. Predicted `shared`; predicted is not observed. |
| A rotation preserves the operator's own session | UNVERIFIED **on hardware** | Checkpoint steps 46: run `--apply --yes` on one disposable node from a session you are willing to lose, confirm that session survives, confirm a second connection shows the expected mismatch. The harness proves the script's ordering and its abort path; it cannot prove that `systemctl reload ssh` keeps a real forked session alive. |
| `host_secrets` reaches `system.stats` on a real node | UNVERIFIED | Needs a build carrying this plan deployed to the dev pair, then a `system.stats` call. Proven in unit tests against the file contract only. |
| The four unreachable nodes | UNVERIFIED | Re-run the scan when they come back online. |
#### Consequence
`archipelago-1`, `archy-x250-beta` and `archipelago` are a **confirmed live F-03 instance**.
Anyone holding a copy of the ISO these nodes were flashed from holds their SSH host private keys,
and for the first two, their TLS private key as well — enough for undetectable SSH host
impersonation and transparent MITM of the web UI.
None of them was rotated as part of this verification, and that is deliberate: this checkpoint
verifies, it does not remediate, and remediating a node inside a verification task is how a
verification task takes a node offline. They are recorded below.
---
## Nodes with a `shared` verdict, deliberately not rotated
Any node that reports `shared` and is not rotated in the same session MUST be added here with the
date and the reason, so that the standing consequence of `detect-report-then-apply` is a visible
list rather than an assumption.
| Node label | Date detected | Why not rotated | Next step |
|---|---|---|---|
| `archipelago-1` | 2026-08-02 | Detected by remote fingerprint comparison during C-3, not by an operator running the script. In real use; rotating it inside a verification task is exactly what the task forbids. | Stage the script, run `--detect`, then rotate from a session the operator is willing to lose. |
| `archy-x250-beta` | 2026-08-02 | Same. Also shares its **TLS private key** with `archipelago-1`, so it is the more urgent of the two. Reached over a DERP relay from another continent — the least recoverable node in the set if a rotation goes wrong. | Rotate from physical or console access if available; otherwise rotate TLS first, confirm, then SSH. |
| `archipelago` | 2026-08-02 | Same. TLS is already unique (the node was renamed, which re-mints the cert); only its SSH host keys are shared. | `--apply --yes` will rotate SSH only — the detect pass flags the classes separately, so this node's already-unique TLS pair is left alone. |
**Nobody has been told their `known_hosts` is about to break.** Three nodes here are in real use;
the rotation is one-way and every existing entry for them dies with it. Sequencing that is an
operator decision, which is the whole content of D-06.
---
## Operator runbook — rotating one node
Run this from a session you are willing to lose, on **one node at a time**. Never on `.228` or
any node in real use without arranging access recovery first.
```bash
# 1. Detect. Read-only; safe on any node, including production.
sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect
cat /var/lib/archipelago/host-secrets-audit.json
# 2. Dry run. Prints the plan, touches nothing, exits 0.
sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply
# 3. Rotate. Only proceeds if the verdict is `shared`.
sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes
# 4. WITHOUT closing that session, prove it survived:
echo still-here
# 5. From a second terminal, expect a host-key mismatch warning. That is the
# correct outcome. Update known_hosts against the fingerprints printed by
# step 3 (also in /var/lib/archipelago/host-key-rotation.json), never by
# blindly accepting whatever is offered.
ssh-keygen -R <node>
ssh <node>
# 6. The web UI will present a new self-signed cert. A fresh browser trust
# prompt is expected and is the correct outcome.
```
The script reloads sshd rather than restarting it. A reload re-execs the listener while
already-forked session children keep running, which is why the operator's own SSH session
survives its own rotation. `restart` would kill it, and on a remote node with no console that is
unrecoverable.
Old fingerprints are written to `/var/lib/archipelago/host-key-rotation.json` **before** the
swap, so an operator who loses access anyway can still identify what changed.
+208
View File
@@ -0,0 +1,208 @@
# 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.
+448
View File
@@ -0,0 +1,448 @@
# KEY-03 — Signing posture after the Bitcoin Core wallet deletion
> **What this document is.** The evidence-backed record of how Archipelago's Bitcoin signing
> posture stands after Phase 10 KEY-03. It supersedes, for the Bitcoin Core wallet specifically,
> the target state described in `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 1 — that
> phase planned to *convert* Core's wallet to watch-only; **D-07b deleted the path instead.**
>
> **Governing decisions:** `.planning/phases/10-key-material-hardening/10-CONTEXT.md`
> **D-07b** (final KEY-03 scope — delete, do not migrate) and **D-07c** (the deferred BDK cold
> vault, recorded so it is not lost with the code). D-07b supersedes D-07 and D-07a's conditional
> migration.
>
> **Audit finding closed:** F-13 (High) —
> `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:604`, remediation register R-04.
---
## Bitcoin Core wallet path — deleted (D-07b)
### What was deleted
| Symbol | Kind | Location before deletion |
|---|---|---|
| `handle_bitcoin_init_wallet_from_seed` | `async fn` | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` |
| `"bitcoin.init-wallet-from-seed"` | JSON-RPC dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` |
### The defect (F-13)
The handler loaded the encrypted seed, derived the **BIP-84 account extended private key**
(`crate::seed::derive_bitcoin_xprv`, `bitcoin.rs:188`), stringified it (`:189`), and imported
`wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` (`:230-231`) into a Bitcoin Core descriptor wallet created
with `disable_private_keys = false` (`:203`) and an **empty** wallet passphrase (`:205`).
The result was a **second copy of the node's spending key**, persisted in Core's `wallet.dat`
inside the Bitcoin container's data volume, with no Argon2 passphrase — while the first copy sits
in the daemon's Argon2 + ChaCha20-Poly1305 envelope written `0600`
(`core/archipelago/src/seed.rs:238-269`, `:318-324`). That duplication, into weaker protection,
was the entire finding.
### Evidence that deletion was the right close (re-established for this task, not inherited)
The four D-07a evidence points, verified again against the tree before anything was removed:
**1. No caller anywhere.** Repo-wide search across `core/`, `neode-ui/src`, `scripts/`, `web/`,
`apps/`, `tests/` and `docs/`, excluding `core/target`, `node_modules` and `.git`:
```
$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => {
$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed(
core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await
docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`):
docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
```
Exactly one occurrence of the method name (its own dispatcher registration) and two of the symbol
in code (its definition and the dispatcher call). The three remaining symbol hits are prose in
documentation — the audit, the task tracker, and the PSBT architecture spec — not callers. No
frontend, script, test or other Rust module invoked it.
**2. LND is the wallet the product actually drives.** Across all of `neode-ui/src`, every
`bitcoin.*` RPC call is read-only status: `bitcoin.getinfo` (14 call sites),
`bitcoin.prune-status` (3), `bitcoin.onion` (1). There are **no** `bitcoin.*` wallet operations.
The wallet UI (`Web5Wallet.vue`, `SendBitcoinModal.vue`) sends via `lnd.sendcoins`, estimates via
`lnd.estimatefee`, and reads balance via `lnd.getinfo`.
**3. The wallet it creates never existed on the reference node.** Verified live on
**archi-dev-box, 2026-08-02**, against the running `bitcoin-knots` container (read-only RPCs
only — see the census section for the exact commands and the standing ban on
`listdescriptors true`):
```
listwalletdir → { "wallets": [ "gatewayd-02004b91…", "gatewayd-03443c0c…", "" ] }
listwallets → [ "" ]
```
**There is no wallet named `archipelago`** — the handler's default `wallet_name`
(`bitcoin.rs:170-173`). It has never run on this node. `getwalletinfo` on the one loaded wallet
(the unnamed default) reports:
```
walletname: "" blank: true keypoolsize: 0
txcount: 0 balance: 0.00000000
descriptors: true private_keys_enabled: true
```
`blank: true` with `keypoolsize: 0` and `txcount: 0` is Bitcoin Core's own statement that **no
key was ever imported into it and no transaction ever touched it**. The two `gatewayd-*` entries
are Fedimint gateway wallets, unrelated to the BIP-84 path. The `wallet.dat` at the datadir root
is Core's own legacy default-wallet location, not this handler's output.
**This is one node.** The same check was subsequently run across the reachable fleet — see the
census below: **4 nodes examined and clear, 6 unreachable and therefore unknown.**
**Supporting history evidence:** `git log -S "init-wallet-from-seed"` scoped to
`core/archipelago/src/api/rpc/dispatcher.rs` and `neode-ui/src` returns exactly one commit —
`19dcfd4f feat: BIP-39 master seed for unified key derivation`, the commit that **added** it. No
frontend wrapper was ever written: it was built and never wired up.
**4. It was never remotely reachable.** The endpoint is absent from `UNAUTHENTICATED_METHODS`
(`core/archipelago/src/api/rpc/middleware.rs:5-40`) — so it required an authenticated session —
**and** it additionally re-verified the user's password before touching the seed
(`self.auth_manager.verify_password(password)`, `bitcoin.rs:176-179`). **F-13 was therefore
key-at-rest duplication, not an exposed endpoint.** That is why it was rated High rather than
Critical, and why deleting it is a hardening measure rather than an incident response.
### What was *not* wrong with it
Worth stating so the record is fair, and so the next reader does not mistake the lesson. The
in-memory handling of the xprv string was **careful**: it was zeroized on the error path
(`bitcoin.rs:222`) and on the success path (`:284`), matching the standard set elsewhere in
`seed.rs`. The wallet type was also correct — `createwallet` already passed `descriptors = true`
(`:207`), which is the right foundation.
**The defect was which key went into the wallet, not how the key was held in memory or what kind
of wallet it was.** A watch-only rewrite (xpub + `[fingerprint/derivation]` key origin) would
have been a legitimate fix. Deletion was chosen over rewrite because the endpoint had no caller,
no consumer, and no product role: rewriting it would have produced a correct implementation of
something nothing uses, and left a wallet-creating code path to be maintained and re-audited
forever.
### How F-13 is closed
**By removal, not by conversion to watch-only.** After this change there is no code path in the
daemon that writes the BIP-84 account private key into Bitcoin Core. The only on-node copy of
that key is the daemon's Argon2 + ChaCha20-Poly1305 envelope.
**No migration was performed and none is planned.** D-07's parity-proof migration and its
one-way checkpoint are **withdrawn** (D-07b) — there is no wallet to migrate. If a fleet node is
ever found holding a descriptor wallet this handler created, that is a **finding to surface and
stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked by hand and that
node's spending key is duplicated in Core, which deserves a human decision rather than an
automated rewrite of a wallet that may hold funds.
### This deletion removes code, not wallets
Stated explicitly so nobody reading the change later has to wonder whether it was destructive:
> **Nothing on disk is touched.** No `wallet.dat` is modified, unloaded or removed. No funds
> move. No LND state, secret, descriptor or seed is altered. The change removes a Rust function
> and a `match` arm — the *path* by which a private key could be imported into Bitcoin Core —
> and nothing else.
This holds even on a hypothetical node where the endpoint had been invoked by hand: deleting the
handler destroys nothing there either. It closes the door; it does not clean the room. Cleaning
up such a wallet, if one is ever found, is a separate human decision (see the census below), and
CLAUDE.md's **"migrations never destroy data"** invariant is not engaged by this change because
there is no migration.
### What deletion does to D-08 and D-09
Neither decision lapses; both are satisfied by a different mechanism.
- **D-08** asked that the spending key exist in exactly one place, with an opt-in air-gapped
path. Deleting the Core import achieves the first half outright. The opt-in path is LND's
existing PSBT round trip, not a Core watch-only wallet — see the next section, including the
recorded verdict on how far that actually goes today.
- **D-09** required a `[fingerprint/derivation]` key origin on emitted descriptors so a hardware
signer can locate its key. With Core's descriptors deleted there are **no Archipelago-emitted
descriptors left to annotate**, so D-09's actual protection moves to the PSBT itself. That is
why `lnd.create-psbt` now inspects and reports the key-origin data its PSBT carries
(`psbt_key_origin_report`, `core/archipelago/src/api/rpc/lnd/wallet.rs`).
### `derive_bitcoin_xprv` is retained deliberately (D-07c)
`crate::seed::derive_bitcoin_xprv` (`core/archipelago/src/seed.rs:231`) lost its only non-test
caller and was **kept**, marked `#[allow(dead_code)]` with the reason in its doc comment. It is
covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation **D-07c's deferred
BDK cold vault** — a descriptor wallet in the daemon using the node's own ElectrumX app
(`apps/electrumx`, `electrs_status.rs`) as chain source — will need.
D-07c was considered and deliberately deferred out of Phase 10 (it needs its own phase: a new
dependency and a new UI surface). It is recorded here, and in the function's doc comment, so the
option is not quietly lost along with the code that was deleted. The alternative shape — LND
watch-only via `importaccount` plus remote signing — was considered and rejected for coupling
cold storage to LND's upgrade path.
---
## LND PSBT round trip — what is covered
With Core's wallet deleted, LND is the only wallet Archipelago has, and its PSBT round trip is
the only external-signer path that exists. This section records what that path actually consists
of, what is tested, and — the question that decides whether any of it is an air gap — whether an
externally-held signer can sign a default node's PSBT at all.
### Per-step coverage map
Round trip: **fund → export → sign offline → import → finalize → broadcast.**
| # | Step | Where it lives | `file:line` | Automated test coverage |
|---|---|---|---|---|
| 1 | **Fund** — build a funded PSBT via LND WalletKit `/v2/wallet/psbt/fund` | `lnd.create-psbt` handler | `core/archipelago/src/api/rpc/lnd/wallet.rs:605`; dispatch arm `api/rpc/dispatcher.rs:136` | **Untested.** No LND mock exists; the handler's request/response handling is exercised only by hand. |
| 1a | **Inspect** — report BIP-32 key origin on the funded PSBT | `psbt_key_origin_report` + wiring | `lnd/wallet.rs:1186` (fn), `:1169` (struct), `:705` (call site), `:737` (response field) | **Tested.** 3 unit tests, below. |
| 2 | **Export** — hand the base64 PSBT to the user | UI renders `psbt_base64` for copy | `neode-ui/src/api/rpc-client.ts:407-423`; `neode-ui/src/views/web5/Web5SendReceiveModals.vue:308` | **Partial.** `neode-ui/src/api/__tests__/rpc-client.test.ts:319-323` asserts only that the client calls the method `lnd.create-psbt`; it does not test the payload or the rendering. |
| 3 | **Sign offline** — external signer produces a signed PSBT | **Not in this repo.** No first-party signer ships today. | — | N/A |
| 4 | **Import** — user pastes the signed PSBT back | textarea → `signedPsbtInput` | `Web5SendReceiveModals.vue:102`, `:419-424` | **Untested.** |
| 5 | **Finalize**`/v2/wallet/psbt/finalize` | `lnd.finalize-psbt` handler | `lnd/wallet.rs:743`; dispatch arm `dispatcher.rs:137` | **Untested.** |
| 6 | **Broadcast**`/v2/wallet/tx`, in the same handler | `handle_lnd_finalize_psbt` tail | `lnd/wallet.rs:795` | **Untested.** |
| — | **Rate limiting** — both endpoints at 5 calls / 300s | `RateLimiter` defaults | `core/archipelago/src/rate_limit.rs:68-69` | **Untested for these two methods specifically.** |
**Stated plainly, because an untested path must not be described as verified:** of the six steps,
**one** (the key-origin inspection added by this plan) has automated coverage in the Rust
crate. Steps 1, 4, 5 and 6 have **none** — no test exercises the LND REST calls, the finalize
handler, or the broadcast. Step 2's only test asserts a method name. **No end-to-end test of the
round trip exists**, and none of it has been verified against a real hardware signer.
There is also **no air-gap transport**: no animated QR encode/decode, no `.psbt` file
download/upload. Export and import are copy-paste of base64 in a textarea. The BC-UR v2 / BBQr
design in `PSBT-SIGNING-ARCHITECTURE.md` §4 is unimplemented.
### New tests added by this plan
In `core/archipelago/src/api/rpc/lnd/wallet.rs`'s `mod tests`, with fixtures built
programmatically from the `bitcoin` crate rather than pasted as opaque base64:
| Test | Asserts |
|---|---|
| `psbt_without_derivations_reports_no_key_origin` | A one-input unsigned PSBT with no `bip32_derivation` reports `inputs_with_key_origin: 0` and `all_inputs_have_key_origin: false`. |
| `psbt_with_derivations_reports_key_origin` | The same PSBT with a `(Fingerprint, DerivationPath)` inserted on input 0 reports `1/1` and `true`. |
| `malformed_psbt_is_an_error_not_a_panic` | Non-base64, truncated-PSBT and empty inputs all return `Err`, never panic. |
```
running 3 tests
test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok
test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok
test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out
```
`lnd.create-psbt` now returns an additive `key_origin` field:
```json
"key_origin": { "input_count": 1, "inputs_with_key_origin": 0, "all_inputs_have_key_origin": false }
```
It is computed **best-effort**: a decode failure degrades to `null` and logs a warning, never to
an error — a user's send must not fail because an inspection helper could not parse something.
When `all_inputs_have_key_origin` is false the handler emits a `tracing::warn!` with the counts,
because that is the exact condition under which a hardware signer refuses the PSBT. Existing
response fields are unchanged; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (the
sibling that deliberately auto-signs with LND's hot keys) were not touched.
### Can an external signer actually sign a default node's PSBT? — **No, not today**
This is the question that separates "we have PSBT plumbing" from "we have air-gapped custody",
and the two must not be allowed to blur.
**Verdict: on a default Archipelago node, an externally-held signer cannot meaningfully sign a
PSBT produced by `lnd.create-psbt`.** The evidence:
1. **The PSBT is funded from LND's own wallet.** `lnd.create-psbt` POSTs to LND's WalletKit
`/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`), which selects UTXOs belonging to **LND's**
wallet. The keys for those inputs are the keys LND holds.
2. **LND's wallet on every node is a full key-holding wallet, created locally.**
`container::lnd::ensure_wallet_initialized` (`core/archipelago/src/container/lnd.rs:86`) calls
`init_wallet_via_rest`, which POSTs `/v1/initwallet` with a `cipher_seed_mnemonic`
(`container/lnd.rs:504-516`) and persists the aezeed backup (`:523-525`). That is a normal
wallet with private keys, not a watch-only one.
3. **No node's `lnd.conf` carries a remote-signing block.** The config Archipelago generates
(`container/lnd.rs:64-79`) contains `bitcoin.node=bitcoind` and the bitcoind RPC settings, and
**no `remotesigner.*` keys at all**.
4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`,
`core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and
`nochainbackend` returns **zero matches**. There is no code path, script or manifest that sets
any node up this way.
An external signer could only sign these inputs if LND were first provisioned **watch-only
against that signer** — `remotesigner.*` on the node plus `lncli createwatchonly` from the
signer's exported accounts, with the level-3 accounts and the p2tr import step described in
`PSBT-SIGNING-ARCHITECTURE.md` §5.1-5.2. **No fleet node is so provisioned.**
**What therefore ships today is the PSBT *transport*, not air-gapped custody.** The round trip is
real and rate-limited, and it is genuinely useful for signing a PSBT whose inputs belong to some
*other* wallet — but on a default node the signer that holds the input keys is LND itself, so
routing the PSBT out to an external device and back adds a step without moving custody anywhere.
The gap between here and D-08's opt-in air-gapped path is **provisioning, not plumbing**, and
that provisioning is out of scope for Phase 10 (it is `PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 6).
Nothing in the UI currently claims otherwise, and nothing added by this plan does either. If
copy is ever written for this flow, it must not describe it as cold storage on the strength of
the PSBT round trip alone.
### Lightning channel, revocation and HTLC keys are not air-gappable — at all
This is a standing constraint, not a caveat, and it survives every change in this document.
> **A Lightning node's channel, revocation and HTLC keys must sign in real time to answer
> counterparty commitments. They cannot be air-gapped.** A routing node cannot tolerate a
> human-in-the-loop signing step: a delayed response to a commitment update risks a force-close,
> and a missing revocation risks loss. LND remote signing **relocates** these keys to a hardened
> host — it does **not** cool them. There is no configuration, present or future, in which a
> live Lightning node's channel keys are cold.
This is the same limit stated in `PSBT-SIGNING-ARCHITECTURE.md` §5.1 ("Air-gap channel /
revocation / HTLC keys — **No**") and §5.4, whose honesty table remains correct and unmodified.
The consequence for user-facing copy, quoted from §5.4 and repeated here so it cannot be lost:
> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a
> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely
> protected by an offline signer.*
**No wording in this document, or in any document this phase touches, may imply that Lightning
funds can be held cold.** A user who believes their Lightning balance is cold will keep more in
it than they otherwise would, which is exactly the miscalibration that turns an incident into a
loss.
---
## Fleet census — Core descriptor wallets
**Status: run 2026-08-02 — 4 nodes examined and CLEAR, 6 nodes UNCHECKED. No escalation.**
This section answers one question per node: *does this node hold a Bitcoin Core descriptor wallet
that the deleted wallet-init handler created, and does it hold private keys?* It is recorded per
node rather than assumed, because deletion closes the door but does not tell us whether anyone
walked through it before.
The nodes that could **not** be examined are listed with their reasons, not omitted. A census
that quietly drops its failures is worthless — an auditor must be able to see exactly which
machines were looked at and which were not.
### Hard constraint on every command in this census
> **Never run `listdescriptors true`.** The `true` argument makes Bitcoin Core return the
> descriptors **including private keys**, which would print an xprv to a terminal and into a
> transcript — creating the exact exposure this census exists to measure.
> `listwalletdir`, `listwallets`, `getwalletinfo` and `listdescriptors` **with no second
> argument** answer the question completely.
>
> If any output unexpectedly contains a string beginning `xprv`, **stop immediately, do not
> paste it**, and report only that it occurred.
### Commands (re-runnable by an auditor)
Per node, against the Bitcoin Core / Knots container:
```bash
# 0. Does the handler's wallets directory exist at all? An absent directory is
# itself a complete answer for that node — paste the output as-is.
ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1
# bitcoin-cli is NOT on $PATH inside the container. On archi-dev-box (Knots
# 29.3) it lives at:
# /opt/bitcoin-29.3.knots20260210/bin/bitcoin-cli
# The RPC user is `archipelago`; the password is read from
# /var/lib/archipelago/secrets/bitcoin-rpc-password
# — reference that path, never the value, and prefer -stdinrpcpass so the
# password never appears in a process list or shell history.
# 1. Every wallet on disk, loaded or not.
bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwalletdir
# 2. Currently loaded wallets.
bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwallets
# 3. Per wallet returned: record walletname, private_keys_enabled, descriptors,
# blank, keypoolsize, txcount, balance.
bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet=<name> getwalletinfo
# 4. ONLY for a wallet with private_keys_enabled: true — NOTE: no second argument.
# Record descriptor prefixes (`wpkh(...`) only, never a full key string.
bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet=<name> listdescriptors
# 5. Which Bitcoin app and version.
bitcoin-cli -rpcuser=archipelago -stdinrpcpass getnetworkinfo | head
```
### Results — examined, 2026-08-02 (4 nodes, all CLEAR)
Run by the operator over Tailscale, read-only RPCs only.
| Node | Tailscale IP | Container | `listwalletdir` | `listwallets` | `archipelago` wallet? | Default wallet state | Verdict |
|---|---|---|---|---|---|---|---|
| **archi-dev-box** | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | `[ "" ]` | **No** | `blank: true`, `keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` | **CLEAR** |
| **shorty-s** (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** |
| **archy-x250-beta** | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** |
| **archy-x250-pa** | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** |
On every examined node there is **no wallet named `archipelago`** — the deleted handler's default
`wallet_name`. The only named wallets are Fedimint `gatewayd-*`, unrelated to the BIP-84 path.
The one loaded wallet on each node is Core's unnamed default. It does report
`private_keys_enabled: true`, but also `blank: true` with `keypoolsize: 0`, `txcount: 0` and
`balance: 0.00000000` — **Bitcoin Core's own statement that no key was ever imported into it and
no transaction ever touched it.** It is not the deleted handler's output, and it holds nothing.
**The result holds across two container vintages**`bitcoin-knots` on two nodes and
`bitcoin-core` on two others. That matters: it is not four copies of one image behaving
identically, so the finding is a property of the fleet rather than an artefact of a single build.
**No key material appeared in any output, and `listdescriptors true` was never run.**
### Not examined, 2026-08-02 (6 nodes, with reasons)
| Node | Tailscale IP | Why not checked |
|---|---|---|
| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held |
| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` |
| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` |
| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` |
| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange |
| archy-x250-dev | `100.113.100.55` | Offline — Tailscale reports last seen 2 days prior |
**Password authentication was deliberately not attempted on any of these.** Several fleet nodes
lock PAM quickly on a wrong password, and locking an in-use production node out is a worse
outcome than an incomplete census. These are recorded as UNCHECKED, **not** as clear.
### Conclusion, at the strength the evidence supports
> **No examined node holds a wallet created by the deleted handler, and no examined node holds
> any wallet with keys or funds.** Four nodes, across two container vintages, on 2026-08-02.
**This is deliberately not a claim that "the fleet is clear."** Six nodes were not examined, and
an unexamined node is unknown, not safe. F-13 is closed **by deletion** — the code that could
create such a wallet is gone from every future build, which is true regardless of the census —
and the census adds that no such wallet was found where anyone could look.
### Standing item — finish the census
The six unchecked nodes remain open. **Homed in `docs/UNIFIED-TASK-TRACKER.md`** (the project's
canonical "what's open" list) as *"Finish the Core-wallet fleet census — 6 nodes unchecked"*,
rather than only here, so it is visible to someone who is not already reading a security
document. It is flagged there as a natural fold-in for **KEY-04's on-node work**, which needs
node access anyway — but it is tracked independently so it does not vanish if KEY-04 is
re-scoped.
Re-run the read-only procedure above when credentials or connectivity allow.
### Standing rule if a wallet is found
If any node reports a wallet named `archipelago` (or any descriptor wallet with
`private_keys_enabled: true` that this handler plausibly created), that is a **finding**:
1. **Stop.** Record it here with the node label and wallet name.
2. **Raise it as a blocker.** KEY-03 does not close until a human decides what to do about it.
3. **Do not migrate, unload, rescan or modify it.** D-07b withdrew the migration deliberately.
Rewriting a wallet that might hold funds is exactly the kind of decision that belongs to a
human, and CLAUDE.md's "migrations never destroy data" invariant applies the moment anyone
touches it.
Such a wallet would mean the endpoint was invoked manually before this plan deleted it, and that
node's spending key is duplicated in Core outside the Argon2 envelope.
+680
View File
@@ -0,0 +1,680 @@
# 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`.
**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
**deliberately declined to classify them**.
**Tree state this document was derived against:** `HEAD = c5a82cba` (2026-08-02).
---
## Nothing here is broken today
`rand::random()` and `rand::thread_rng()` on the pinned `rand 0.8.5` resolve to
`ReseedingRng<ChaCha12Core, OsRng>` — seeded from `getrandom(2)`, reseeded every 64 KiB,
fork-protected. **Every value in the table below was drawn from a genuine CSPRNG.** This
document is not an incident record.
What KEY-05 removes is the *structural* shape: 41 call sites whose entropy backend is
selected by `Cargo.lock` resolution and crate feature flags rather than stated in
Archipelago's own source, with no compile error if that selection changes. That is the shape
("T1") that produced the 2026-07-30 COLDCARD entropy defect, here with key material, an AEAD
nonce and session credentials in the blast radius.
---
## Layer coverage
ROADMAP KEY-05 names five layers. None was dropped.
| Layer | What it is | Task that closes it | Status |
|---|---|---|---|
| (a) | Sealed key-generation RNG allowlist at the mnemonic seam; the false `impl rand::CryptoRng` promise retired | Task 2 | **Closed**`entropy::KeyGenRng` sealed via a private `sealed::Sealed`; `seed.rs::generate_mnemonic_with` retyped to it; zero `impl rand::CryptoRng` blocks remain in the crate |
| (b) | Crate-wide compile-time ban on the defaulted entry points, enforced by the CI clippy step that already exists | Task 2 (dry run, uncommitted) → Task 6 (enable) | **NOT CLOSED** — see `## Clippy dry-run evidence` and `## What this does not close`. Blocked behind the Task 5 human checkpoint. |
| (c) | `cargo-deny` `bans` rule making the duplicate-`rand` split visible and change-detecting | Task 5 (decision) → Task 6 (implement) | **NOT CLOSED** — blocked on the Task 5 human decision |
| (d) | Degenerate-entropy runtime predicate | Task 2 (built) → Tasks 3/4 (applied) | **Closed**`entropy::is_degenerate` / `entropy::draw_key_bytes`, applied at every `guarded: yes` row below |
| (e) | Durable CSPRNG-readiness record | Task 2 | **Closed**`entropy::record_csprng_readiness`, called from `MasterSeed::generate` |
Layers (b) and (c) are the two that turn CI red for every agent on this shared repository if
they are enabled wrongly. Both are gated behind Task 5, a `gate="blocking-human"` checkpoint.
---
## Source precedence
`.planning/phases/10-key-material-hardening/10-CONTEXT.md` (2026-08-01) 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.
Two deferrals from that context **stand and were not executed**:
- **F-09 / R-12** — TOTP modulo bias. `totp.rs:305` is migrated for its *entropy source*
only. The `% charset.len()` selection is byte-for-byte unchanged. (The bias is presently
**zero**: the charset is 32 characters and 32 divides 256 exactly. R-12 is about the latent
bias if the charset ever changes length.)
- **F-11 / R-14** — `Math.random()` in `neode-ui`. No frontend file is touched by this plan.
---
## Enforcement blast radius — pinned mechanically
CI runs clippy with `working-directory: core` (`.github/workflows/ci.yml:19`) and
`cargo clippy --all-targets --all-features -- -D warnings` (`:35`). A `clippy.toml` at
`core/` therefore governs exactly the workspace members and no more.
`cargo metadata --no-deps --format-version 1` run from `core/`, package names only:
```
['archipelago', 'archipelago-container', 'archipelago-openwrt', 'archipelago-performance', 'archipelago-security']
```
`models`, `helpers` and `js-engine` **do not appear**. They are directories under `core/` but
are not workspace members (`core/Cargo.toml:4-10`), and are referenced only by each other.
**Stated limitation, not an omission.** `core/models/src/data_url.rs:163`
(`let random: [u8; 10] = rand::random();`) and `core/models/src/procedure_name.rs:32`
(`Some(format!("Properties-{}", rand::random::<u64>()))`) are real matches of the same shape
and are **outside KEY-05's reach**: they are outside the clippy build graph, so no
`disallowed-methods` entry can reach them, and they are outside this plan's `files_modified`.
Neither draws key material (a data-URL filename component and a procedure-name suffix), and
neither is compiled into the `archipelago` binary. They are recorded here so a future reader
does not mistake "43 classified" for "43 of 45 in the repository".
The other four workspace members (`container`, `openwrt`, `performance`, `security`) contain
**zero** matches — verified by
`grep -rn "rand::random\|thread_rng()" core/container core/openwrt core/performance core/security --include=*.rs`,
which returns nothing. So the ban, once enabled, is free for them.
---
## Per-site classification — all 43 matches
Source of the inventory, re-run against the working tree at `HEAD = c5a82cba` rather than
inherited from the plan or from F-10a:
```
grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs
```
**43 lines across 16 files** (15 code files + `seed.rs`, whose two matches are comments).
`prod/test` is decided by whether the line falls inside that file's `#[cfg(test)] mod tests`
block; the block's start line is cited in the `## cfg(test) boundaries` section below and is
the evidence for every `test` verdict.
`guarded` is `yes` only where the drawn value is **key material or an AEAD nonce** *and* the
draw is **at least `MIN_GUARDED_LEN` = 12 bytes**. Every `no` carries its reason.
| Site | Expression | Kind | Becomes | Guarded | Disposition |
|---|---|---|---|---|---|
| `core/archipelago/src/storage_crypto.rs:39` | `let nonce_bytes: [u8; 12] = rand::random();` | production | ChaCha20-Poly1305 nonce for the message / mesh-contact at-rest stores; the 12-byte prefix of the `nonce ‖ ciphertext` envelope | **yes** (12 B, AEAD nonce — reuse is a keystream break) | migrate |
| `core/archipelago/src/credentials/store.rs:120` | `let nonce_bytes: [u8; 12] = rand::random();` | production | ChaCha20-Poly1305 nonce for the credential store, inside `encrypt_credentials` | **yes** (12 B, AEAD nonce) | migrate |
| `core/archipelago/src/session.rs:156` | `let token_bytes: [u8; 32] = rand::random();` | production | full authenticated session token (`SessionStore::create`) | **yes** (32 B, bearer credential) | migrate |
| `core/archipelago/src/session.rs:178` | `let token_bytes: [u8; 32] = rand::random();` | production | pending-TOTP session token (`create_pending`) | **yes** (32 B) | migrate |
| `core/archipelago/src/session.rs:254` | `let new_token_bytes: [u8; 32] = rand::random();` | production | rotated token on pending→full upgrade (`upgrade_to_full`) | **yes** (32 B) | migrate |
| `core/archipelago/src/session.rs:294` | `let new_token_bytes: [u8; 32] = rand::random();` | production | rotated session token (`rotate`) | **yes** (32 B) | migrate |
| `core/archipelago/src/session.rs:478` | `rand::random::<u64>()` | test (mod at `:471`) | uniquifying suffix in a temp-file path for `new_for_tests` | no — 8 B, a filename component, not key material | migrate |
| `core/archipelago/src/session.rs:489` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:498` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:511` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:538` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:569` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:584` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:602` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:620` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:651` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:669` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/session.rs:685` | `rand::random::<u64>()` | test | temp-file path suffix | no — as above | migrate |
| `core/archipelago/src/device_tokens.rs:64` | `let token_bytes: [u8; 32] = rand::random();` | production | companion-device bearer token (`device_tokens::create`) | **yes** (32 B, bearer credential) | migrate |
| `core/archipelago/src/federation/invites.rs:42` | `rand::thread_rng().fill(&mut token_bytes);` | production | 16-byte federation invite token, hex-encoded into the invite payload | **yes** (16 B, unguessable-by-design token) | migrate |
| `core/archipelago/src/wallet/bdhke.rs:133` | `let random_bytes: [u8; 32] = rand::random();` | production | Cashu (NUT-00/NUT-10) proof secret — **genuine ecash key material** | **yes** (32 B) | migrate |
| `core/archipelago/src/wallet/bdhke.rs:139` | `let mut rng = rand::thread_rng();``SecretKey::new(&mut rng)` | production | Cashu blinding factor — a secp256k1 scalar; **genuine ecash key material** | no — **deliberate non-application**, see `## Deliberate non-applications of the guard` | migrate |
| `core/archipelago/src/wallet/bdhke.rs:169` | `let k = SecretKey::new(&mut rand::thread_rng());` | test (mod at `:144`) | throwaway scalar in `test_bdhke_flow` | no — test scalar, same rejection-sampling argument as `:139` | migrate |
| `core/archipelago/src/wallet/bdhke.rs:206` | `let k = SecretKey::new(&mut rand::thread_rng());` | test | throwaway scalar | no — as above | migrate |
| `core/archipelago/src/mesh/x3dh.rs:100` | `let spk_id: u32 = rand::random();` | production | `SignedPrekey.id` — a 4-byte **identifier**, not key material (the X25519 secret comes from `crypto::generate_x25519_ephemeral()` at `:99`) | no — 4 B, below `MIN_GUARDED_LEN`; an "all bytes identical" predicate false-positives on a 4-byte draw once in 2^24 | migrate |
| `core/archipelago/src/mesh/x3dh.rs:114` | `let otk_id: u32 = rand::random();` | production | `OneTimePrekey.id` — 4-byte identifier; the secret comes from `crypto::generate_x25519_ephemeral()` at `:113` | no — as above | migrate |
| `core/archipelago/src/container/secrets.rs:103` | `rand::thread_rng().fill_bytes(&mut buf);` | production | `random_hex(bytes)` — the manifest-declared `generated_secrets` (app passwords, API keys); the original F-10 | **yes when `bytes >= 12`** (the only production callers request 16/32); unguarded below the floor | migrate |
| `core/archipelago/src/container/secrets.rs:112` | `rand::thread_rng().fill_bytes(&mut buf);` | production | `random_base64(bytes)` — same, for services that base64-decode to raw bytes (e.g. netbird `encryptionKey`) | **yes when `bytes >= 12`** | migrate |
| `core/archipelago/src/api/rpc/package/install.rs:732` | `let secret: [u8; 32] = rand::random();` | production | SearXNG `server.secret_key` in `settings.yml` — signs SearXNG's own tokens | **yes** (32 B, app secret) | migrate |
| `core/archipelago/src/api/rpc/package/install.rs:1456` | `let salt_bytes: [u8; 16] = rand::random();` | production | `rpcauth=` salt for the Bitcoin Core RPC HMAC credential line | **yes** (16 B; the salt is half the credential — a degenerate salt weakens the stored `rpcauth` line) | migrate |
| `core/archipelago/src/bitcoin_rpc.rs:62` | `let bytes: [u8; 16] = rand::random();` | production (file has no `#[cfg(test)]` module) | the Bitcoin RPC **password** itself, hex-encoded to 32 chars | **yes** (16 B, credential) | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:102` | `let raw: [u8; 32] = rand::random();` | production | Pine/Home-Assistant status bearer token, written 0600 under `NODE_SECRETS_DIR` | **yes** (32 B, bearer credential) | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:490` | `"entry_id": id(rand::random()),` | production | Home Assistant config-entry **id** (16 B hex) — HA needs uniqueness only; not a credential and never authenticates anything | no — an identifier, not key material; fails the "key material or AEAD nonce" test | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:507` | `"subentry_id": id(rand::random()),` | production | HA conversation subentry id | no — identifier, as above | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:521` | `"subentry_id": id(rand::random()),` | production | HA `ai_task_data` subentry id | no — identifier, as above | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:588` | `let entry_id: [u8; 16] = rand::random();` | production | HA `wyoming` config-entry id | no — identifier, as above | migrate |
| `core/archipelago/src/api/rpc/package/pine_ha.rs:665` | `let raw: [u8; 26] = rand::random();` | production | ULID-shaped HA id (26 Crockford-base32 chars) | no — identifier, as above | migrate |
| `core/archipelago/src/api/rpc/auth.rs:125` | `hex::encode(rand::random::<[u8; 2]>())` | production (file has no `#[cfg(test)]` module) | 4-hex-char suffix disambiguating default-named `companion-*` device entries in the UI | no — 2 B; an "all bytes identical" predicate false-positives once in 256, which would be worse than the defect it guards | migrate |
| `core/archipelago/src/fips/dial.rs:75` | `let id: u16 = rand::random();` | production | DNS query transaction id for the FIPS `_fips` lookup | no — 2 B, protocol identifier; same 1-in-256 false-positive argument | migrate |
| `core/archipelago/src/transport/chunking.rs:149` | `let message_id: u32 = rand::random();` | production | chunk-frame `message_id` correlating Reed-Solomon shards | no — 4 B, protocol identifier | migrate |
| `core/archipelago/src/totp.rs:305` | `let idx = (rand::random::<u8>() as usize) % charset.len();` | production | one character of a TOTP backup code (bcrypt-hashed before storage) | no — a single byte, far below the floor; **the `%` selection is R-12 and is deliberately untouched** | migrate |
| `core/archipelago/src/seed.rs:87` | `/// to \`&mut rand::thread_rng()\` *inside* the \`bip39\` crate, so the RNG backing every` | doc comment | nothing — prose in the F-02 remediation rationale | n/a | comment |
| `core/archipelago/src/seed.rs:681` | `// bip39's transitive \`rand::thread_rng()\` default, is the one consumed.` | line comment | nothing — prose inside `mnemonic_generation_uses_injected_rng` | n/a | comment |
**Disposition tally:** `migrate` = 41, `comment` = 2, `allow` = **0**.
**There are no `allow` rows.** Every test fixture migrates to `OsRng` as readily as production
code does, so no site needed an exemption, and consequently **no
`#[allow(clippy::disallowed_methods)]` attribute is introduced anywhere in the crate**. That
is the strongest available outcome for layer (b): the ban has no holes to audit.
### cfg(test) boundaries — the evidence for every prod/test verdict
| File | `#[cfg(test)] mod tests` begins | Consequence |
|---|---|---|
| `core/archipelago/src/session.rs` | `:471` | 4 of 16 matches are production; 12 are test fixtures |
| `core/archipelago/src/wallet/bdhke.rs` | `:144` | 2 production, 2 test |
| `core/archipelago/src/api/rpc/package/pine_ha.rs` | `:979` | all 6 matches are production |
| `core/archipelago/src/mesh/x3dh.rs` | `:292` | both matches production |
| `core/archipelago/src/container/secrets.rs` | `:275` | both matches production |
| `core/archipelago/src/api/rpc/package/install.rs` | `:2872` | both matches production |
| `core/archipelago/src/storage_crypto.rs` | `:79` | production |
| `core/archipelago/src/credentials/store.rs` | `:168` | production |
| `core/archipelago/src/device_tokens.rs` | `:112` | production |
| `core/archipelago/src/federation/invites.rs` | `:350` | production |
| `core/archipelago/src/totp.rs` | `:340` | production |
| `core/archipelago/src/transport/chunking.rs` | `:294` | production |
| `core/archipelago/src/fips/dial.rs` | `:683` | production |
| `core/archipelago/src/seed.rs` | `:513` | `:87` is above it (doc comment on a production fn); `:681` is inside it |
| `core/archipelago/src/bitcoin_rpc.rs` | **none** — the file has no `#[cfg(test)]` module at all (72 lines) | its single match is production by construction |
| `core/archipelago/src/api/rpc/auth.rs` | **none** — the file has no `#[cfg(test)]` module at all (332 lines) | its single match is production by construction |
---
## Two corrections to F-10a
F-10a recorded **raw match counts** and said so explicitly ("the full table in §F-10a"); it
declined to classify. These are resolutions of that refusal, not contradictions of it.
**1. `session.rs` is 4 production sites, not 16.** F-10a's headline table reports
`session.rs | 16` under a "Generates: session tokens" column. The evidence line is
`core/archipelago/src/session.rs:471` — `mod tests {` — above which lie exactly four matches
(`:156`, `:178`, `:254`, `:294`) and below which lie twelve. The twelve below are
`rand::random::<u64>()` used to uniquify a temp-file name in
`SessionStore::new_for_tests(std::env::temp_dir().join(format!("archipelago-sessions-test-{}.json", …)))`
— not tokens at all. (F-10a's own body text does carry the `4 prod + 12 test` split; the
correction is that the headline number is a raw grep count and must not be read as a
production-site count.)
**2. `mesh/x3dh.rs`'s two matches are prekey identifiers, not key material.** The evidence
lines are `core/archipelago/src/mesh/x3dh.rs:99` and `:113` —
`let (spk_secret, spk_public) = crypto::generate_x25519_ephemeral();` and
`let (otk_secret, otk_public) = crypto::generate_x25519_ephemeral();`. The X25519 secrets are
produced there; `:100` and `:114` draw only the `u32` `id` fields of `SignedPrekey` and
`OneTimePrekey`. They remain in scope — they are values that go on the wire — but the
characterisation "X3DH key agreement — key material" overstates these two specific lines.
(The audit has since been corrected in place at `ENTROPY-SEED-AUDIT-2026-07-31.md:508`; this
section records the derivation independently.)
---
## Sealing: what it prevents and what it does not
`core/archipelago/src/entropy.rs` declares a **private** module `sealed` containing a trait
`Sealed`, and
```rust
pub(crate) trait KeyGenRng: rand::RngCore + sealed::Sealed { … }
```
`sealed::Sealed` is nameable only from inside `entropy`, so `impl KeyGenRng for MyType`
written anywhere else cannot compile — the required supertrait bound is unsatisfiable and
unimplementable there.
**What it prevents.**
- No other module of this crate can add a member to the key-generation allowlist.
- No downstream crate can, either.
- `seed.rs::generate_mnemonic_with` is typed `R: KeyGenRng`, so the entropy source for the
entire master key hierarchy — node Ed25519 `did:key`, node Nostr key, FIPS mesh key,
per-identity keys, the BIP-84 wallet, LND aezeed entropy, and the fleet release-root
**signing** key — is constrained at the type level rather than by a doc comment.
**What it does not prevent, stated plainly.**
- **It does not prevent someone editing `entropy.rs` itself and adding a member.** Sealing
makes the allowlist a closed set that is *reviewable in one file*; it does not make it
immutable. That is the honest limit of the mechanism.
- **It does not prevent code calling an RNG directly, bypassing the seam entirely.** A new
`let k: [u8; 32] = rand::random();` in some unrelated module never mentions `KeyGenRng` and
sealing has nothing to say about it. **That gap is exactly what layer (b) covers.** The two
mechanisms are complementary, not redundant: (a) constrains what can drive a seam, (b)
constrains what can be written at all.
- **The "no downstream crate" clause is vacuous today.** `core/archipelago` is a
**binary-only** crate — `core/archipelago/Cargo.toml:8` declares `[[bin]]` with
`path = "src/main.rs"` and there is no `src/lib.rs`, so nothing depends on it and there are
no downstream crates to exclude. The clause is stated because it becomes load-bearing the
day this is split into a library, not because it is doing work now.
### The false `CryptoRng` promise is retired, not relocated
`seed.rs` previously carried `impl rand::CryptoRng for CountingRng` — a marker asserting that
an ascending counter is suitable for cryptographic use. `CryptoRng` has no compiler-checked
content: it is a promise any caller can make about any type, which is why the old bound
`R: rand::CryptoRng + rand::RngCore` was satisfiable by a counter in the first place.
KEY-05 **deletes** that impl rather than moving it. After this plan the crate contains **zero**
`impl rand::CryptoRng` blocks — verified comment-filtered, so prose describing the deletion can
neither satisfy nor invalidate the check:
```
$ grep -rn "impl rand::CryptoRng" core/archipelago/src --include=*.rs \
| grep -vE ':[0-9]+: *(//|///|\*)' | wc -l
0
```
There is now exactly one mechanism for the claim "this RNG may generate keys", and it is the
one the compiler verifies.
### Deviation from the plan: `KeyGenRng::GUARD_DRAWS`
The plan specified `draw_key_bytes` as unconditionally guarded *and* required
`generate_mnemonic_with` to route through it *and* required the pre-existing
`mnemonic_generation_uses_injected_rng` known-answer assertions to stay byte-identical. **Those
three requirements are mutually unsatisfiable**, and the contradiction is not incidental: that
test's RNG emits `0x00, 0x01, … 0x1f`, which *is* the ascending-counter pattern layer (d)
exists to reject. Guarding it makes the known-answer pin unrepresentable.
Resolution: `KeyGenRng` carries an associated constant
```rust
const GUARD_DRAWS: bool = true;
```
which `draw_key_bytes` consults. Three properties make this an acceptable seam rather than a
hole:
1. **It is inside the seal.** Only a type blessed in `entropy.rs` can set it, because only such
a type can implement `KeyGenRng` at all.
2. **The only member that sets it `false` is `#[cfg(test)]`-gated.** `testing::CountingRng` is
not compiled into the `archipelago` binary, so in a production build *every* allowlist
member is guarded. `sealed_allowlist_has_one_production_member` asserts
`<OsRng as KeyGenRng>::GUARD_DRAWS` is `true`.
3. **The guard is still observed tripping through `draw_key_bytes`**, not merely through the
pure predicate: `testing::ConstantRng` keeps the default `GUARD_DRAWS = true`, and
`draw_key_bytes_rejects_and_zeroizes_a_degenerate_draw` proves the full path — refusal,
variant, and buffer zeroization.
The alternative — dropping the known-answer pin to satisfy the guard — would have deleted the
crate's only proof that the RNG named at the call site is the one `bip39` consumes. That proof
is the entire point of the F-02 remediation this plan generalises.
---
## Degenerate-entropy predicate
`entropy::is_degenerate(&[u8]) -> Option<DegenerateEntropy>` recognises **exactly three**
patterns and nothing else:
| Variant | Predicate | Why this shape |
|---|---|---|
| `AllZero` | every byte is `0x00` | what a buffer looks like when the fill never happened |
| `AllIdentical` | every byte equals `bytes[0]` | an uninitialised constant fill; checked *after* `AllZero` so the reported variant is the more specific one |
| `Counter` | every adjacent pair satisfies `b[i+1] == b[i].wrapping_add(1)`, **or** every adjacent pair satisfies `b[i+1] == b[i].wrapping_sub(1)` | a counter PRNG standing in for a CSPRNG — the 2026-07-30 COLDCARD shape |
**Nothing heuristic.** No entropy estimator, no chi-squared, no "looks non-random" scoring. A
predicate whose false-positive rate cannot be computed in closed form cannot be argued safe,
and refusing genuine CSPRNG output on a key-generation path is strictly worse than the defect
being guarded against.
### False-positive bound, computed
For a uniform random `n`-byte buffer (`n ≥ 2`):
- `P(AllIdentical)` — the first byte is free, the remaining `n1` must match:
`256^(n1) = 2^8(n1)`. This already includes `AllZero` as a subset.
- `P(Counter)` — the first byte is free, the remaining `n1` are then determined; ascending
and descending are disjoint for `n ≥ 2` (they would require `+1 ≡ 1 (mod 256)`):
`2 · 2^8(n1)`.
- Union bound: `P(degenerate) ≤ 3 · 2^8(n1)`.
| `n` | Bound | As a probability |
|---|---|---|
| 2 | `3 · 2^8` | **1.17 × 10⁻²** — about 1 in 85 |
| 4 | `3 · 2^24` | 1.79 × 10⁻⁷ — about 1 in 5.6 million |
| **12** (`MIN_GUARDED_LEN`, the ChaCha20-Poly1305 nonce width) | `3 · 2^88` | **9.7 × 10⁻²⁷** |
| **32** (session tokens, Cashu secrets, master-seed entropy) | `3 · 2^248` | **6.6 × 10⁻⁷⁵** |
Over a deliberately generous lifetime budget of **10¹² guarded draws across the whole fleet,
forever**, the expected number of false rejections is **9.7 × 10⁻¹⁵ at n = 12** and
**6.6 × 10⁻⁶³ at n = 32**. A false stop is not a risk this predicate meaningfully carries at or
above the floor.
### Why twelve is the floor, and why it is a panic
The `n = 2` and `n = 4` rows are the argument. On a 2-byte draw the predicate fires on genuine
CSPRNG output about **once in 85** — vastly worse than the defect it guards against. That is why
`draw_key_bytes` **panics** rather than erroring on a buffer shorter than `MIN_GUARDED_LEN`:
calling the guard where its own bound does not hold is a programmer error, not an input
condition. A caller that legitimately needs fewer bytes draws from `OsRng` directly and
unguarded, and the classification table above records every such site with its reason.
Twelve is also exactly the ChaCha20-Poly1305 nonce width, so every AEAD nonce in the crate is
guardable *at* the floor rather than below it.
### On a trip: refuse, zeroize, do not retry
`draw_key_bytes` zeroizes the buffer, logs the variant and the buffer **length**, and returns
the error. **There is no retry.** A retry would paper over a genuinely broken RNG, which is
precisely the failure this layer exists to surface. The bytes themselves are never logged.
### Empirical companion
`degenerate_accepts_100k_osrng_draws` runs 100,000 consecutive 32-byte `OsRng` draws through
`is_degenerate` and asserts every one is accepted. Given the 6.6 × 10⁻⁷⁵ bound above, a single
rejection there means the predicate is wrong, not that the run was unlucky.
---
## CSPRNG-readiness ledger
**Path.** `<ARCHIPELAGO_DATA_DIR>/security/csprng-readiness.jsonl`, with
`ARCHIPELAGO_DATA_DIR` falling back to `/var/lib/archipelago` — the same resolution
`container/version_config.rs:36-39` uses. Resolving its own path is what lets layer (e) live
entirely inside `entropy.rs` **without** touching `bootstrap.rs` or `api/rpc/system/handlers.rs`,
both of which belong to plan `10-04`.
Deliberately **outside `identity/`**: the KEY-02 rootfs identity sweep and
`backup.restore-identity` operate on that directory wholesale, and neither should ever have to
reason about a file that is not key material.
**Schema.** One JSON object per line, append-only:
```json
{"v":1,"ts":"2026-08-02T18:04:11Z","ready":true,"event":"master-seed-generate"}
```
| Field | Meaning |
|---|---|
| `v` | schema version — exists so a future change does not orphan lines already on fleet nodes |
| `ts` | RFC 3339 UTC, second precision |
| `ready` | `true` / `false` / `null` — the verdict `seed.rs::kernel_csprng_ready()` computes via `getrandom(GRND_NONBLOCK)`; `null` on a non-Linux build or an unexpected errno |
| `event` | which generation event this verdict belongs to; `master-seed-generate` from `MasterSeed::generate` |
**No entropy, no key bytes, no seed material, no mnemonic word, and no hash of any of them is
ever written.** A readiness ledger that carried any of those would be a new place to steal a
key from, sitting one directory away from `identity/`. The record is a
`#[derive(serde::Serialize)]` struct with exactly four fields rather than a `json!` literal, so
the schema is a compile-time object that cannot drift.
`readiness_record_contains_no_mnemonic_words` proves this the strong way: it generates a real
mnemonic through `MasterSeed::generate()` against a temporary data dir and asserts the ledger's
alphabetic token set is a **subset of the fixed schema vocabulary** — from which "no mnemonic
word leaked" follows, since any leaked word would be a token outside that set. The test does
**not** do a naive substring search, and the reason is recorded in the test itself: `master`,
`seed` and `ready` are themselves BIP-39 English words, and `generate` contains the BIP-39 word
`era` as a substring (`gen-era-te`), so a naive check would be flaky *and* wrong in both
directions.
**Permissions.** Created `0o600` via `OpenOptions::mode`, matching the identity-blob pattern at
`seed.rs` and the generated-secret pattern at `container/secrets.rs:207`.
**Best-effort, by design.** Every failure path — cannot create the directory, cannot open the
file, cannot write, cannot serialise — logs at `warn` and returns. `ceremony.rs` generates a
master seed **offline**, on a machine that need not have `/var/lib/archipelago` at all. An
audit record that could fail key generation would be an availability defect introduced by a
security feature, which is not a trade worth making.
`readiness_record_survives_unwritable_data_dir` proves this with a real unwritable path (a
*file* where the data directory should be), not by inspection.
**What it closes.** `MasterSeed::generate` computed the readiness verdict, logged it into three
branches, and then discarded it. That discard is the whole of backlog **R-09**: a node could
never answer, after the fact, whether the kernel pool was seeded when its keys were born. It
can now.
## Deliberate non-applications of the guard
Layer (d) is applied at every `guarded: yes` row in the classification table. It is **not**
applied at the sites below. Each is recorded with its reason rather than silently omitted,
because a guard that is quietly skipped somewhere is worse than one that is openly bounded.
### 1. `wallet/bdhke.rs` — the Cashu blinding factor
`random_blinding_factor` migrates to an explicit `OsRng` but does **not** route through
`draw_key_bytes`. The draw is consumed by `secp256k1::SecretKey::new(&mut rng)`, which performs
**rejection sampling** into the curve group order — it draws, tests the candidate against the
order, and redraws on rejection. Intercepting the bytes to inspect them would mean
reimplementing that sampling in Archipelago, and getting rejection sampling subtly wrong on an
ecash key is a materially larger correctness risk than the guard buys against a hypothetical
future RNG rebinding.
The migration is still worth doing on its own: the *source* is now named, which is the whole of
layer (a)'s claim, and `blinding_factor_is_valid_and_varies` pins that successive factors are
valid, in-range secp256k1 scalars and differ — so a rebinding to a constant source fails there
rather than silently producing correlated ecash.
### 2. Short protocol identifiers — below `MIN_GUARDED_LEN`
| Site | Width | Why unguarded |
|---|---|---|
| `mesh/x3dh.rs:100`, `:114` | 4 B (`u32` prekey ids) | Below the floor. Not key material — the X25519 secrets come from `crypto::generate_x25519_ephemeral()`. |
| `transport/chunking.rs:149` | 4 B (`u32` message id) | Below the floor; a frame correlator. |
| `fips/dial.rs:75` | 2 B (`u16` DNS transaction id) | Below the floor; `AllIdentical` would false-positive **once in 256**. |
| `api/rpc/auth.rs:125` | 2 B (display-name suffix) | Below the floor; same 1-in-256 argument. The actual credential is minted by `device_tokens::create`, which **is** guarded. |
| `totp.rs:305` | 1 B | A single byte cannot be meaningfully inspected at all. |
The bound table in `## Degenerate-entropy predicate` is the argument: at two bytes the predicate
fires on genuine CSPRNG output about once in 85, which is a far worse defect than the one it
guards against. `draw_key_bytes` **panics** below the floor precisely so that this reasoning
cannot be bypassed by accident.
### 3. Non-credential identifiers at or above the floor
`api/rpc/package/pine_ha.rs:490`, `:507`, `:521`, `:588` (16-byte Home Assistant config-entry
and subentry ids) and `:665` (a 26-byte ULID-shaped id) are long enough to guard but are **not
key material or AEAD nonces**: Home Assistant requires only uniqueness from them and they
authenticate nothing. Guarding them would widen the guard's contract from "key material" to
"anything random", which makes the `guarded` column meaningless and puts a panic path on an app
config-seeding routine for no security gain. `pine_ha.rs:102` — the actual status **bearer
token** in the same file — *is* guarded, which is the distinction the column exists to record.
### 4. Where a degenerate draw aborts rather than propagating
`draw_key_bytes` returns a `Result`, and every site whose function already returns `Result`
propagates it: `storage_crypto::seal`, `credentials::encrypt_credentials`,
`device_tokens::create`, `federation::invites::create_invite`, the two `install.rs` sites, and
`seed::generate_mnemonic_with`. `pine_ha.rs:102` returns `Option` and degrades to `None` with a
`warn!`.
Four sites **abort** instead, and this is a deviation from the plan's "propagate rather than
unwrap" instruction that needs stating:
| Site | Why it cannot propagate |
|---|---|
| `session.rs::fresh_session_token` | `create`, `create_pending` and `rotate` return a bare `String`; their callers are in `api/rpc/mod.rs` and `api/rpc/totp.rs`, files plan 10-06 does not own. Widening them to `Result` is an API change this plan is not permitted to make. |
| `wallet/bdhke.rs::generate_secret` | returns `Vec<u8>` |
| `bitcoin_rpc.rs::generate_random_password` | returns `String`, and its caller is a `OnceCell` initialiser that also returns `String` |
| `container/secrets.rs::fill_secret_bytes` | `random_hex` / `random_base64` return `String` |
In every one of the four, the only two available behaviours are *emit a predictable credential*
or *refuse loudly*, and only the second is defensible. Reaching the branch means the kernel
CSPRNG returned 1232 bytes that are all-zero, all-identical or a ±1 counter — the machine has
no usable entropy and must not be issuing credentials at all. None of the four can be driven by
attacker-supplied input: the predicate reads only `OsRng` output. The false-trip bound is
`3 · 2^88` at 12 bytes and `3 · 2^248` at 32.
Making these propagate properly is a worthwhile follow-up, but it is an API change across files
this plan does not own, so it is recorded here rather than performed.
## Clippy dry-run evidence
A lint config that is never observed to fail is indistinguishable from one that is
misconfigured, so the ban was **observed firing** rather than assumed. Run from `core/`,
2026-08-02, clippy 1.95.0.
### The ban fires
A single banned call was reintroduced into `entropy.rs` and clippy re-run:
```
warning: use of a disallowed method `rand::random`
--> archipelago/src/entropy.rs:675:5
|
675 | rand::random::<u64>()
| ^^^^^^^^^^^^^^^^^^^
|
= note: KEY-05: inherits its entropy backend from a dependency default instead of
stating it. Use rand::rngs::OsRng at the call site; for key material or AEAD
nonces >= 12 bytes use crate::entropy::draw_key_bytes. See
docs/security/KEY-05-ENTROPY-ENFORCEMENT.md
= note: `#[warn(clippy::disallowed_methods)]` on by default
```
The `reason` string reaches the developer at the point of failure, which is the whole
value of the `reason` field. Under the CI invocation's `-D warnings` this is an error.
### The reintroduction was reverted
After `git checkout core/archipelago/src/entropy.rs`, the residual count is **0**:
```
grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs \
| grep -vE ':[0-9]+: *(//|///|\*)' | wc -l
0
```
### ⚠️ The enforcement channel is currently NOT green — a finding, not a side note
Layer (b) was designed to need no CI change because the Rust job already runs
`cargo clippy --all-targets --all-features -- -D warnings`. That reasoning is sound, but
the measured state of the tree is not:
**`cargo clippy --all-targets --all-features` emits 42 pre-existing warnings** on this
tree, unrelated to KEY-05 — `unused import: DeviceProbe`, `constant ELECTRUM is never
used`, `value assigned to last_err is never read`, plus ~39 style lints
(`redundant_guards`, `manual_map`, `needless_return`, `nonminimal_bool`,
`items_after_test_module`, and others). Under `-D warnings` **every one of them is
already an error**, so that CI step cannot currently pass for reasons that have nothing
to do with this plan.
Consequences, stated plainly:
1. KEY-05 layer (b) is **correctly configured and proven to fire**, but the gate it rides
on is red for other reasons. Until those 42 are cleared, a new banned RNG call would be
one error among many rather than the distinctive build-stopper the design intends.
2. This is **pre-existing and out of scope here** — clearing 42 lints across the crate is
its own change, and doing it immediately before an OTA would be poor sequencing.
3. It is recorded rather than quietly absorbed, because a reader would otherwise
reasonably conclude from "no CI change was needed" that the gate is live and effective.
It is live; it is not yet effective.
Recommended follow-up: a dedicated lint-clearing pass, after which layer (b) becomes a
real gate. Tracked in `## What this does not close`.
## cargo-deny evidence
Verified by the same standard — the rule was observed both passing and failing.
**A. The tree as it stands passes.** `cargo deny check bans` → `bans ok`, exit 0.
**B. The rule bites.** The plan offered two demonstrations; the second was used
(introducing a synthetic third `rand` was impractical without perturbing the lockfile).
The grandfather `[[bans.skip]]` entry was temporarily removed and the rule fired on the
existing pair, printing the full dependency trees for both versions and exiting **2**:
```
├ rand v0.8.5 (direct, + archipelago-security, bip39, mainline,
│ secp256k1, tungstenite 0.20.1)
├ rand v0.9.2 (totp-rs 5.7.0; tungstenite 0.26.2 via nostr-sdk)
bans FAILED
```
This also independently confirms F-07's account of where each version comes from.
**C. Restored.** The grandfather entry was put back and `cargo deny check bans` returns
`bans ok`, exit 0.
## cargo-deny policy
**Decision (checkpoint 10-06 Task 5, human-approved 2026-08-02): `bans` only. `advisories` NOT
enabled.** Pinned version: **cargo-deny 0.20.2**.
### Tool legitimacy (the required pre-step)
`cargo-deny` was verified on crates.io before being wired into CI:
| Check | Result |
|---|---|
| Publisher / repository | EmbarkStudios — `github.com/EmbarkStudios/cargo-deny`, resolves |
| Homepage | same as repository |
| Latest published version | `0.20.2`, published 2026-07-09 |
| Downloads | ~4,786,401 all-time; ~1,285,082 recent |
| Version pinned in CI | `0.20.2` |
Disposition: legitimate, actively maintained, plausible download history for a tool of its age.
### Why bans-only
R-05 / F-07 / KEY-05(c) asked for exactly one thing: fail the build when the duplicate `rand`
majors change, "so the split is visible rather than silent". That is what shipped.
The `advisories` section is a materially larger, separate commitment and was declined **for now**,
with the cost stated rather than glossed: an advisories gate fails builds when a **new CVE is
published against an existing dependency, with no change to this repository**. On a tree where
several agents commit and push continuously, an unrelated upstream disclosure would block
everyone at an arbitrary hour, and the remediation is frequently a dependency bump that is itself
a phase-sized change — this repo pins `bip39` and `bitcoin` exactly, and F-07 already documents
why a `rand` bump is not casual. No break-glass procedure exists today. That is a policy call
about how the team wants to be interrupted, so it was taken by a human, not defaulted by a planner.
### Mechanism
`core/deny.toml` uses a global `multiple-versions = "allow"` with a per-crate
`[[bans.deny]] name = "rand", deny-multiple-versions = true`, plus a dated `[[bans.skip]]`
grandfather entry pinning `=0.9.2` exactly. The contract, independent of config keys:
- the tree **as it stands** passes;
- a **third** `rand` version, or a change to either member of the current pair, **fails**.
### CI wiring, and one deliberate deviation from the plan's suggestion
The plan anticipated the `EmbarkStudios/cargo-deny-action`. That action was inspected and
**not** used: it exposes **no input to pin the cargo-deny version**, and an unpinned
supply-chain checker is a contradiction in terms — it would reintroduce, at the CI layer, exactly
the "backend fixed by configuration rather than stated" failure shape this whole plan exists to
remove. Instead the CI step installs the tool from crates.io at an exact version
(`cargo install --locked cargo-deny --version 0.20.2`), which is also the source that was
legitimacy-checked above, and avoids adding a second, unvetted third-party action to the workflow.
Cost of this choice, stated honestly: `cargo install` is slower than a prebuilt-binary action on
a cold cache. The existing `actions-rust-lang/setup-rust-toolchain@v1` caching mitigates it.
## What this does not close
Recorded so that nothing here is mistaken for a stronger guarantee than it is.
- **F-07's advisory half remains OPEN.** Bans-only was selected; there is still no
dependency-advisory (CVE) gate in CI. This stays in the backlog as R-05's unfinished remainder,
and adopting it needs an agreed break-glass procedure first.
- **The two `rand` majors are still both in the graph.** This layer makes the split *visible and
change-detecting*; it does not unify it. Unifying means bumping exactly-pinned crypto
dependencies and is not in scope here.
- **F-09 / R-12 remains deferred.** `totp.rs` still selects its charset with `% charset.len()`.
The bias is presently **zero** (32 divides 256 exactly), and only the *entropy source* was
migrated. The selection algorithm was deliberately left untouched.
- **F-11 / R-14 remains deferred.**
- **`core/models` is outside the enforcement graph.** `cargo metadata --no-deps` confirms the
workspace members are `archipelago`, `archipelago-container`, `archipelago-openwrt`,
`archipelago-performance` and `archipelago-security`. `core/models/src/data_url.rs:163` and
`core/models/src/procedure_name.rs:32` are real matches of the same shape that **no
`disallowed-methods` entry can reach**. This is a stated limitation, not an omission.
- **Sealing does not prevent an edit to `entropy.rs` itself.** The allowlist is sealed against
*other modules* adding a member; anyone editing `entropy.rs` can still add one. The mechanism
raises the act from an invisible default to a deliberate, reviewable change to a file whose
entire purpose is this guarantee — that is the honest claim, and it is not "impossible".
- **Mnemonics generated before this change came from the previous source.** That source was, and
remains, `getrandom(2)`-backed on the pinned `rand 0.8.5` — so nothing already generated is
suspect. This plan removes a *future* failure mode; it is not a remediation of past key material,
and no re-generation is implied or required.
- **Layer (b)'s gate is live but not yet effective.** The tree carries 42 pre-existing clippy
warnings that are already errors under the CI step's `-D warnings`, so that step cannot pass
today for reasons unrelated to KEY-05. The ban is correctly configured and proven to fire (see
`## Clippy dry-run evidence`), but it needs a dedicated lint-clearing pass before a new banned
RNG call stands out as the distinctive build-stopper the design intends. Out of scope here.
- **The degenerate-entropy predicate is not a health check for the kernel CSPRNG.** It rejects
three specific catastrophic shapes at the moment of a draw. It cannot detect a subtly-biased or
backdoored generator, and it is not evidence that one is absent.
@@ -0,0 +1,267 @@
# 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
`docs/UNIFIED-TASK-TRACKER.md`.
---
## 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.
+620
View File
@@ -0,0 +1,620 @@
# PSBT-First Signing Architecture
> ## ⚠️ Status update (2026-08-02): **§8 Phase 1 was superseded by deletion, not delivered**
>
> Phase 1 ("Descriptor watch-only read path", §8) planned to **rewrite**
> `handle_bitcoin_init_wallet_from_seed` so Bitcoin Core's wallet held only the xpub. That is not
> what happened. Under Phase 10 decision **D-07b**, the entire Bitcoin Core wallet path was
> **deleted**: `handle_bitcoin_init_wallet_from_seed` and its `bitcoin.init-wallet-from-seed`
> dispatch arm are gone. It had no caller, LND is the wallet the product drives, and the endpoint
> was authenticated *and* password-gated, so F-13 was key-at-rest duplication rather than an
> exposed endpoint.
>
> **Consequences for reading the rest of this document:**
>
> - **§0's "single highest-value change"** and **§2.1's invariant** now read against a code path
> that no longer exists. Their goal — the BIP-84 private key existing in exactly one place —
> is **achieved**, by removal rather than by conversion to watch-only.
> - **§1.1, §2.2, §3.1 and §7.3** describe a Core watch-only wallet and a wallet migration.
> **There is no such wallet and no migration was performed or is planned.**
> - **§3.1's key-origin requirement** still holds, but it now applies to the **PSBT** rather than
> to Archipelago-emitted descriptors, of which there are none left. `lnd.create-psbt` inspects
> and reports it (`psbt_key_origin_report`, `core/archipelago/src/api/rpc/lnd/wallet.rs`).
> - **§5 (LND) is unaffected and remains accurate**, including **§5.4's honesty table**, which is
> correct as written and unchanged.
>
> **For the current state, read `docs/security/KEY-03-SIGNING-POSTURE.md`** — it records the
> deletion with its evidence, an honest per-step coverage map of the LND PSBT round trip, and the
> verdict on whether an external signer can sign a default node's PSBT today (it cannot: no fleet
> node is provisioned watch-only). Phases 2-7 below are unaffected as design targets.
> **Status: specification.** No implementation. This document defines a target architecture and
> a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately
> contains no code, adds no dependencies, and changes no wallet or signing behaviour.
>
> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and
> seed-generation audit that motivated this spec. **Cross-linked design:**
> `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this
> architecture treats as the future *first-party* signer, not as a competing design.
**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
Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from
neither is marked `[UNVERIFIED]`.
---
## 0. Why this document exists
The 2026-07-30 Coinkite COLDCARD entropy incident swept ~1,082 BTC from ~1,195 addresses. The
Archipelago-specific reading is in the audit; the design-relevant lesson is narrower and is the
organising principle of this spec:
> **T1's survivors were the users who took the *optional* extra step.** Users who rolled dice
> contributed ≥128 bits independently of the broken RNG and were not at risk. The safe path
> existed the whole time; it just was not the default.
Everything below follows from that. The safe path (watch-only + external signer + PSBT) must be
the **default** and must feel like the normal way to use Archipelago, not an expert mode buried
behind a warning. The hot wallet is retained, deliberately, as an explicitly-secondary tier —
because a safe path users route around is not a safe path.
**Where the tree stands today (important, and not what the target says).**
`core/archipelago/src/api/rpc/bitcoin.rs:161-294` already creates a **descriptor** wallet
(`createwallet ... descriptors=true`, `:207`) — which is the right foundation — but it passes
`disable_private_keys = false` (`:203`) and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)`
(`:229-231`), i.e. **the BIP-84 account extended *private* key is imported into Bitcoin Core's
`wallet.dat`.** The node's spending key therefore lives in two places: the daemon's Argon2 +
ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`) *and* Core's wallet
database. The code is careful with the string in memory (`bitcoin.rs:189`, zeroized at `:222`
and `:284`), but the key itself is persisted by Core. Closing that gap is Phase 1 of the
rollout in §8, and it is the single highest-value change in this document.
---
## 1. Target architecture
### 1.1 Watch-only descriptor wallet on the node
The node runs a Bitcoin Core wallet that is **structurally incapable of signing**:
- Created with `createwallet` passing **`disable_private_keys = true`** and
`descriptors = true`. Note the ordering already used at
`core/archipelago/src/api/rpc/bitcoin.rs:200-208` — the second positional argument is
`disable_private_keys`, currently `false`.
- Populated with `importdescriptors`, using **public** descriptors only
(`wpkh([fingerprint/84h/0h/0h]xpub.../0/*)` and `.../1/*`).
Unsignability comes from the *absence of private key material*, not from a flag that could be
flipped. That is the correct construction and is why "watch-only" here means "descriptor wallet
with no private keys", not "a wallet we promise not to sign with".
**Descriptor-only from day one.** Bitcoin Core 30.0 removed the ability to create *or load* BDB
legacy wallets (RESEARCH §C.1). Nothing in this design may depend on a legacy wallet, on
`importmulti`, or on any of the 11 removed legacy RPCs. Archipelago is already descriptor-based
(`bitcoin.rs:207`), so this costs nothing to preserve and would be expensive to lose.
### 1.2 The loop, with the actual RPCs
| Step | RPC | Scope | Notes |
|---|---|---|---|
| 1. Construct + fund | `walletcreatefundedpsbt` | **wallet** | Runs on the watch-only wallet. Selects inputs, adds change, attaches the metadata the signer needs. |
| 2. Fill UTXO data (optional) | `utxoupdatepsbt` | node | Useful when the PSBT was built elsewhere or is missing witness UTXO data. |
| 3. Inspect | `analyzepsbt` | node | **Drive all UI state from this** — see §1.3. |
| 4. Export | — | — | Serialise to base64 / file / QR (§4). |
| 5. Sign (offline) | external signer | — | Hardware device, or `descriptorprocesspsbt` on an offline machine holding the descriptors. |
| 6. Import | — | — | Scan / upload the signed PSBT back. |
| 7. Merge signatures | `combinepsbt` | node | Multisig only: merges signatures for the **same** transaction from multiple signers. |
| 8. Merge transactions | `joinpsbts` | node | Different transactions into one. **Not** the multisig merge — a common and expensive confusion. |
| 9. Finalize | `finalizepsbt` | node | Produces the network-serialized transaction. |
| 10. Broadcast | `sendrawtransaction` | node | Except for LND channel funding — see §5. |
`walletprocesspsbt` (wallet-scoped) and `descriptorprocesspsbt` (node-scoped, takes a descriptor
list, **needs no wallet**) are the two signing entry points. `descriptorprocesspsbt` is the
right primitive for an offline signing machine that has descriptors but no wallet.
**Wallet-scoped vs node-scoped matters operationally**: wallet-scoped RPCs must be addressed to
the specific wallet endpoint (`/wallet/<name>`), node-scoped ones must not. Archipelago's
existing `bitcoin_rpc_call` helper (`core/archipelago/src/api/rpc/bitcoin.rs:191-210` usage)
will need an explicit wallet-scoping parameter rather than one global endpoint.
### 1.3 `analyzepsbt` drives the UI — do not infer state
`analyzepsbt` reports, per input, what is still missing and **which role must act next**
(updater / signer / finalizer). The UI must render from that, not from Archipelago's own guess
about how many signatures a 2-of-3 needs. Rationale: role inference is where coordinators get
multisig wrong, and the node already has an authoritative answer one RPC away. It also makes
the "what do I do now" screen correct for free in partial-signature states.
### 1.4 Versions this runs against
From the manifests, so the spec is not written against an imaginary node:
| App | Manifest version | Image |
|---|---|---|
| Bitcoin Core | `28.4.0` (`apps/bitcoin-core/manifest.yml:4`) | `bitcoin:28.4` (`:10`) |
| Bitcoin Knots | `28.1.0` (`apps/bitcoin-knots/manifest.yml:4`) | **`bitcoin-knots:latest`** (`:10`) |
| LND | `0.18.4` (`apps/lnd/manifest.yml:4`) | `lnd:v0.18.4-beta` (`:8`), requires Bitcoin `>=26.0` (`:25`) |
**Flagged, in scope to name and out of scope to fix:** `bitcoin-knots:latest`
(`apps/bitcoin-knots/manifest.yml:10`) is an **unpinned tag**, at odds with ADR-009's
pinned-tag mandate and with every other image in these three manifests. For a wallet-bearing
component, an unpinned tag means the descriptor/PSBT RPC surface underneath a user's funds can
change on a `podman pull`. Fixing it belongs to whoever owns ADR-009 enforcement.
**PSBTv2 / BIP-370** is merged into Bitcoin Core (RESEARCH §C.1). **`[UNVERIFIED]`** — which
released version first exposes it at the RPC surface, and how broadly hardware signers accept
it, was not confirmed. **Build against PSBTv1 as the interop baseline**; treat v2 as
opportunistic and never as a requirement for a user to spend their money.
---
## 2. Where each step lives
Three surfaces, one non-negotiable invariant.
### 2.1 The invariant
> **The BIP-84 private key stays in the daemon's encrypted store. Only the xpub goes into the
> Core descriptor wallet. The private key is never imported into Core.**
Today this is violated (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). The at-rest
envelope that should hold it exclusively already exists and is sound: Argon2 + ChaCha20-Poly1305
with per-blob salt and nonce from `OsRng`, written `0600`
(`core/archipelago/src/seed.rs:238-269`, `:243-246`, `:318-324`).
### 2.2 Rust orchestrator — `core/archipelago`
Owns everything that touches keys or Core:
- Derives the BIP-84 account key (`core/archipelago/src/seed.rs:207-224`, path `m/84'/0'/0'`)
and exports **only** the account-level xpub plus its key-origin fingerprint into descriptors.
- Creates and maintains the watch-only wallet (rewrite of
`handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
- Owns the PSBT lifecycle RPCs: construct, analyze, combine, finalize, broadcast.
- Owns the *internal* software-signer path used by the hot tier (§6), which decrypts the seed
under the user's password exactly as `bitcoin.rs:182-185` does today, signs, and zeroizes.
- Enforces spend limits server-side (§6). **Limits enforced in the UI are not limits.**
### 2.3 `neode-ui`
Owns presentation and transport only. It must never see a private key, an xprv, or a mnemonic
outside the onboarding flow the audit already scopes (F-04, F-08).
- Renders the PSBT review screen: inputs, outputs, fee, change, and the `analyzepsbt` "next
role" state.
- Renders the export payload as animated QR (§4) and offers file download.
- Accepts the signed PSBT by camera scan or file upload.
- Renders the cold / warm / hot tier badges (§6) and the honest Lightning copy (§5.4).
### 2.4 Companion app
Owns the air-gap camera path. It already has the two pieces this needs:
- A working QR scanner (project memory: native scan shipped in companion 0.5.22; dense-QR fix
`07772b56`).
- SeedQR encode/decode (`neode-ui/src/utils/seedqr.ts:11`), with a correct, honest note at
`:9` that the LND aezeed is **not** BIP-39 and must never be SeedQR-encoded.
The companion is the natural home for scan-heavy multi-frame PSBT transport, because the node's
own browser may be a TV kiosk with no camera.
---
## 3. Tiers
### 3.1 Tier 1 — single-sig with an external hardware signer
- Descriptor: `wpkh([<fingerprint>/84h/0h/0h]xpub.../0/*)` and `.../1/*`.
- **Key-origin annotation `[fingerprint/derivation]` is mandatory, not cosmetic.** Without it a
hardware signer cannot locate its own key in the PSBT and will refuse to sign (RESEARCH §C.2).
Every descriptor Archipelago emits must carry it. The current code emits descriptors with **no
key-origin prefix** (`core/archipelago/src/api/rpc/bitcoin.rs:230-231`) — a second concrete
reason Phase 1 must rewrite that function.
- Descriptor checksums: obtain via `getdescriptorinfo` before `importdescriptors`, as the
existing code correctly already does (`bitcoin.rs:234-259`). Core rejects a wrong checksum.
### 3.2 Tier 2 — `wsh(sortedmulti(k, ...))` multisig
- Script: `wsh(sortedmulti(k, xpub1/…, xpub2/…, xpub3/…))`.
- **Why `sortedmulti` over ordered `multi`:** `sortedmulti` (BIP-67) lexicographically sorts the
keys in the resulting script, so the wallet can be **recreated without preserving xpub order**.
With ordered `multi`, losing the order loses the wallet even though every key survives — a
recovery failure mode that is entirely avoidable. Use `sortedmulti` unless a specific
cosigner demands ordered `multi`.
- **BIP-48 derivation** for multisig accounts: `m/48'/<coin>'/<account>'/<script_type>'`, with
`2'` = P2WSH. Every coordinator (Sparrow, Nunchuk, Caravan, Specter) expects this path; using
anything else means users cannot import their Archipelago multisig anywhere else.
- Descriptor exchange: each cosigner contributes an xpub **with key origin**; the coordinator
assembles the descriptor and every participant imports the identical descriptor string. All
participants must be able to export the descriptor for backup — a multisig backup is the
descriptor plus each seed, and users who back up only seeds lose funds.
- Reference to copy rather than re-derive: Bitcoin Core's `doc/multisig-tutorial.md` and the
functional test `test/functional/wallet_multisig_descriptor_psbt.py`, which is the exact RPC
sequence in executable form (RESEARCH §C.3).
### 3.3 Taproot / MuSig2 multisig — future work, deliberately
`tr(...)` descriptors exist, but **`[UNVERIFIED]`** — the 2026 state of MuSig2 key-aggregation
support in Core's descriptor wallets and across hardware signers was not confirmed (RESEARCH
§C.3, Open Question 4). Shipping a multisig scheme whose recovery depends on unconfirmed
signer support is how users lose money years later. **Ship `wsh(sortedmulti(...))`.** Revisit
taproot multisig when Core's support and at least two independent hardware signers can be
verified against a real device.
---
## 4. Air-gapped transport
### 4.1 The format decision
| Format | Mechanism | Verdict |
|---|---|---|
| **BC-UR v2** (Blockchain Commons) | **Fountain-coded** (rateless erasure). Any sufficient subset of frames reconstructs the payload; order-independent. | **Recommended primary.** |
| **BBQr** (Coinkite) | Payload split across sequential frames; receiver accumulates and must obtain each missing frame. | Support for Coldcard interop; not the primary. |
| microSD / file (`.psbt`) | Plain file exchange. | **Mandatory fallback, always offered.** |
| SeedQR | Static QR of mnemonic word indices. | **Seed transport only, not PSBT.** Already shipped (`neode-ui/src/utils/seedqr.ts:11`). |
**Recommendation: BC-UR v2 as primary, BBQr for Coldcard interop, file always available.**
The justification is specific to Archipelago's hardware reality rather than generic. The
companion app scans QR from a phone camera, frequently at a TV or in a rack cupboard, in poor
light. BBQr's sequential model means a single missed frame stalls the user until that exact
frame comes round again — the failure mode is "keep pointing the camera and hope". BC-UR's
fountain coding means *any* sufficient number of frames reconstructs the payload, so a bad
scanning environment degrades into "takes longer" instead of "gets stuck". That difference is
what makes an air-gap workflow tolerable enough that users keep using it — which, per §0, is
the whole point.
**`[UNVERIFIED]`** — device support matrix. Confirmed from RESEARCH §C.4: Coldcard → BBQr
(native) + microSD + NFC; Foundation Passport and Keystone → UR; SeedSigner → BC-UR v2. Jade,
Krux, BitBox, Ledger and Trezor support was **not** confirmed and must be verified against real
hardware before any of them is listed as supported in the UI.
### 4.2 QR density — animated is mandatory, not a nice-to-have
A QR code maxes out around ~2,953 bytes at the largest version with the lowest error correction,
and far less at densities a phone camera can actually read across a room. **A real multi-input
multisig PSBT routinely exceeds that.** Therefore:
- **Multi-frame animated QR is mandatory.** Single-QR PSBT export must not be the only path.
- **A file fallback must always be offered**, on every export screen, with equal visual weight.
microSD/file has no density limit and is the most reliable route for large PSBTs.
- The UI must show frame progress (e.g. "142 of 210 frames received") so a stalled scan is
visibly stalled rather than mysteriously slow.
### 4.3 Consistency with the first-party signer
`docs/hardware-signer-design.md` specifies a QR-only, camera-in/screen-out air-gapped signer
(TROPIC01 + ESP32-S3), and lists "Animated/multi-part QR strategy for large PSBTs" as an open
item (`docs/hardware-signer-design.md:167`) and "Define QR payload formats for both roles" at
`:165`. **This document answers both for Bitcoin: BC-UR v2 primary, BBQr for Coldcard interop.**
That signer, when built, should implement the same format so the same node-side transport code
serves third-party signers and the first-party device identically. Its dual Nostr-signing role
(`docs/hardware-signer-design.md:110-148`) is out of scope here but shares the transport layer,
which is an argument for implementing transport as a payload-agnostic module.
---
## 5. LND — what is and is not achievable
### 5.1 Decision table
| Capability | Achievable? | Detail |
|---|---|---|
| Watch-only `lnd` + separate signer instance | **Yes** | `remotesigner.*` on the watch-only node; the signer needs no chain backend (`bitcoin.node=nochainbackend`). |
| Signer fully offline | **No** | The signer must accept a **live inbound gRPC connection**. "Offline except for one connection" is not an air-gap. |
| Air-gap channel / revocation / HTLC keys | **No** | These live in the signer and must sign **on demand, at protocol speed**. A routing node cannot tolerate human-in-the-loop signing. **This is the hard limit of the entire design.** |
| PSBT funding of channels | **Yes** | `lncli openchannel --psbt`; `PsbtShim` via `FundingStateStep`; batch by passing the returned PSBT as `base_psbt`. |
| Open a channel with zero LND wallet balance | **Yes** | The `--psbt` flow explicitly supports funding from an external wallet. |
| **Self-broadcast the funding transaction** | **NEVER** | LND must publish it "in the proper funding flow order **or the funds can be lost**". Encode as a hard UI rule — see §5.3. |
| Sign arbitrary messages / on-chain txs externally | **Yes** | `signrpc` / `walletrpc` (`signer:generate`, `onchain:write`). |
| Move private keys between instances after init | **No** | Not supported. |
| Add accounts dynamically without wallet reconstruction | **No** | Not supported. |
Source: RESEARCH §C.5, from LND `docs/remote-signing.md` and `docs/psbt.md`.
### 5.2 Required accounts and the taproot gotcha
Remote signing requires xpubs for level-3 derivation accounts: purpose **49** (NP2WKH), **84**
(P2WKH), **86** (P2TR), and **1017** accounts 0-255 (node identity, channels, watchtower,
HTLCs). Setup is `lncli wallet accounts list > accounts-signer.json` on the signer, then
`lncli createwatchonly accounts-signer.json` on the watch-only node. A minimal signer macaroon
is `lncli bakemacaroon --save_to signer.custom.macaroon message:write signer:generate
address:read onchain:write`.
**Taproot gotcha:** requires LND v0.15.3-beta+ and a manual
`lncli wallet accounts import --address_type p2tr <xpub> default` on upgrade, or the node fails
with `"account 0 not found"`. Archipelago pins LND `0.18.4` (`apps/lnd/manifest.yml:4`), so the
version floor is satisfied; the manual import step is not automatic and must be part of any
migration runbook.
Migrating an existing node is `remotesigner.migrate-wallet-to-watch-only=true`, which **purges
private key material in place** — one-way, and therefore gated behind a verified backup.
### 5.3 The self-broadcast rule is a hard UI constraint
Archipelago already exposes `lnd.create-psbt` and `lnd.finalize-psbt`
(`core/archipelago/src/api/rpc/dispatcher.rs:136-137`,
implemented in `core/archipelago/src/api/rpc/lnd/wallet.rs:605` and `:711`), and the finalize
handler already broadcasts (`core/archipelago/src/api/rpc/lnd/wallet.rs:757`). That is correct
for an **on-chain** send and **catastrophic** for a channel-funding PSBT.
**Rule:** any PSBT produced by the channel-funding flow must be tagged as such end-to-end, and
every broadcast path must refuse to broadcast a channel-funding PSBT. The refusal belongs in the
Rust orchestrator, not in the UI, and it should be a type-level distinction (a distinct
`ChannelFundingPsbt` wrapper) rather than a boolean anyone can forget to check. This is the one
place in this document where a mistake destroys funds rather than exposing them.
### 5.4 On-chain vs Lightning — two genuinely different tiers
The design splits cleanly, and the split must be visible to users:
| | **On-chain balance** | **Lightning balance** |
|---|---|---|
| Key exposure | Can be fully cold — key never on the node | **Necessarily hot** — channel/revocation/HTLC keys must sign at protocol speed |
| Protection mechanism | Watch-only descriptors + PSBT + external signer | Remote signing *relocates* keys to a hardened host; it does not remove hot exposure |
| Honest claim | "Cold storage" is accurate | "Cold storage" is **false** |
**The exact sentence the UI should use:**
> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a
> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely
> protected by an offline signer.*
**Any copy implying a routing node's channel keys are cold is misleading and must not ship.**
This is not pedantry: a user who believes their Lightning balance is cold will keep more in it
than they would otherwise, which is precisely the miscalibration that turns an incident into a
loss. The Coldcard incident is a good reason to be conservative in this copy rather than
optimistic.
---
## 6. The hot wallet as the explicitly-secondary option
The hot wallet stays. Removing it would push users to worse tools. It is framed, limited, and
labelled as secondary.
1. **Hard separation of on-chain and Lightning balances** in the data model and in the UI.
**Never one blended number.** They have different key exposure (§5.4), different recovery
stories, and different risk. A single "balance" figure silently averages a cold number with a
hot one, which is a lie of composition.
2. **Server-enforced spend limits.** Per-transaction and rolling-daily, enforced in the Rust
orchestrator. Anything above the limit is **forced onto the PSBT path** — not blocked, not
warned-and-allowed: routed. Archipelago already rate-limits financial RPCs
(`core/archipelago/src/rate_limit.rs:62-69`: `wallet.send` 5/300s, `lnd.sendcoins` 5/300s,
`lnd.openchannel` 3/300s), so the enforcement point exists; value limits are the addition.
3. **Reuse the existing at-rest envelope.** Argon2 + ChaCha20-Poly1305, per-blob salt and nonce
from `OsRng`, `0600` (`core/archipelago/src/seed.rs:238-269`, `:318-324`). Do not invent a
second envelope. See audit finding **F-05** on aligning the Argon2 parameters with ADR-005
before this tier carries meaningful value.
4. **Zeroization on every path.** The existing code is the standard to match:
`core/archipelago/src/seed.rs:262`, `:292`, `:384`, `:401`;
`core/archipelago/src/api/rpc/bitcoin.rs:222`, `:284`.
5. **Explicit tiering in the UI**, named rather than hidden:
- **Cold** — watch-only + external signer. On-chain only. The default for new wallets.
- **Warm** — hot on-chain key in the daemon's envelope, under spend limits.
- **Hot** — Lightning. Unavoidably hot; labelled as such.
### 6.1 Nudging toward PSBT without punishing the hot path
The failure mode to avoid is a safe path so tedious that users disable it, and a hot path so
nagged-at that users stop reading warnings. Concretely:
- **Default new wallets to cold.** Do not make the user opt in to safety. This is the direct
lesson of §0.
- **One-time framing, not per-transaction nagging.** Explain the tiers once, at setup, and then
show a small persistent tier badge. Repeated modal warnings train users to dismiss modals.
- **Make the limit the teacher.** When a spend exceeds the warm limit, route it to the PSBT
flow with a neutral explanation ("this amount uses your signing device") rather than an error.
The user learns the tier boundary by using it.
- **Never make the hot path feel broken.** A small Lightning payment should be one tap. If
everyday use is painful, users move their funds to software that does not have any of this.
- **Let the user raise limits, deliberately.** A limit the user cannot adjust gets worked around
entirely; a limit they must consciously raise is a decision they remember making.
---
## 7. Migration for existing users
### 7.1 What the incident does and does not imply here
**Be precise, because both errors are costly.**
- **A software fix does not repair an already-generated seed.** If a seed was produced by a
defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told
users to migrate rather than merely update.
- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`
§2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG,
that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no
present exploitability.
**Therefore: no Archipelago user needs to rotate their seed because of the COLDCARD incident.**
Do not ship a banner implying otherwise. Over-alarming has a real cost — it triggers unnecessary
fund movements, which have their own fee, privacy, and fat-finger risks, and it burns the
credibility needed for a real advisory later.
**Who this section *does* apply to:**
1. **Users whose seed was generated on a Coldcard and imported into Archipelago**, on affected
firmware. Their seed is at risk from T1, independent of Archipelago's own code quality. They
should follow Coinkite's guidance and the sequence in §7.2.
2. **Every user, at the point Phase 1 lands** — because the account xprv is currently imported
into Bitcoin Core (`core/archipelago/src/api/rpc/bitcoin.rs:229-231`, §0). Moving to
watch-only does not require a new seed; it requires re-creating the Core wallet without
private keys. That is a *wallet* migration, not a *key* migration, and it must be presented
as such — see §7.3.
### 7.2 Seed-rotation sequence (only when a seed is actually suspect)
Order matters; each step de-risks the next.
1. **Generate a new key** on trusted, fixed hardware or software.
2. **Verify the backup** — restore it into a second wallet and confirm it reproduces the same
first receive address before sending anything.
3. **Verify a receive address** on the signing device's own screen, not only on the host.
4. **Send a small test transaction** to the new wallet and confirm it arrives and is spendable.
5. **Migrate the funds** from the old wallet to the new one.
6. **Retain the old backup** until every output is confirmed spent and the new wallet's balance
is verified. Destroying the old backup early is the most common way this sequence loses money.
If Lightning is in use, closing channels is part of step 5 and is slow (force-closes carry
timelocks). Budget for it; do not present channel migration as instantaneous.
### 7.3 Wallet migration to watch-only (Phase 1) — *not* a seed rotation
For every existing user, when Phase 1 lands:
1. Confirm the encrypted seed backup exists and is decryptable
(`core/archipelago/src/seed.rs:341-357`, `seed_exists` at `:360-362`).
2. Derive the account xpub and build the key-origin-annotated descriptors.
3. Create a **new** wallet with `disable_private_keys = true` and import the public descriptors.
4. Rescan, and confirm the new watch-only wallet reports the **same balance and the same UTXO
set** as the old one. Do not proceed on any mismatch.
5. Only then unload and remove the private-key-bearing wallet from Core.
**The user's seed does not change and their funds do not move.** Say that plainly in the UI —
the natural user fear on seeing any wallet-migration prompt is that their money is being touched.
---
## 8. Phased rollout
Each phase names a goal, its dependencies, candidate requirements, and whether it needs real
hardware. This section is the input a future `/gsd-plan-phase` consumes.
### Phase 1 — Descriptor watch-only read path
**Goal:** the node's Bitcoin Core wallet holds no private keys; the daemon's encrypted store is
the only place the BIP-84 key exists.
**Dependencies:** none. **This is the highest-value change in the document and it unblocks
everything else** — no external-signer flow is meaningful while Core holds the xprv.
**Candidate requirements:**
- `createwallet` is called with `disable_private_keys = true` (currently `false`,
`core/archipelago/src/api/rpc/bitcoin.rs:203`).
- Imported descriptors carry the **xpub** and a key-origin annotation
`[fingerprint/84h/0h/0h]` (currently a bare xprv with no origin, `bitcoin.rs:229-231`).
- A migration path re-creates the wallet watch-only and verifies balance/UTXO parity before
removing the old wallet (§7.3).
- The account xprv is never written to Core and never leaves the Argon2 envelope except in
memory, zeroized.
- Regression test: the wallet cannot sign — a signing attempt against it fails structurally.
**Real hardware:** yes, for the migration — verify on a node with real UTXO history (`.228`).
### Phase 2 — PSBT construct and export
**Goal:** the node can build a funded PSBT from the watch-only wallet and hand it out.
**Dependencies:** Phase 1.
**Candidate requirements:**
- `walletcreatefundedpsbt` wired with explicit fee control, reusing the existing fee-preset UI.
- `analyzepsbt` exposed and used as the single source of UI state (§1.3).
- Export as base64 and as a `.psbt` file download.
- A PSBT review screen showing inputs, outputs, fee, change, and destination — the human check
the whole air-gap model depends on.
**Real hardware:** no (regtest/testnet sufficient).
### Phase 3 — External-signer import and finalize
**Goal:** a signed PSBT from a third-party signer completes the loop and broadcasts.
**Dependencies:** Phase 2.
**Candidate requirements:**
- Import a signed PSBT by file upload; `combinepsbt` where multiple parts arrive.
- `finalizepsbt` + `sendrawtransaction`, with the channel-funding refusal of §5.3 in place from
day one — not retrofitted.
- Clear error surfacing when `analyzepsbt` says signatures are still missing.
**Real hardware:** **yes** — must be verified end-to-end against at least one real signer
(Coldcard or Passport) before it is offered to users.
### Phase 4 — Air-gap transport (BC-UR v2 + BBQr)
**Goal:** the loop closes over QR, with a file fallback, in the companion app.
**Dependencies:** Phase 3.
**Candidate requirements:**
- BC-UR v2 encode (node) and decode (companion), fountain-coded, with visible frame progress.
- BBQr decode for Coldcard interop.
- File fallback offered with equal weight on every export and import screen (§4.2).
- Payload-agnostic transport module, so `docs/hardware-signer-design.md`'s Nostr role can reuse
it later without a rewrite.
**Real hardware:** **yes** — QR density and scan reliability cannot be evaluated in an emulator.
Verify at realistic distance and lighting, including the TV-kiosk case.
### Phase 5 — Multisig
**Goal:** `wsh(sortedmulti(k, ...))` wallets with BIP-48 paths and descriptor exchange.
**Dependencies:** Phase 4 (large multisig PSBTs are exactly the case that needs robust transport).
**Candidate requirements:**
- Create/import a `wsh(sortedmulti(...))` descriptor with per-key origin annotations.
- BIP-48 `m/48'/0'/<account>'/2'` derivation for Archipelago's own key.
- Descriptor export/backup UX that states plainly that the descriptor is part of the backup.
- `combinepsbt` across N signers with `analyzepsbt`-driven progress.
- Interop test against at least one external coordinator (Sparrow or Nunchuk).
**Real hardware:** **yes** — two independent signers minimum.
### Phase 6 — LND remote signing
**Goal:** LND runs watch-only with a separate signer instance, with honest UI copy.
**Dependencies:** Phase 1 (the on-chain story must be settled first; doing Lightning first would
teach users the wrong mental model).
**Candidate requirements:**
- Signer instance provisioning (`bitcoin.node=nochainbackend`, minimal macaroon) and watch-only
setup via `createwatchonly`.
- Explicit p2tr account import step (§5.2), or a documented failure with a fix-it action.
- `remotesigner.migrate-wallet-to-watch-only=true` migration, gated behind a verified backup —
it purges key material in place and is one-way.
- UI copy carrying the §5.4 sentence verbatim, and no copy anywhere claiming Lightning funds are
cold.
**Real hardware:** **yes** — two hosts, and a real channel.
### Phase 7 — Hot-wallet limits and tiering
**Goal:** the hot path is bounded, labelled, and routes large spends to PSBT.
**Dependencies:** Phase 3 (there must be a PSBT path to route *to*).
**Candidate requirements:**
- Server-enforced per-transaction and rolling-daily limits, with over-limit spends routed to the
PSBT flow rather than rejected (§6.1).
- On-chain and Lightning balances separated in the data model and never summed in the UI.
- Cold / warm / hot tier badges.
- New wallets default to cold.
**Real hardware:** no, beyond normal on-node verification.
### Sequencing note
Phases 1-4 are the spine and should run in order. Phase 6 (LND) and Phase 7 (limits) can run in
parallel with Phase 5 (multisig) once Phase 3 lands. Phase 1 alone materially improves the
current security posture and should not wait for the rest.
---
## 9. Related documents
- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05
(Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue.
- `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.