Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# 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, a test node, while verifying `a05956c4` instead of assuming it.
|
||||
**Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC
|
||||
through a proxy that injects the node's own credentials.
|
||||
|
||||
## 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.0.2.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** |
|
||||
| `POST http://192.0.2.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** |
|
||||
| `OPTIONS http://192.0.2.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 a test node — 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.0.2.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 a test node, 2026-08-02 20:00–20: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.0.2.240:8334` |
|
||||
| 6 | Compare state | Conf **byte-identical** to the pre-test known-good; container healthy |
|
||||
|
||||
Step 2 is what makes steps 4–6 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. 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.
|
||||
|
||||
**Amended 2026-08-08.** This section said the script "has never rotated anything on any
|
||||
node"; that is no longer true. A rotation was performed on a development node while
|
||||
responding to the BTCPay Server advisory (that node had been running an affected
|
||||
`btcpayserver:2.3.9`), and it exposed a gap the script did not cover: BTCPay's inline copy
|
||||
of the macaroon was left stranded, so its Lightning payments failed silently while both
|
||||
apps reported healthy. Rotation is now a first-class, password-confirmed dashboard action
|
||||
that repairs that copy as part of the run — see
|
||||
[`LND-MACAROON-ROTATION.md`](LND-MACAROON-ROTATION.md). The fleet decision recorded above
|
||||
is unchanged: no fleet-wide rotation for this leak.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,685 @@
|
||||
# KEY-05 — Entropy enforcement: per-site classification and mechanism record
|
||||
|
||||
**Requirement:** ROADMAP `KEY-05`.
|
||||
**Supersedes:** backlog `R-13`. **Absorbs:** `R-05` (duplicate-`rand` visibility) and `R-09`
|
||||
(CSPRNG-readiness record). **Resolves:** `F-10a` from the internal entropy and
|
||||
seed-generation audit, which recorded raw match counts and **deliberately declined
|
||||
to classify them**.
|
||||
|
||||
**Tree state this document was derived against:** `HEAD = c5a82cba` (2026-08-02).
|
||||
|
||||
**Update:** every `migrate` disposition in the table below has since been applied.
|
||||
No `rand::random()` / `rand::thread_rng()` call remains in production `archipelago`
|
||||
code — each draws through `entropy::draw_key_bytes` from a named `OsRng`, and
|
||||
`core/clippy.toml` now bans both APIs, so a regression fails the build.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
The Phase 10 hardening work lists **F-07 / R-05**
|
||||
and **F-10 / R-13** under `## Deferred Ideas`. KEY-05 was added to the ROADMAP on
|
||||
**2026-08-02**, after that context was gathered, and explicitly absorbs R-05 and supersedes
|
||||
R-13. The ROADMAP requirement is the later and governing artifact.
|
||||
|
||||
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 internal audit has since been corrected; 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 `n−1` must match:
|
||||
`256^−(n−1) = 2^−8(n−1)`. This already includes `AllZero` as a subset.
|
||||
- `P(Counter)` — the first byte is free, the remaining `n−1` are then determined; ascending
|
||||
and descending are disjoint for `n ≥ 2` (they would require `+1 ≡ −1 (mod 256)`):
|
||||
`2 · 2^−8(n−1)`.
|
||||
- Union bound: `P(degenerate) ≤ 3 · 2^−8(n−1)`.
|
||||
|
||||
| `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 12–32 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,191 @@
|
||||
# Rotating this node's Lightning credentials
|
||||
|
||||
A Lightning macaroon is a **bearer token**: whoever holds one can spend from the
|
||||
node's wallet. There is no revocation list and no expiry. If a macaroon is ever
|
||||
read by something you do not control — a leaked endpoint, a screenshot, a phone
|
||||
that has since been lost, an app that ran a version with a published
|
||||
vulnerability — that ability persists until the macaroons are rotated.
|
||||
|
||||
Rotation is therefore a **routine operator action**, not an emergency procedure.
|
||||
Two paths do the same work:
|
||||
|
||||
| Path | Use when |
|
||||
|---|---|
|
||||
| **Dashboard** — Settings → *Lightning credentials* | Normal case. Password-confirmed, shows progress, repairs BTCPay for you. |
|
||||
| **`scripts/security/rotate-lnd-macaroon.sh`** | No dashboard reachable, or you want a detect-only report. |
|
||||
|
||||
## What rotation actually does
|
||||
|
||||
LND derives every macaroon it issues from a root key in `macaroons.db`. Remove
|
||||
that root key plus the issued `*.macaroon` files, restart, and LND mints a fresh
|
||||
root key and a fresh set of macaroons when the wallet unlocks. Every macaroon
|
||||
issued before that moment — including any an attacker holds — stops verifying.
|
||||
|
||||
## Why your funds and channels survive
|
||||
|
||||
Macaroons are bearer tokens, not keys. Coins live in `wallet.db` and channel
|
||||
state in `channel.db`; channels are secured by the node's identity and channel
|
||||
keys, none of which are derived from the macaroon root key. Neither database is
|
||||
opened, moved or deleted.
|
||||
|
||||
Both paths **prove** this rather than asserting it: they record the node's
|
||||
identity pubkey and its channel census before rotating, and refuse to report
|
||||
success if either differs afterwards.
|
||||
|
||||
Two details in that check are deliberate and should not be "tightened":
|
||||
|
||||
- **Channels are compared as a total, not as `num_active_channels`.** The active
|
||||
count only counts channels whose peer is currently online, so it legitimately
|
||||
dips for minutes after *any* restart while peers reconnect. Asserting on it
|
||||
alone would abort a perfectly healthy rotation.
|
||||
- **`wallet.db` is not compared byte-for-byte.** btcwallet records chain-sync
|
||||
progress inside it, so the file changes on every start. Asserting byte-identity
|
||||
would fire a frightening false alarm on a completely healthy rotation.
|
||||
|
||||
## What it never does
|
||||
|
||||
- No macaroon **content** reaches a response, an error, a log line, or the
|
||||
progress feed the dashboard polls. Everything reported is a SHA-256 digest or a
|
||||
byte count — enough to prove the material changed without disclosing it to
|
||||
whoever is reading the screen.
|
||||
- No path from "rotate my credentials" to "delete my wallet". LND's boot path
|
||||
self-heals a wallet no candidate password can open by wiping and recreating it;
|
||||
correct for an unattended boot, catastrophic here. Rotation unlocks through
|
||||
`container::lnd::unlock_existing_wallet_no_wipe`, so a wallet whose password
|
||||
this node does not hold surfaces as a **failed rotation** with the wallet
|
||||
intact.
|
||||
|
||||
## Nothing else may touch LND mid-rotation
|
||||
|
||||
Between "stop LND" and "start LND" the rotation owns a stopped container whose
|
||||
credential material is being deleted. Two background actors would step in there
|
||||
unasked: the **health monitor** restarts any container it finds stopped, and the
|
||||
**reconciler** starts one whose unit is enabled. Either brings LND back up
|
||||
mid-deletion — and LND re-mints `macaroons.db` on unlock, so the deletion loop
|
||||
would race a live process writing that file, or "succeed" against material that
|
||||
had already been regenerated. The operator would be told they had rotated while
|
||||
the old root key was still in service.
|
||||
|
||||
The rotation therefore holds `app_ops::op_lock("lnd")` for its whole duration.
|
||||
That is the lock both actors already consult (`lifecycle_op_in_flight`, reached
|
||||
in the health monitor via `lifecycle_op_covers_container`), and it also
|
||||
serialises against the `package.start`/`stop`/`restart` workers, so an operator
|
||||
hitting "Restart" on Lightning mid-rotation queues rather than interleaving. A
|
||||
rotation requested while one of those is running fails fast with a short
|
||||
explanation instead of waiting silently.
|
||||
|
||||
Deliberately **not** the `user-stopped` marker that `recreate_wallet_destructively`
|
||||
uses for its own window: that marker is a file on disk, so a rotation that died
|
||||
between marking and clearing would leave Lightning suppressed *permanently* —
|
||||
fixable only by finding and editing JSON on the node. The lock guard releases
|
||||
when it drops, on every path including a panic.
|
||||
|
||||
## The BTCPay coupling — the part that bites
|
||||
|
||||
**BTCPay Server keeps its own inline copy of the admin macaroon**, and it cannot
|
||||
self-heal. LND's data directory is owned by its container's mapped uid, so BTCPay
|
||||
cannot bind-mount the macaroon file (EACCES across the userns boundary). The
|
||||
connection string therefore carries the macaroon as hex:
|
||||
|
||||
```
|
||||
type=lnd-rest;server=https://lnd:8080/;macaroon=<hex>;certthumbprint=<hex>
|
||||
```
|
||||
|
||||
delivered as the `btcpay-lnd-connection` secret file. Rotate the macaroons and
|
||||
that copy becomes a dead credential. Nothing notices on its own, because the
|
||||
daemon only regenerates this secret when LND's **TLS cert thumbprint** changes —
|
||||
and macaroon rotation does not touch the cert.
|
||||
|
||||
The resulting state is the dangerous one: **BTCPay is up, LND is up, both report
|
||||
healthy, and every Lightning invoice BTCPay tries to create fails.**
|
||||
|
||||
Repair needs two things, and one without the other is cosmetic:
|
||||
|
||||
1. **Rewrite the secret** (`container::lnd::rewrite_btcpay_lnd_connection_secret`).
|
||||
This is what makes the change visible: `secret_env_hash` is derived from the
|
||||
resolved secret contents, so a changed file reads as label drift on the
|
||||
running container.
|
||||
2. **Recreate the container.** `btcpay-server` is on the restart-sensitive list,
|
||||
and the reconcile loop runs in `ExistingOnly` mode *always* — boot and
|
||||
periodic alike — where env drift on a restart-sensitive app is detected and
|
||||
then deliberately skipped. Rewriting the secret alone therefore changes
|
||||
nothing that is running. Observed directly on a development node, once per
|
||||
tick, for half an hour:
|
||||
|
||||
```
|
||||
container drift detected during boot reconcile; leaving running
|
||||
restart-sensitive app untouched app_id=btcpay-server
|
||||
```
|
||||
|
||||
The dashboard path calls
|
||||
`ContainerOrchestrator::mark_credential_rotated("btcpay-server")`, which is
|
||||
the flag the drift check consults to override restart-sensitivity. It is the
|
||||
same carve-out FED-07 added for the Fedimint gateway, and the reasoning is
|
||||
identical: restart sensitivity protects apps that are *working*, and this one
|
||||
is working only in appearance.
|
||||
|
||||
**The shell script cannot set that in-process flag**, so it does the equivalent
|
||||
from outside: it deletes the secret (the daemon regenerates it within a tick),
|
||||
then removes the `btcpay-server` container so the orchestrator's own
|
||||
desired-state recovery rebuilds it around unchanged data. That recovery is what
|
||||
makes this safe rather than a hand-rolled remove-and-run — it fires because the
|
||||
app is still installed and was in the last running-containers snapshot. The
|
||||
script then prints the commands to confirm it actually happened, because a
|
||||
failure here is invisible.
|
||||
|
||||
## Slow nodes: the unlock budget
|
||||
|
||||
LND opens `channel.db`, `graph.db` and `wallet.db` before it serves the unlocker
|
||||
at all, and on a busy node that is genuinely slow — **2m38s measured on a box
|
||||
running 30 containers**. The unlock helper used to give up after ~60s, which on
|
||||
such a node could never succeed.
|
||||
|
||||
That timeout was not a harmless retry. Reconcile records the post-start hook as
|
||||
failed, restarts LND, and the slow database open starts over: a restart loop that
|
||||
leaves the wallet permanently locked and every LND-dependent app (BTCPay's
|
||||
internal node included) broken, on exactly the nodes least able to afford it.
|
||||
|
||||
The not-ready budget is now ~10 minutes (`UNLOCK_NOT_READY_ATTEMPTS`). Waiting
|
||||
longer costs nothing, because a genuinely wrong password still exits on the first
|
||||
pass through the candidate list — the `all_rejected` fast path is untouched.
|
||||
|
||||
## Verifying a rotation
|
||||
|
||||
The dashboard shows all of this. From a shell:
|
||||
|
||||
```bash
|
||||
# 1. Fingerprint changed (digest only — never print the macaroon)
|
||||
sudo sha256sum /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon
|
||||
|
||||
# 2. Same node, same channels
|
||||
podman exec lnd lncli --network=mainnet getinfo \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["identity_pubkey"], \
|
||||
d["num_active_channels"] + d["num_inactive_channels"], d["num_pending_channels"])'
|
||||
|
||||
# 3. BTCPay is carrying the CURRENT macaroon, not the rotated-out one
|
||||
CUR=$(sudo od -An -v -tx1 /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon | tr -d ' \n')
|
||||
SEC=$(sudo sed -n 's/.*macaroon=\([0-9a-f]*\).*/\1/p' /var/lib/archipelago/secrets/btcpay-lnd-connection)
|
||||
[ "$CUR" = "$SEC" ] && echo "current" || echo "STALE — BTCPay's Lightning is broken"
|
||||
|
||||
# 4. BTCPay was actually recreated (a silent failure looks like success)
|
||||
podman inspect btcpay-server --format '{{.Created}}'
|
||||
```
|
||||
|
||||
Check 3 is the one people skip, and it is the one that fails.
|
||||
|
||||
## Afterwards
|
||||
|
||||
- **Re-pair every wallet app**, Zeus most importantly. Open the Lightning app in
|
||||
the dashboard and scan the pairing QR again; it serves the new macaroon.
|
||||
- **Delete the backup once re-pairing is done.** Both paths back the old material
|
||||
up to `/var/lib/archipelago/lnd/macaroon-rotation-<stamp>` (0700) so a mistake
|
||||
is recoverable. That directory holds the **old root key** and is still
|
||||
sensitive: `sudo rm -rf <path>`.
|
||||
|
||||
## Related
|
||||
|
||||
- `docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md` — the leak that first made
|
||||
rotation necessary, and the operator decision not to rotate the fleet for it.
|
||||
- `scripts/security/rotate-lnd-macaroon.sh` — the shell path, including its
|
||||
ordering guard (it refuses to rotate on a binary that still leaks
|
||||
`/lnd-connect-info`, since the new macaroon would leak within seconds).
|
||||
@@ -0,0 +1,618 @@
|
||||
# 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.
|
||||
>
|
||||
> **Note:** the current signing-posture record is maintained internally. 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:** the internal 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
|
||||
(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.** The internal entropy audit's
|
||||
§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
|
||||
|
||||
- The internal entropy and seed-generation 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.
|
||||
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
|
||||
format comparison.
|
||||
Reference in New Issue
Block a user