Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit 1e5d0cd313
1931 changed files with 440577 additions and 0 deletions
@@ -0,0 +1,360 @@
---
phase: 10-key-material-hardening
plan: 05
type: execute
wave: 1
depends_on: []
files_modified:
- core/archipelago/src/api/rpc/bitcoin.rs
- core/archipelago/src/api/rpc/dispatcher.rs
- core/archipelago/src/seed.rs
- core/archipelago/src/api/rpc/lnd/wallet.rs
- docs/security/KEY-03-SIGNING-POSTURE.md
- docs/security/PSBT-SIGNING-ARCHITECTURE.md
autonomous: false
requirements: [KEY-03]
must_haves:
truths:
- "No code path in the daemon writes the BIP-84 account extended private key into Bitcoin Core (F-13 closed by deletion, per D-07b)"
- "The PSBT produced by lnd.create-psbt is inspected for the BIP-32 key-origin data an external signer needs, and the result is reported to the caller instead of assumed"
- "The repository states honestly which parts of the fund -> sign-offline -> finalize -> broadcast round trip are covered by tests today and which are not"
- "No document this plan touches implies Lightning channel, revocation or HTLC keys can be air-gapped"
- "If any fleet node turns out to hold a descriptor wallet this handler created, that is surfaced and stopped on, not silently migrated (D-07b)"
artifacts:
- path: "core/archipelago/src/api/rpc/lnd/wallet.rs"
provides: "psbt_key_origin_report plus its tests, and the key-origin field on the lnd.create-psbt response"
contains: "psbt_key_origin_report"
- path: "docs/security/KEY-03-SIGNING-POSTURE.md"
provides: "The evidence-backed record of the Core deletion, the LND PSBT coverage map, and the air-gap honesty statement"
contains: "D-07b"
min_lines: 60
- path: "docs/security/PSBT-SIGNING-ARCHITECTURE.md"
provides: "Status banner recording that its Phase 1 was superseded by deletion rather than conversion"
contains: "D-07b"
key_links:
- from: "core/archipelago/src/api/rpc/lnd/wallet.rs"
to: "docs/security/KEY-03-SIGNING-POSTURE.md"
via: "the key-origin report is the mechanical form of the doc's external-signer claim"
pattern: "psbt_key_origin_report"
- from: "core/archipelago/src/api/rpc/dispatcher.rs"
to: "core/archipelago/src/api/rpc/bitcoin.rs"
via: "the bitcoin.init-wallet-from-seed dispatch arm and its handler are removed together"
pattern: "init-wallet-from-seed"
---
<objective>
Close F-13 (High) by deleting the code that duplicates the spending key, and make the signing
path that actually matters — LND's PSBT round trip — a first-class, tested, honestly documented
one (D-07b).
Purpose: `handle_bitcoin_init_wallet_from_seed` derives the BIP-84 account **xprv**, stringifies
it, and imports `wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` into Bitcoin Core's `wallet.dat`
(`core/archipelago/src/api/rpc/bitcoin.rs:188-189`, `:203`, `:229-231`, `:278-281`). It has no
caller anywhere in the repo, LND is the wallet the UI actually drives, and archi-dev-box shows
the wallet it creates has never existed there. Its entire function is to put a second copy of the
spending key somewhere with weaker protection than the Argon2 envelope. D-07b: delete it.
Output: the Core path gone, `lnd.create-psbt` reporting whether its PSBT carries the BIP-32
derivation data a hardware signer needs, and a signing-posture document that states plainly what
is and is not air-gappable.
**D-07's parity-proof migration and its one-way checkpoint are withdrawn (D-07b).** There is no
wallet to migrate. Do not plan or build migration machinery. If Task 3's census unexpectedly
finds a wallet this handler created, that is a finding to stop on — it would mean the endpoint
was invoked by hand and that node's spending key is duplicated in Core, which deserves a human
decision, not an automated rewrite.
**What deletion does to D-08 and D-09.** D-08 asked that the spending key exist in exactly one
place, with an opt-in air-gapped path; deleting the Core import achieves the first half outright
(the only remaining on-node copy of the BIP-84 key is the Argon2 envelope), and the opt-in path
is LND's existing PSBT round trip rather than a Core watch-only wallet. D-09 required a
`[fingerprint/derivation]` key origin on emitted descriptors so a hardware signer can locate its
key; with Core's descriptors deleted there are no Archipelago-emitted descriptors left to
annotate, so D-09's actual protection moves to the PSBT itself — Task 2 inspects and reports
whether the PSBT `lnd.create-psbt` returns carries the BIP-32 key-origin data a signer needs.
Neither decision is dropped; both are satisfied by a different mechanism, and the plan says so
rather than letting them lapse.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/10-key-material-hardening/10-CONTEXT.md
@docs/security/PSBT-SIGNING-ARCHITECTURE.md
@CLAUDE.md
</context>
<tasks>
<task type="tracer">
<name>Task 1: Delete the Core wallet path end to end, and record why (D-07b)</name>
<reversibility rating="reversible">Deleting an uncalled, password-gated endpoint is recoverable from git in one revert; nothing consumes its output and no on-disk state depends on it (D-07b).</reversibility>
<files>core/archipelago/src/api/rpc/bitcoin.rs, core/archipelago/src/api/rpc/dispatcher.rs, core/archipelago/src/seed.rs, docs/security/KEY-03-SIGNING-POSTURE.md</files>
<read_first>
- core/archipelago/src/api/rpc/bitcoin.rs (lines 155-300 — the whole handler being deleted, including the zeroize calls at :222 and :284 whose careful in-memory handling is worth naming in the record)
- core/archipelago/src/api/rpc/dispatcher.rs (lines 105-130 — the bitcoin.* dispatch arms, one of which is being removed)
- core/archipelago/src/seed.rs (lines 225-250 — derive_bitcoin_xprv, which loses its only non-test caller)
- docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (finding F-13 and remediation R-04 — the record this task closes)
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a and D-07b — the evidence chain, and D-07c which must stay visible)
</read_first>
<action>
Before deleting anything, re-establish the evidence yourself rather than inheriting it, and paste
the raw command output into the SUMMARY. Run a repo-wide search for the method name
`bitcoin.init-wallet-from-seed` and for the handler symbol
`handle_bitcoin_init_wallet_from_seed` across `core/`, `neode-ui/src`, `scripts/`, `web/`, `apps/`
and `tests/`, excluding `core/target`, `node_modules` and `.git`. The expected result is exactly
two occurrences of the method name (the dispatcher arm and, if present, a docs mention) and two
of the symbol (its definition and the dispatcher call). If the search finds a third caller, STOP
and raise a checkpoint — the deletion's premise is that nothing calls it.
Then:
1. Delete `handle_bitcoin_init_wallet_from_seed` from `core/archipelago/src/api/rpc/bitcoin.rs`
(the whole function including its doc comment) and remove the `"bitcoin.init-wallet-from-seed"`
arm from `core/archipelago/src/api/rpc/dispatcher.rs`. Remove any import that becomes unused
as a result — `zeroize::Zeroize` is the likely one; let the compiler tell you rather than
guessing.
2. `crate::seed::derive_bitcoin_xprv` loses its only non-test caller. Do NOT delete it: it is
covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation D-07c's deferred
BDK cold vault will need. Add `#[allow(dead_code)]` with a doc line naming D-07c as the reason
it is retained, so the next reader does not delete it as cruft. `archipelago` is a binary crate
with no `lib.rs`, so an uncalled `pub fn` does warn and `clippy -D warnings` would fail without
this.
3. Create `docs/security/KEY-03-SIGNING-POSTURE.md` with a first section
`## Bitcoin Core wallet path — deleted (D-07b)` recording: the four evidence points from D-07a
with their `file:line`; the search output from this task; that the endpoint was authenticated
AND password-gated (`bitcoin.rs:176-180`) so F-13 was never remotely reachable — key-at-rest
duplication, not an exposed endpoint; that the in-memory handling of the xprv string was
careful (zeroized on both paths) and the defect was which key went into the wallet, not how it
was held; and that F-13 is closed by removal rather than by conversion to watch-only.
Reference D-07c explicitly so the deferred cold-vault option stays visible rather than being
quietly lost with the code.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago &amp;&amp; CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo build -p archipelago` succeeds and `cargo clippy -p archipelago -- -D warnings` is clean.
- `grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ --include=*.rs` returns no matches.
- `grep -rn 'init-wallet-from-seed' core/archipelago/src/api/rpc/dispatcher.rs` returns only the `lnd.init-wallet-from-seed` arm, which is a different endpoint and stays.
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` still passes, proving `derive_bitcoin_xprv`'s coverage survived the deletion.
- `docs/security/KEY-03-SIGNING-POSTURE.md` exists, cites D-07b and D-07c by name, and contains the pasted no-caller search output.
</acceptance_criteria>
<done>Nothing in the daemon can write the BIP-84 account private key into Bitcoin Core, the derivation function survives with its tests and a stated reason, and the deletion is documented with its evidence.</done>
</task>
<task type="auto">
<name>Task 2: Make the LND PSBT path first-class — report the key-origin data an external signer needs</name>
<files>core/archipelago/src/api/rpc/lnd/wallet.rs, docs/security/KEY-03-SIGNING-POSTURE.md, docs/security/PSBT-SIGNING-ARCHITECTURE.md</files>
<read_first>
- core/archipelago/src/api/rpc/lnd/wallet.rs (lines 600-710 for handle_lnd_create_psbt; 705-790 for handle_lnd_finalize_psbt including the broadcast at the end; 790-905 for handle_lnd_create_raw_tx, the auto-signing sibling that must NOT be changed; 1128-1160 for the existing test module and the build_invoice_request_body free-function pattern to mirror)
- core/archipelago/src/rate_limit.rs (lines 68-69 — lnd.create-psbt and lnd.finalize-psbt are already limited at 5/300s)
- core/archipelago/Cargo.toml (lines 70-95 — `bitcoin = "=0.32.5"` and `base64 = "0.21"` are already present; no new dependency is permitted in this task)
- docs/security/PSBT-SIGNING-ARCHITECTURE.md (sections 1.2, 3.1 and 5 — the RPC loop, why key origin is mandatory rather than cosmetic, and the honest LND limits)
- neode-ui/src/api/rpc-client.ts (lines 410-435 — createPsbt/finalizePsbt, the client contract the new response field extends)
</read_first>
<action>
Add a pure, testable inspection function to `core/archipelago/src/api/rpc/lnd/wallet.rs`,
alongside the existing free function `build_invoice_request_body`:
`fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport>` returning a
struct with `input_count: usize`, `inputs_with_key_origin: usize` and
`all_inputs_have_key_origin: bool`. Decode with `base64` and parse with
`bitcoin::psbt::Psbt::deserialize`; count an input as carrying key origin when either its
`bip32_derivation` map or its `tap_key_origins` map is non-empty. Use only the already-present
`bitcoin` and `base64` crates — adding a dependency is out of scope for this plan and the
executor must halt rather than add one.
Wire it into `handle_lnd_create_psbt` after `funded_psbt` is extracted: compute the report
best-effort (a decode failure must degrade to `null`, never to an error — a user's send must not
fail because an inspection helper could not parse something), add a `key_origin` object to the
JSON response carrying the three fields, and `tracing::warn!` with the counts when
`all_inputs_have_key_origin` is false, because that is the exact condition under which a hardware
signer will refuse the PSBT. Do not change `handle_lnd_finalize_psbt` and do not touch
`handle_lnd_create_raw_tx` — the latter deliberately auto-signs with LND's hot keys and is a
different flow.
Add tests in the existing `mod tests`, building the fixtures programmatically with the `bitcoin`
crate rather than pasting opaque base64 so the tests explain themselves:
`psbt_without_derivations_reports_no_key_origin` (a PSBT built from an unsigned transaction with
one input, no `bip32_derivation`) and `psbt_with_derivations_reports_key_origin` (the same PSBT
with a `bip32_derivation` entry inserted on input 0). Also
`malformed_psbt_is_an_error_not_a_panic` for a non-base64 and a truncated input.
Then answer, with evidence, the question the report exists to raise, and put the answer in
`docs/security/KEY-03-SIGNING-POSTURE.md` under `## LND PSBT round trip — what is covered`:
- Which steps of fund -> export -> sign offline -> import -> finalize -> broadcast exist in this
repo today, with `file:line` for each (`lnd.create-psbt` at `lnd/wallet.rs:605`,
`lnd.finalize-psbt` at `:711` including its broadcast to `/v2/wallet/tx`, the client bindings at
`neode-ui/src/api/rpc-client.ts:410-435`, the 5/300s limits at `rate_limit.rs:68-69`).
- Which of those steps has automated test coverage after this task and which does not. State the
uncovered ones plainly; do not describe an untested path as verified.
- **The question that decides whether this is a real air gap:** on a default node, LND holds the
keys for the inputs `lnd.create-psbt` selects. Determine and record whether an externally-held
signer can sign such a PSBT at all without LND first being provisioned watch-only against that
signer (`remotesigner.*` / `createwatchonly`, PSBT-SIGNING-ARCHITECTURE §5.1-5.2), and whether
any fleet node is so provisioned today. Record the verdict either way with its evidence. The
PSBT transport being present is not the same claim as custody being air-gapped, and this
document must not let the two blur.
- The standing honesty statement, in its own subsection: Lightning channel, revocation and HTLC
keys are **not** air-gappable at all — they must sign in real time to answer counterparty
commitments; remote signing relocates them to a hardened host, it does not cool them. No
wording anywhere in the document may imply otherwise.
Finally, add a short status banner at the top of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`
recording that its Phase 1 ("Descriptor watch-only read path", §8) was **superseded by D-07b**:
the Core wallet path was deleted rather than converted, so §0's "single highest-value change" and
§2.1's invariant now read against a code path that no longer exists. Point the reader at
`docs/security/KEY-03-SIGNING-POSTURE.md` for the current state. Change nothing else in that
document — §5.4's honesty table is correct and stays exactly as written.
</action>
<verify>
<automated>cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago psbt -- --nocapture</automated>
</verify>
<acceptance_criteria>
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo test -p archipelago psbt` passes with at least three new tests, including the with-derivations and without-derivations pair.
- `cd core &amp;&amp; CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` is clean.
- `git diff core/archipelago/Cargo.toml` is empty — no dependency was added.
- `grep -c 'key_origin' core/archipelago/src/api/rpc/lnd/wallet.rs` is at least 4 (struct, function, response field, warn line).
- `handle_lnd_create_raw_tx` is unchanged: `git diff` shows no hunk inside it.
- `docs/security/KEY-03-SIGNING-POSTURE.md` contains a per-step coverage table with `file:line` and an explicit tested/untested column, the recorded watch-only verdict, and the Lightning-keys-are-not-air-gappable subsection.
- `docs/security/PSBT-SIGNING-ARCHITECTURE.md`'s diff is confined to the added status banner; section 5.4 is byte-identical.
</acceptance_criteria>
<done>`lnd.create-psbt` reports whether its PSBT carries the BIP-32 key-origin data an external signer needs, backed by tests, and the repository states honestly what the round trip does and does not deliver today.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Fleet census — does any node hold a descriptor wallet this handler created? (D-07b, stop on finding)</name>
<precondition>At least one fleet node is reachable with a running Bitcoin Core or Knots container whose `bitcoin-cli` can be invoked (for example via `podman exec`).</precondition>
<files>docs/security/KEY-03-SIGNING-POSTURE.md</files>
<read_first>
- .planning/phases/10-key-material-hardening/10-CONTEXT.md (D-07a point 3 — archi-dev-box is the only known-negative node; the rest of the fleet is UNVERIFIED — and D-07b's stop-on-finding rule)
- docs/security/KEY-03-SIGNING-POSTURE.md (Task 1 output — append the census to it)
- core/archipelago/src/api/rpc/bitcoin.rs (as it stood before Task 1 — the default wallet_name was `archipelago`, which is what the census looks for)
- CLAUDE.md (node access policy; `.228` is remote and in real use)
</read_first>
<action>
Claude prepares the command set; the operator runs it per node and pastes the output; Claude
records it into `docs/security/KEY-03-SIGNING-POSTURE.md` under `## Fleet census — Core descriptor
wallets`, one row per node, using node labels rather than addresses.
**Hard constraint on every command in this task: never run `listdescriptors true`.** The `true`
argument makes Core return the descriptors including private keys, which would print an xprv to a
terminal and into a transcript. `listwallets`, `getwalletinfo` and `listdescriptors` without
arguments answer the question completely. If any output unexpectedly contains a string starting
with `xprv`, stop immediately, do not paste it, and report only that it occurred.
If a wallet with `private_keys_enabled: true` is found on any node, that is a **finding**: stop,
record it, and raise it as a blocker. It would mean the endpoint was invoked manually before this
plan deleted it, and that node's spending key is duplicated outside the Argon2 envelope. Do not
migrate, unload or modify it — D-07b withdrew the migration deliberately, and rewriting a wallet
that might hold funds is exactly the kind of decision that belongs to a human.
</action>
<what-built>Deletion of the code path that would create such a wallet, plus the signing-posture record this census completes.</what-built>
<how-to-verify>
On each reachable fleet node, for the Bitcoin Core (or Knots) container:
1. `ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&amp;1` — an absent directory is itself a complete answer for that node; paste it as-is.
2. `bitcoin-cli listwallets` (via `podman exec` into the container, matching however that node runs it).
3. For each wallet name returned: `bitcoin-cli -rpcwallet=<name> getwalletinfo` and record `private_keys_enabled`, `descriptors`, `walletname` and `balance`.
4. For any wallet with `private_keys_enabled: true`: `bitcoin-cli -rpcwallet=<name> listdescriptors` — with NO second argument. Record only the descriptor prefixes (`wpkh(...`), never a full key string.
5. Record the node label, the Bitcoin app in use (Core or Knots) and its version from `bitcoin-cli getnetworkinfo | head`.
6. Paste the output for every node checked, and list explicitly any fleet node that was NOT checked and why.
</how-to-verify>
<acceptance_criteria>
- Every reachable fleet node has a row in the census table with `wallets_dir_present`, `listwallets` output and, per wallet, `private_keys_enabled`. Unreachable nodes are listed as UNCHECKED with the reason, never omitted.
- No command run in this task included `listdescriptors true`, and no output containing a key string was pasted. The operator confirms this explicitly.
- If any wallet reported `private_keys_enabled: true`, the phase raises a blocker naming the node label and the wallet name, and the plan does NOT proceed to close KEY-03 until that is decided by a human.
- If no such wallet is found, `docs/security/KEY-03-SIGNING-POSTURE.md` records F-13 as closed by deletion with no migration required, and names the nodes that evidence it.
</acceptance_criteria>
<resume-signal>Paste the per-node census output, then type "approved" — or name the node and wallet if a private-key-bearing wallet was found.</resume-signal>
<done>The fleet's Core wallet state is recorded rather than assumed, and any private-key-bearing wallet is surfaced as a blocker instead of being silently migrated.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Daemon encrypted envelope -> Bitcoin Core `wallet.dat` | The boundary F-13 crosses. `wallet.dat` lives in the Bitcoin container's data volume with no Argon2 passphrase (the wallet was created with an empty one, `bitcoin.rs:205`). Task 1 removes the only code that crosses it. |
| Daemon -> LND REST (`/v2/wallet/psbt/*`) | Macaroon-authenticated, loopback. The PSBT that crosses it is public data; the keys that sign it are LND's. |
| Node -> external signer (offline) | The air-gap boundary. Whether it can be crossed at all depends on which party holds the input keys — the question Task 2 must answer with evidence. |
| Operator terminal -> census output | `listdescriptors true` would print an xprv into a transcript; the census must not create the exposure it is measuring. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-10-41 | Information disclosure | BIP-84 account xprv persisted in Core's `wallet.dat` outside the Argon2 envelope (F-13) | high | mitigate | Task 1 deletes the only code path that writes it; Task 3 confirms no node already holds such a wallet |
| T-10-42 | Information disclosure | A census command prints a private key into a terminal and a transcript | high | mitigate | `listdescriptors true` is banned by name in the task; the acceptance criterion requires the operator to confirm it was not run; a key-looking string aborts the paste |
| T-10-43 | Tampering | An automated migration rewrites a wallet that may hold real funds | high | mitigate | D-07b withdrew the migration; Task 3 stops on a finding and escalates to a human instead |
| T-10-44 | Spoofing | An external signer refuses a PSBT because it carries no key origin, and the failure surfaces as an opaque error | medium | mitigate | Task 2 inspects the PSBT and reports `all_inputs_have_key_origin` on the response plus a warn log, so the condition is named before the user reaches the signer |
| T-10-45 | Repudiation | Documentation claims air-gapped custody the implementation does not deliver | high | mitigate | Task 2 requires a recorded, evidence-backed verdict on whether an external signer can sign a default node's PSBT at all, plus the Lightning-keys honesty subsection; the PSBT-SIGNING-ARCHITECTURE banner records that its Phase 1 was superseded rather than delivered |
| T-10-46 | Denial of service | The new PSBT inspection breaks a user's on-chain send | medium | mitigate | The report is computed best-effort and degrades to `null`; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` are untouched, asserted by a diff-scoped acceptance criterion |
| T-10-47 | Elevation of privilege | Deleting a dispatcher arm changes the reachable RPC surface unexpectedly | low | accept | The removed method was authenticated and additionally password-gated and had no caller; the no-caller search is re-run as an acceptance criterion rather than inherited |
| T-10-SC | Tampering | npm/pip/cargo installs | low | accept | No dependency is added; an empty `git diff core/archipelago/Cargo.toml` is an acceptance criterion. Executor MUST halt and raise a checkpoint rather than adding a crate to satisfy the PSBT parsing. |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this plan produces
**Removed:**
| Symbol | Kind | Location |
|---|---|---|
| `handle_bitcoin_init_wallet_from_seed` | async fn | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` |
| `"bitcoin.init-wallet-from-seed"` | dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` |
**New in `core/archipelago/src/api/rpc/lnd/wallet.rs`:**
| Symbol | Kind | Signature |
|---|---|---|
| `PsbtKeyOriginReport` | struct | `{ input_count: usize, inputs_with_key_origin: usize, all_inputs_have_key_origin: bool }` |
| `psbt_key_origin_report` | fn | `fn psbt_key_origin_report(psbt_base64: &str) -> anyhow::Result<PsbtKeyOriginReport>` |
**Changed RPC response contract:** `lnd.create-psbt` gains
`key_origin: { input_count, inputs_with_key_origin, all_inputs_have_key_origin } | null`.
Existing fields (`psbt_base64`, `change_output_index`, `total_amount_sats`,
`fee_rate_sat_per_vbyte`) are unchanged; the field is additive.
**Changed attribute:** `crate::seed::derive_bitcoin_xprv` gains `#[allow(dead_code)]` with a
doc line naming D-07c as the reason it is retained.
**New file:** `docs/security/KEY-03-SIGNING-POSTURE.md` — headings
`## Bitcoin Core wallet path — deleted (D-07b)`, `## LND PSBT round trip — what is covered`,
`## Fleet census — Core descriptor wallets`.
**Modified:** `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — status banner only.
</artifacts_this_phase_produces>
<verification>
- `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago` — no new failures vs. the recorded baseline.
- `cd core && CARGO_INCREMENTAL=0 cargo clippy -p archipelago -- -D warnings` — clean.
- `git diff core/archipelago/Cargo.toml` is empty.
- Task 3's checkpoint resolved with per-node census output.
- Commit stages only this plan's six paths explicitly — never `git add -A`.
</verification>
<success_criteria>
- No code path in the daemon writes the BIP-84 account private key into Bitcoin Core.
- `derive_bitcoin_xprv` survives with its tests and a written reason (D-07c).
- `lnd.create-psbt` reports key-origin presence, backed by programmatically-built test fixtures.
- The signing-posture document states, with `file:line`, which round-trip steps exist, which are
tested, whether an external signer can sign a default node's PSBT at all, and that Lightning
channel/revocation/HTLC keys are not air-gappable.
- The fleet census is recorded per node, with any private-key-bearing wallet raised as a blocker.
</success_criteria>
<output>
Create `.planning/phases/10-key-material-hardening/10-05-SUMMARY.md` when done, carrying the
no-caller search output, the new test names and results, the round-trip coverage table, the
watch-only verdict, and the per-node census.
</output>