docs(10): add independent verification guide for auditors

A guide a third-party security auditor can use to verify Phase 10's claims
without trusting our test harness — and that we use ourselves.

Every claim carries four parts, all required: the claim stated falsifiably;
how to REPRODUCE THE DEFECT on the parent commit; how to verify the fix; and a
negative control that must go red on exactly that defect and nothing else. A
test passing on both fixed and unfixed code proves nothing, and reproduce-first
is the step most often omitted in security theatre.

Prefers external checks (curl from another host, tar listing, cross-node file
comparison) over our own tests wherever a claim can be checked from outside.

Tiered by hardware needed: Tier 0 any checkout, Tier 1 running node, Tier 2 ISO
build host, Tier 3 two physical nodes, Tier 4 pre-release gate. Status marked
per claim — verifiable now, pending a plan, or hardware-gated — so an unmarked
absence is never read as a pass.

States what is explicitly NOT claimed (Lightning custody is not air-gappable;
no claim against a compromised kernel CSPRNG or supply chain; KEY-05 is
structural not exploitable), the known-accepted risks with where each was
decided, and carries the C-6 warning that probing with seed.status reports the
surface closed while the real door stands open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 09:14:27 -04:00
co-authored by Claude Opus 5
parent 201ef474e7
commit ff6902dd9d
@@ -0,0 +1,267 @@
# Phase 10 — Independent Verification Guide
**Audience:** third-party security auditors, and the Archipelago team.
**Purpose:** verify the Phase 10 security claims *independently*, without trusting the
project's own test harness.
**Status:** LIVING — sections are marked ✅ verifiable now, ⏳ pending a plan still in
execution, or 🔒 hardware-gated. Do not read an unmarked absence as a passing result.
---
## 0. How to use this document
Every claim below follows the same four-part structure, and **all four parts matter**:
| Part | Why it exists |
|---|---|
| **Claim** | Stated so it can be falsified. A claim you cannot disprove is not a security claim. |
| **Reproduce the defect** | Check out the parent commit and demonstrate the bug. *A test that passes on both the fixed and unfixed code proves nothing.* |
| **Verify the fix** | Command + expected output, runnable without our harness wherever possible. |
| **Negative control** | Break the fix deliberately; confirm the check goes red on **exactly** that and nothing else. This is what separates verification from demonstration. |
**Do not skip "Reproduce the defect".** It is the only step that proves the fix addresses
something real, and it is the step most often omitted in security theatre.
### Trust posture
Where a claim can be checked from *outside* the codebase — an HTTP request from another host,
a `tar` listing, a file comparison across two machines — **prefer that over running our tests.**
Our tests are offered as convenience and as evidence of intent, not as proof. Every claim below
that can be externally checked says so explicitly.
---
## 1. Scope
### In scope — what Phase 10 claims
| ID | Claim | Severity | Status |
|---|---|---|---|
| KEY-01 | An already-provisioned node refuses every unauthenticated RPC that can mutate identity or credentials | **Critical** | ⏳ `10-01` in execution |
| KEY-02 | First-boot per-device secret generation is fail-closed, retried, self-healing, and has exactly one producer; the shipped rootfs contains no fleet-shared identity material | **High** | ✅ partially landed (`21043096`, `408b328c`), ⏳ single-producer + self-heal in progress |
| KEY-03 | The BIP-84 account private key is never imported into Bitcoin Core; the dead import path is deleted | **High** | ⏳ `10-05` in execution |
| KEY-04 | On-node evidence for C-3 / C-4 / C-6 | — | 🔒 hardware-gated |
| KEY-05 | A defaulted RNG cannot be inherited anywhere in the crate | Medium | ⏳ `10-06` not started |
### Explicitly NOT claimed
State these plainly so an auditor is not left inferring them:
- **Lightning custody is not air-gappable.** Channel, revocation and HTLC keys must sign in real
time to answer counterparty commitments. LND remote signing *relocates* those keys; it does not
make them cold. Any document implying otherwise is wrong.
- **No claim against a compromised kernel CSPRNG**, a malicious dependency in the supply chain,
memory disclosure on a running node, or physical access.
- **KEY-05 fixes a structural risk, not a live vulnerability.** `rand::random()`/`thread_rng()`
are ChaCha12 seeded from `getrandom(2)`; nothing in that finding is exploitable today. The
mitigation targets *future silent rebinding* of the entropy source.
- **Findings F-04 through F-12 are out of scope** for this phase and remain open. See
`ENTROPY-SEED-AUDIT-2026-07-31.md` remediation register (R-05..R-15) and
`docs/UNIFIED-TASK-TRACKER.md`.
---
## 2. Provenance
```bash
# The audit that motivated this phase
docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md # 103 file:line references
# The entropy fix that preceded the phase
git show 8b51b7e2 # seed.rs — explicit OsRng at the call site
# Phase 10 plans and locked decisions
.planning/phases/10-key-material-hardening/
```
`.planning/` is committed deliberately: an auditor can read *why* each decision was made,
including the ones that were reversed. `10-CONTEXT.md` records D-01..D-11 plus three
in-flight corrections (D-03a, D-07a/b/c) where our own earlier reasoning was wrong.
---
## 3. Tier 0 — verifiable on any checkout, no node required ✅
No hardware, no deploy. Start here.
### 3.1 First-boot secrets are fail-closed (KEY-02)
**Claim.** If per-device secret generation fails, the completion marker is **not** written and
the boot does not proceed as if it had succeeded.
**Reproduce the defect:**
```bash
git log --oneline -1 21043096 # the fix commit
git show 21043096^:image-recipe/_archived/build-auto-installer-iso.sh > /tmp/pre-fix.sh
grep -n 'touch .*MARKER' /tmp/pre-fix.sh
# Observe: the marker write is NOT inside the success branch — it runs regardless of outcome.
```
**Verify the fix:**
```bash
bash tests/first-boot-secrets/run-tests.sh
# Expect: passed: 3 failed: 0 (more cases once the self-heal work lands)
```
The harness extracts the heredoc body **from the builder itself**, so it exercises the bytes
that ship rather than a copy. Confirm that for yourself:
```bash
grep -n 'extracted .* lines from the builder' tests/first-boot-secrets/run-tests.sh
```
**Negative control:**
```bash
# Move `touch "$MARKER"` outside the success branch in the builder, then:
bash tests/first-boot-secrets/run-tests.sh
# Expect: FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE
# passed: 2 failed: 1 EXIT=1
git checkout image-recipe/_archived/build-auto-installer-iso.sh
```
It must fail on **that case only**. A negative control that reddens everything is measuring
nothing.
### 3.2 Master-seed entropy is explicit (F-02, shipped)
**Claim.** Mnemonic generation draws from an explicitly-passed `OsRng`, not a
transitive-dependency default, and a test proves the injected RNG is the one consumed.
```bash
git show 8b51b7e2 -- core/archipelago/src/seed.rs # ~6 lines of production change
cd core && cargo test -p archipelago seed:: # expect 25 passed; 0 failed
```
**Reproduce the defect:** on `8b51b7e2^`, `MasterSeed::generate` calls
`bip39::Mnemonic::generate(24)`, which resolves to `&mut rand::thread_rng()` *inside* the bip39
crate — there is no seam to inject through, so the proving test cannot be written at all.
**Note for auditors:** the test module implements `rand::CryptoRng` for a counter RNG. That is a
deliberately false marker-trait promise, confined to `#[cfg(test)]` (`seed.rs:502`). KEY-05
retires it. Confirm containment:
```bash
grep -n 'CountingRng' core/archipelago/src/seed.rs # all hits must be after the cfg(test) at :502
```
### 3.3 Unauthenticated method inventory (KEY-01 context)
Read the authoritative list rather than trusting prose:
```bash
sed -n '/UNAUTHENTICATED_METHODS/,/];/p' core/archipelago/src/api/rpc/middleware.rs
```
Every entry is reachable without a session, RBAC check, or CSRF token. KEY-01's claim is that
those which can mutate identity or credentials refuse once the node is provisioned.
---
## 4. Tier 1 — requires a running node ⏳
Pending `10-01` and `10-02`. `10-02` produces `scripts/security/rpc-exposure-probe.sh` and
`docs/security/KEY-01-ON-NODE-VERIFICATION.md`.
**The external check that matters most (C-6).** From a *different host* on the same network,
against a node that has completed onboarding:
```bash
curl -sS -X POST http://<node>/rpc \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"seed.restore","params":{"words":["<24 words>"]}}'
```
- **Before the fix:** the node accepts attacker-supplied words and overwrites `node_key`,
`nostr_secret` and the FIPS mesh key. This is the Critical finding.
- **After the fix:** refused, and the node's identity is byte-identical afterwards.
Verify byte-identity yourself rather than trusting a log line:
```bash
sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret
# run before and after the request; the hashes must be unchanged
```
> ⚠️ **Do not run the "before" case against a node you care about.** It really does overwrite the
> identity. Use a disposable node — see `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`
> for standing up an isolated instance without flashing an ISO.
**Do not probe with `seed.status`.** The original audit's C-6 command used it; `seed.status` is
**not** in `UNAUTHENTICATED_METHODS`, so it returns 401 by design and would report the surface
closed while the real door stands open. Probe with a method that is genuinely on the
unauthenticated list.
**Non-regression, equally important:** a *fresh, un-onboarded* node must still complete
onboarding. The gate distinguishes provisioned from fresh; a fix that refuses on a fresh node
bricks first boot fleet-wide.
---
## 5. Tier 2 — ISO build host 🔒
Full procedure: `docs/security/KEY-02-ROOTFS-EVIDENCE.md` (C-4).
```bash
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild
# then follow steps 2/4/5/6 in KEY-02-ROOTFS-EVIDENCE.md
```
**Claim.** The shipped rootfs tar contains no SSH host keys, no TLS private key, and no
machine-id — so no two nodes flashed from one image can share them.
**Gotcha, recorded because it will waste your afternoon:** read `RECIPE_HASH` from
`image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256`, **not** by hashing the
repo file. The wrapper rewrites 35 path expressions and absolutises `SCRIPT_DIR` before exec,
so the hash is host- and checkout-specific.
**Note the inverted expectation.** The original audit expected these artefacts to be *present*.
This check passes when they are *absent*.
---
## 6. Tier 3 — two physical nodes 🔒
**C-3 — host-key uniqueness.** Flash two machines from the *same* ISO, then compare:
```bash
# on each node
sha256sum /etc/ssh/ssh_host_*_key.pub
sha256sum /etc/ssl/private/<tls-key> # path per the nginx config
cat /etc/machine-id
```
Every value must differ between the two nodes. Any match is a finding.
SSH host keys and the TLS key are equally sharp signals once the single-producer work lands
(before it, TLS had an installer fallback and SSH did not — see `KEY-02-ROOTFS-EVIDENCE.md`).
---
## 7. Tier 4 — pre-release gate
```bash
# ON the node, not over RPC — it uses local podman/systemctl/bitcoin probes
ARCHY_ITERATIONS=5 bash tests/lifecycle/run-gate.sh
```
Install / UI / stop / start / restart / reinstall / reboot-survive /
archipelago-restart-survive / uninstall, 5× green. See `tests/lifecycle/TESTING.md`.
Frontend: `cd neode-ui && npm run test` (vitest) and `npm run build`.
Rust: `cd core && cargo test -p archipelago`.
---
## 8. Known-accepted risks
Recorded so an auditor does not have to discover them by reading commit messages.
| Risk | Decision | Where |
|---|---|---|
| A node whose first-boot secret generation can never succeed will not serve TLS | Accepted. Mitigated by a build-time assertion on generator binaries, retry-with-backoff, and self-heal on subsequent boots — leaving genuinely-broken hardware as the residual | `10-03` |
| Rotating host keys on already-deployed nodes invalidates `known_hosts` fleet-wide | Accepted, rated one-way, gated behind a decision checkpoint | D-06, `10-04` |
| KEY-01's fix ships on the next scheduled OTA, not an emergency release | Deliberate. The Critical finding stays live on the fleet until that OTA | D-10 |
| `#[cfg(test)]` code implements `rand::CryptoRng` falsely | Accepted until KEY-05 retires it; contained to test builds | `seed.rs:656` |
---
## 9. Reporting a finding
If any check above fails, or you find something not covered: the audit format that produced this
work is `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — evidence as `file:line`, an explicit
severity, and a stated confidence. Findings that cannot be verified without hardware belong in an
UNVERIFIED section rather than being asserted.
Two corrections in that document are worth reading as calibration, because both were ours: F-10
**understated** its scope by a factor of 20, and the correction to it then **overstated** the
severity of two files within a day. Both are struck in place rather than rewritten.