Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 520b93d58e
1937 changed files with 441306 additions and 0 deletions
@@ -0,0 +1,370 @@
---
phase: 10-key-material-hardening
plan: 05
subsystem: bitcoin-signing
tags: [security, key-material, psbt, lnd, bitcoin-core, F-13, KEY-03]
status: complete
requires:
- "10-CONTEXT.md D-07b (delete, do not migrate) and D-07c (deferred BDK cold vault)"
- "docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md F-13 / R-04"
provides:
- "No daemon code path writes the BIP-84 account private key into Bitcoin Core"
- "psbt_key_origin_report + the key_origin field on lnd.create-psbt"
- "docs/security/KEY-03-SIGNING-POSTURE.md — the evidence-backed signing-posture record"
affects:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
tech-stack:
added: []
patterns:
- "Best-effort inspection that degrades to null, never to an error, on a funds path"
- "Programmatically-built PSBT test fixtures instead of pasted opaque base64"
- "Tombstone comments that deliberately omit the deleted symbol name so grep-based regression checks stay durable"
key-files:
created:
- "docs/security/KEY-03-SIGNING-POSTURE.md"
modified:
- "core/archipelago/src/api/rpc/bitcoin.rs"
- "core/archipelago/src/api/rpc/dispatcher.rs"
- "core/archipelago/src/api/rpc/lnd/wallet.rs"
- "core/archipelago/src/seed.rs"
- "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
decisions:
- "F-13 closed by deleting the Core wallet path outright rather than rewriting it watch-only (D-07b)"
- "derive_bitcoin_xprv retained with #[allow(dead_code)] and a stated D-07c reason rather than deleted as cruft"
- "Verdict recorded: no fleet node is provisioned watch-only, so an external signer cannot meaningfully sign a default node's PSBT today"
- "Census conclusion scoped to examined nodes only — not generalised to the fleet while 6 nodes are unreachable"
metrics:
duration: "~3h15m (dominated by cargo target-dir contention with three concurrent agents)"
completed: 2026-08-02
tasks_completed: 3
tasks_total: 3
---
# Phase 10 Plan 05: Key-Material Hardening (KEY-03) Summary
Deleted the uncalled Bitcoin Core wallet handler that imported the BIP-84 account **xprv** into
`wallet.dat` (F-13), and made LND's PSBT round trip report the BIP-32 key-origin data an external
signer needs — with an honest, evidence-backed record of what that round trip does and does not
deliver.
**Status: 3 of 3 tasks complete.** Task 3's blocking `checkpoint:human-verify` was satisfied by
operator-run verification (the plan is `autonomous: false`; the checkpoint was not self-approved —
execution stopped, the operator ran the census, and the result was recorded).
## Commits
| # | SHA | Task | Message |
|---|---|---|---|
| 1 | `96229268` | Task 1 (tracer) | `fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)` |
| 2 | `26299874` | Task 2 | `feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)` |
| 3 | `0d513a0e` | Task 3 | `docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)` |
Not pushed, not tagged, not deployed, per the execution brief. The SUMMARY itself is deliberately
uncommitted.
## Task 1 — Core wallet path deleted
### No-caller search output (re-established, not inherited)
```
$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => {
$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/
core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed(
core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await
docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`):
docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`).
```
Exactly the expected result: one occurrence of the method name (its own dispatcher registration),
two of the symbol in code (definition + dispatch call). The three remaining symbol hits are prose
in documentation, not callers. **No third caller — the deletion's premise held**, so no checkpoint
was raised.
Also re-verified independently:
- **Across all of `neode-ui/src`, every `bitcoin.*` RPC call is read-only status**: `bitcoin.getinfo`
(14 sites), `bitcoin.prune-status` (3), `bitcoin.onion` (1). Zero `bitcoin.*` wallet operations.
- **The endpoint is absent from `UNAUTHENTICATED_METHODS`** (`middleware.rs:5-40`) and additionally
called `verify_password` (`bitcoin.rs:176-179`) — authenticated *and* password-gated, so F-13
was key-at-rest duplication, never a remotely reachable endpoint.
### What changed
- Deleted `handle_bitcoin_init_wallet_from_seed` (`bitcoin.rs:161-295`) and the
`"bitcoin.init-wallet-from-seed"` dispatch arm (`dispatcher.rs:122-124`).
- Removed the now-unused `use zeroize::Zeroize;` from `bitcoin.rs`.
- `seed::derive_bitcoin_xprv` retained with `#[allow(dead_code)]` and a doc line naming **D-07c**
as the reason (deferred BDK cold vault), so the next reader does not remove it as cruft.
- Created `docs/security/KEY-03-SIGNING-POSTURE.md`.
### Acceptance criteria
| Criterion | Result |
|---|---|
| `cargo build -p archipelago` succeeds | **PASS** (1m47s, 3 pre-existing warnings, none in this plan's files) |
| `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` → no matches | **PASS** (see deviation 1) |
| `grep -n 'init-wallet-from-seed' dispatcher.rs` → only the `lnd.` arm | **PASS**`145: "lnd.init-wallet-from-seed"` only |
| `cargo test -p archipelago seed::` still passes | **PASS** — 25 passed, 0 failed, incl. `test_bitcoin_xprv_deterministic` and `test_full_derivation_from_known_mnemonic` |
| Doc exists, cites D-07b and D-07c, carries the search output | **PASS** — 397 lines; 6× D-07b, 4× D-07c |
| `cargo clippy -p archipelago -- -D warnings` clean | **PARTIAL** — see deviation 2 |
## Task 2 — LND PSBT key-origin reporting
### What was added
`core/archipelago/src/api/rpc/lnd/wallet.rs`:
| Symbol | Line | Kind |
|---|---|---|
| `PsbtKeyOriginReport` | `:1169` | struct `{ input_count, inputs_with_key_origin, all_inputs_have_key_origin }` |
| `psbt_key_origin_report` | `:1186` | `fn(&str) -> Result<PsbtKeyOriginReport>` |
| call site + warn | `:705` | best-effort, degrades to `null` |
| response field | `:737` | `"key_origin": { … } \| null` |
An input counts as carrying key origin when either `bip32_derivation` or `tap_key_origins` is
non-empty. A zero-input PSBT reports `all_inputs_have_key_origin: false` rather than vacuous truth,
since an inputless PSBT cannot be signed and "yes, a signer has everything it needs" would be
actively misleading.
### Tests (new, 3 passing)
```
running 3 tests
test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok
test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok
test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out; finished in 0.00s
```
Fixtures are built programmatically with the `bitcoin` crate (`Psbt::from_unsigned_tx` over a
one-input `Transaction`, then a `(Fingerprint, DerivationPath)` inserted on input 0) rather than
pasted as opaque base64, so the tests explain themselves.
### Round-trip coverage map (recorded in the doc)
| # | Step | `file:line` | Tested? |
|---|---|---|---|
| 1 | Fund — `lnd.create-psbt``/v2/wallet/psbt/fund` | `lnd/wallet.rs:605`, `dispatcher.rs:136` | **No** |
| 1a | Inspect — key origin | `lnd/wallet.rs:1186`, `:1169`, `:705`, `:737` | **Yes** (3 tests) |
| 2 | Export — base64 to UI | `rpc-client.ts:407-423`, `Web5SendReceiveModals.vue:308` | **Partial** (`rpc-client.test.ts:319-323` asserts the method name only) |
| 3 | Sign offline | not in this repo | N/A |
| 4 | Import — paste signed PSBT | `Web5SendReceiveModals.vue:102`, `:419-424` | **No** |
| 5 | Finalize — `/v2/wallet/psbt/finalize` | `lnd/wallet.rs:743`, `dispatcher.rs:137` | **No** |
| 6 | Broadcast — `/v2/wallet/tx` | `lnd/wallet.rs:795` | **No** |
| — | Rate limits 5/300s | `rate_limit.rs:68-69` | **No** |
**One of six steps has automated coverage.** There is also **no air-gap transport** — no animated
QR, no `.psbt` file exchange; export/import is copy-paste of base64 in a textarea. Nothing has
been verified against real signing hardware. The doc states all of this plainly rather than
describing an untested path as verified.
### The watch-only verdict (the question that decides whether this is an air gap)
**Verdict: NO — on a default Archipelago node an externally-held signer cannot meaningfully sign
a PSBT from `lnd.create-psbt`, because LND holds the private keys for every input it selects.**
Evidence:
1. The PSBT is funded from **LND's own wallet**`/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`)
selects LND's UTXOs.
2. **LND's wallet on every node is a full key-holding wallet, created locally.**
`container::lnd::ensure_wallet_initialized` (`container/lnd.rs:86`) → `init_wallet_via_rest`
POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` (`container/lnd.rs:504-516`) and persists
the aezeed backup (`:523-525`).
3. **The generated `lnd.conf` carries no `remotesigner.*` block**`container/lnd.rs:64-79` writes
`bitcoin.node=bitcoind` plus bitcoind RPC settings and nothing else.
4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`,
`core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and
`nochainbackend` returns **zero matches**.
So what ships today is the PSBT **transport**, complete and rate-limited, **not air-gapped
custody**. The gap between here and D-08's opt-in path is **provisioning, not plumbing**
(PSBT-SIGNING-ARCHITECTURE §8 Phase 6, out of scope for Phase 10).
### Honesty statement (its own subsection in the doc)
Lightning channel, revocation and HTLC keys are **not air-gappable at all** — they must sign in
real time to answer counterparty commitments; a routing node cannot tolerate human-in-the-loop
signing. LND remote signing **relocates** them to a hardened host; it does **not** cool them. No
wording in either document implies otherwise.
### Acceptance criteria
| Criterion | Result |
|---|---|
| ≥3 new tests including the with/without pair | **PASS** — 3 passed |
| `cargo clippy -p archipelago` clean for this plan's files | **PASS** — zero diagnostics in `bitcoin.rs`, `dispatcher.rs`, `seed.rs`, `lnd/wallet.rs` |
| `git diff core/archipelago/Cargo.toml` empty | **PASS** — no dependency added |
| `grep -c 'key_origin' lnd/wallet.rs` ≥ 4 | **PASS** — 26 |
| `handle_lnd_create_raw_tx` unchanged | **PASS** — diff hunks at `+701`, `+737`, `+1161`, `+1211`; `create_raw_tx` starts at `:825` and `finalize_psbt` spans `:743-823`, so no hunk falls inside either |
| PSBT-SIGNING-ARCHITECTURE diff confined to the banner; §5.4 byte-identical | **PASS** — single hunk `@@ -2,0 +3,28 @@`; `diff` of §5.4 against HEAD reports IDENTICAL |
## Task 3 — Fleet census: **RUN 2026-08-02, no escalation**
`type="checkpoint:human-verify" gate="blocking"`, plan `autonomous: false`. Execution stopped at
the checkpoint; the operator ran the read-only procedure across the Tailscale fleet and supplied
the results, which are recorded in `docs/security/KEY-03-SIGNING-POSTURE.md` § *Fleet census*.
### Examined — 4 nodes, all CLEAR
| Node | Tailscale IP | Container | `listwalletdir` | `archipelago` wallet? | Default wallet |
|---|---|---|---|---|---|
| archi-dev-box | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | **No** | `blank: true`, keypool 0, txcount 0, balance 0 |
| shorty-s (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-beta | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
| archy-x250-pa | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | **No** | same |
`listwallets``[""]` on every node. The only named wallets are Fedimint `gatewayd-*`. The one
loaded (unnamed, default) wallet does report `private_keys_enabled: true`, but also `blank: true`,
`keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` — Core's own statement
that **no key was ever imported into it and no transaction ever touched it.**
**The result holds across two container vintages** (`bitcoin-knots` ×2, `bitcoin-core` ×2), so it
is a property of the fleet rather than four copies of one image behaving identically.
**No key material appeared in any output; `listdescriptors true` was never run.**
Supporting history: `git log -S "init-wallet-from-seed"` scoped to `dispatcher.rs` and
`neode-ui/src` returns exactly one commit — `19dcfd4f`, the commit that **added** it. No frontend
wrapper was ever written.
### Not examined — 6 nodes, recorded with reasons
| Node | Tailscale IP | Why |
|---|---|---|
| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held |
| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` |
| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` |
| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` |
| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange |
| archy-x250-dev | `100.113.100.55` | Offline — Tailscale last seen 2 days prior |
Password auth was **deliberately not attempted** on any of these: several fleet nodes lock PAM
quickly on a wrong password, and locking out an in-use production node is a worse outcome than an
incomplete census.
### Conclusion, at the strength the evidence supports
> **No examined node holds a wallet created by the deleted handler, and no examined node holds any
> wallet with keys or funds.**
Deliberately **not** generalised to "the fleet is clear" while six nodes are unknown — an
unexamined node is unknown, not safe. F-13 is closed **by deletion** regardless: the code that
could create such a wallet is gone from every future build. The census adds that no such wallet
was found anywhere anyone could look. **Nothing to escalate; the stop-on-finding rule stands** for
the remaining nodes.
### Standing item
The six unchecked nodes are homed in **`docs/UNIFIED-TASK-TRACKER.md`** as *"Finish the
Core-wallet fleet census — 6 nodes unchecked"*, not only in the security doc, so it is visible to
someone who is not already reading one. Flagged there as a natural fold-in for **KEY-04's on-node
work** (which needs node access anyway) but tracked independently so it does not vanish if KEY-04
is re-scoped. That file's stale R-04/F-13 entry — which still described the deleted handler and a
watch-only migration as pending work — was corrected to done-by-deletion in the same commit.
## Deviations from Plan
### 1. [Rule 2 — preserved a durable regression check] Tombstone comments reworded to omit the deleted symbol name
**Found during:** Task 1 acceptance verification.
**Issue:** I first wrote tombstone comments in `bitcoin.rs`, `dispatcher.rs` and `seed.rs` that
named `handle_bitcoin_init_wallet_from_seed` / `bitcoin.init-wallet-from-seed` verbatim. That
broke two acceptance criteria (`grep … → no matches`) — and, more importantly, it would have
**permanently defeated the greps as a regression check**: any future reintroduction of the symbol
would be masked by the comment that warns against reintroducing it.
**Fix:** Reworded all three to describe the deleted thing ("the Bitcoin Core wallet-init handler
that used to live here") and point at `docs/security/KEY-03-SIGNING-POSTURE.md`, which carries the
full symbol name. Guidance preserved, greps clean, regression check durable.
**Commit:** `96229268`
### 2. [Out of scope — pre-existing] `cargo clippy -- -D warnings` fails in `archipelago-openwrt`
**Found during:** Task 1 verification.
**Issue:** `cargo clippy -p archipelago -- -D warnings` fails with 4 lint errors — 2×
`consider using sort_by_key`, 1× `str::trim` before `str::split_whitespace`, 1× `creates an owned
instance just for comparison`**all in `archipelago-openwrt`**, a crate this plan does not touch.
**Assessment:** Pre-existing and out of scope under the scope boundary rule. Neither of my commits
touches that crate (`git log 96229268^..HEAD -- core/archipelago-openwrt` → 0 commits), and my
crate is not a dependency of it, so the failure is independent of this work by construction.
**Action:** Not fixed. `cargo clippy -p archipelago --message-format=short` reports **zero
diagnostics** in this plan's four files, which is the criterion that speaks to this work.
**Recommend:** a separate cleanup task for `archipelago-openwrt`'s lints so `-D warnings` can be
used as a gate again.
### 3. [Process — atomicity preserved] SUMMARY not committed, and the doc split across commits
`docs/security/KEY-03-SIGNING-POSTURE.md` is a single file carrying all three tasks' content. To
keep the commits genuinely atomic, it was staged truncated to its Task 1 sections for commit
`96229268`, restored in full for `26299874`, and extended with the census for `0d513a0e`.
`.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` is left uncommitted per the
execution brief.
### 4. [Rule 2 — corrected a record this change invalidated] Updated `docs/UNIFIED-TASK-TRACKER.md`
**Found during:** Task 3 write-up.
**Issue:** the tracker's R-04/F-13 entry still described `handle_bitcoin_init_wallet_from_seed`,
its `disable_private_keys = false` and a watch-only migration with balance/UTXO parity as pending
work — all of which now describe code that does not exist. A stale open item pointing at deleted
line numbers actively misleads the next reader.
**Fix:** marked it done-by-deletion with a pointer to `KEY-03-SIGNING-POSTURE.md`, and added the
six unchecked census nodes as a new standing item.
**Scope note:** `docs/UNIFIED-TASK-TRACKER.md` is not in the plan's `files_modified`. It was
verified clean (`git status --porcelain`) before editing, and staged by path.
**Commit:** `0d513a0e`
## Known Stubs
None. No placeholder values, mock data or unwired components were introduced.
Two *absences* are documented rather than stubbed, because they are honest statements of scope
rather than placeholders: there is no air-gap transport (QR / file exchange) and no automated
coverage for round-trip steps 1, 4, 5 and 6. Both are recorded in
`docs/security/KEY-03-SIGNING-POSTURE.md` as untested/unimplemented, and neither is presented as
working.
## Threat Flags
None. No new network endpoint, auth path, file-access pattern or schema change at a trust boundary
was introduced. The plan's threat register is addressed as follows:
| Threat | Disposition |
|---|---|
| T-10-41 (xprv in `wallet.dat`) | **Mitigated** — the only code path that wrote it is deleted; census found no pre-existing wallet on 4 examined nodes, 6 remain unknown and are tracked |
| T-10-42 (census prints a private key) | **Mitigated**`listdescriptors true` banned by name in the doc and never run; only read-only RPCs used; no key material appeared in any output. Password auth was not attempted on locked-out nodes, so the census also avoided locking a production node out |
| T-10-43 (automated migration rewrites a funded wallet) | **Mitigated** — no migration built, none run; stop-on-finding rule recorded and never triggered |
| T-10-44 (opaque signer refusal) | **Mitigated**`key_origin` on the response plus a `warn!` names the condition before the user reaches the device |
| T-10-45 (docs claim custody they don't deliver) | **Mitigated** — watch-only verdict recorded with 4 evidence points; Lightning-keys subsection added; PSBT-SIGNING-ARCHITECTURE banner records Phase 1 superseded |
| T-10-46 (inspection breaks a send) | **Mitigated** — best-effort, degrades to `null`; finalize and `create_raw_tx` untouched, asserted by diff scope |
| T-10-47 (RPC surface change) | **Accepted** — no-caller search re-run, not inherited |
| T-10-SC (dependency install) | **Accepted** — no dependency added; `Cargo.toml` diff empty |
## Notes for the next agent
- **The tree is shared with three other agents.** All staging was explicit by path;
`.planning/STATE.md` (another agent's uncommitted edit) was never staged. `cargo` runs contended
heavily (load average 25-30, one test build took 34 minutes); one intermediate test build failed
with 16 errors in `federation/*` from another agent's mid-edit state, which resolved on its own.
- **`STATE.md` / `ROADMAP.md` / `REQUIREMENTS.md` were deliberately not updated.** Another agent
holds an uncommitted edit to `STATE.md` throughout, and the execution brief scoped this run to
commits only. KEY-03's requirement should be marked complete by whoever reconciles phase state,
noting that the census's six unchecked nodes are tracked separately and are not a blocker on
KEY-03 itself (F-13 is closed by deletion, which is build-wide and does not depend on the
census).
## Self-Check: PASSED
- Files verified present: `docs/security/KEY-03-SIGNING-POSTURE.md`,
`docs/security/PSBT-SIGNING-ARCHITECTURE.md`, `docs/UNIFIED-TASK-TRACKER.md`,
`core/archipelago/src/api/rpc/lnd/wallet.rs`, this SUMMARY.
- Commits verified in git: `96229268`, `26299874`, `0d513a0e`.
- No file belonging to this plan is left uncommitted (the SUMMARY is uncommitted deliberately,
per the execution brief).