security: remove all infrastructure and internal process material from the repo
Demo images / Build & push demo images (push) Failing after 2m13s
Demo images / Build & push demo images (push) Failing after 2m13s
The repo is source code and guidelines only. Nothing about how Archipelago's own fleet is run, or how the team works, stays in it. Untracked (kept on disk, gitignored) — 250 files: - .planning/ (199) and loop/ — internal development process - fleet operations tooling that targets specific nodes: deploy-to-target, deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server, setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair, image-recipe/sync-from-live.sh - image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of which are live-server workflow and fleet node inventories - the Phase 10 on-node verification and evidence records, which cite .planning/ as their evidence base KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10 docs and then put back: it is cited as normative rationale from ten places in the codebase, including core/clippy.toml, which bans rand::thread_rng and points at it for the reason. That makes it a guideline, not an internal record. Node names removed from source (48 occurrences across comments, manifests and test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt, zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the date, which is what carried the meaning; the machine name did not. Three of those were live test values rather than comments and were replaced with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name. An earlier pass substituted "a test node" into a hostname assertion, producing an invalid hostname; caught and fixed as test-node.local. Wipe mechanism: .local-only/manifest.txt inventories every local-only path and .local-only/wipe.sh deletes them on one confirmation, refusing to touch anything git still tracks. Both are themselves untracked, so the public repo does not carry a map of internal filenames. Verified: cargo check -p archipelago --all-features clean; archipelago-container 75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link in tracked markdown resolves (0 broken). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc00884b98
commit
6ba0599639
@@ -1,7 +1,7 @@
|
||||
# The Bitcoin RPC proxy that stayed open after it was fixed
|
||||
|
||||
**Status:** code fix committed (`f6b5245b`); on-node verification recorded below.
|
||||
**Found:** 2026-08-02, archi-dev-box, while verifying `a05956c4` instead of assuming it.
|
||||
**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.
|
||||
|
||||
@@ -79,7 +79,7 @@ Deliberately narrow:
|
||||
survived. A new regression test pins the whole chain: stale conf in, gate present out,
|
||||
container restarted, nothing created.
|
||||
|
||||
## What actually closed it on archi-dev-box — and what that does NOT prove
|
||||
## 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:
|
||||
|
||||
@@ -104,7 +104,7 @@ Tracked as broken window 15 — **since closed by the controlled test below.**
|
||||
|
||||
## Proving the delivery path on real hardware
|
||||
|
||||
Run on archi-dev-box, 2026-08-02 20:00–20:03 EDT, with operator approval. The point was to
|
||||
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.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,224 +0,0 @@
|
||||
# KEY-01 on-node verification — audit item C-6 and the F-01 refusal proof
|
||||
|
||||
**Status: INCOMPLETE — C-6 is NOT yet verified.**
|
||||
**Opened:** 2026-08-02 · **Phase:** 10 (key-material hardening) · **Plan:** 10-02
|
||||
**Probe:** `scripts/security/rpc-exposure-probe.sh`
|
||||
|
||||
This document records on-node evidence for
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §6 item **C-6** ("Is the RPC endpoint
|
||||
reachable unauthenticated from the LAN?") and for the KEY-01 / F-01 refusal shipped by
|
||||
plan 10-01 (`core/archipelago/src/api/rpc/onboarding_gate.rs`, commit `879de59e`).
|
||||
|
||||
Nothing below is recorded unless it was actually executed and its output observed. Rows
|
||||
marked **NOT MEASURED** are open work, not assumptions. Per threat T-10-13 this document
|
||||
records node **labels** and status codes only — never raw LAN addresses, onion addresses
|
||||
or mesh ULAs, because this repository is being prepared for open-sourcing.
|
||||
|
||||
---
|
||||
|
||||
## Probe-method correction
|
||||
|
||||
**The audit's own C-6 command cannot detect the condition it claims to test. Do not
|
||||
re-derive this; it has now been checked against the code twice.**
|
||||
|
||||
`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901` probes with `seed.status` and declares
|
||||
`200` a failure. But `seed.status` is **not** in `UNAUTHENTICATED_METHODS`
|
||||
(`core/archipelago/src/api/rpc/middleware.rs:5-38`, which lists `seed.generate`,
|
||||
`seed.verify`, `seed.restore` and `seed.save-encrypted` — not `seed.status`). An
|
||||
unauthenticated `seed.status` is therefore rejected at
|
||||
`core/archipelago/src/api/rpc/mod.rs:293` with a **401 by design**. The audit's "Fail:
|
||||
200" criterion can never fire, so the probe would report the unauthenticated surface as
|
||||
closed while F-01's actual door stands open.
|
||||
|
||||
`scripts/security/rpc-exposure-probe.sh` measures the two facts separately:
|
||||
|
||||
| Signal | Method | Why | Reading |
|
||||
|---|---|---|---|
|
||||
| **Exposure** | `auth.isOnboardingComplete` | genuinely unauthenticated (`middleware.rs:9`), read-only, no side effects | `200` = the unauthenticated RPC surface is reachable from this vantage point. This is the honest C-6 result. |
|
||||
| **Session enforcement** | `seed.status` | deliberately *not* allowlisted | `401` = the session check is working. Anything else is a worse finding than C-6 and halts the phase. |
|
||||
|
||||
The probe reports a reachable unauthenticated surface as `EXPOSED`, not `FAIL`: on the LAN
|
||||
this is the current expected posture, and the purpose of C-6 is to **measure** the surface,
|
||||
not to assert it is already closed.
|
||||
|
||||
---
|
||||
|
||||
## C-6 — unauthenticated RPC reachability
|
||||
|
||||
### Result table
|
||||
|
||||
| Transport | Label | `health` | `auth.isOnboardingComplete` (exposure) | `seed.status` (enforcement) | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| Loopback | `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 |
|
||||
| Node's own LAN address, probed *from the node itself* | `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 |
|
||||
| LAN, from a second machine | `lan` | — | — | — | **NOT MEASURED** |
|
||||
| Tor onion | `tor` | — | — | — | **NOT MEASURED** |
|
||||
| FIPS mesh ULA, from a peer node | `mesh` | — | — | — | **NOT MEASURED** |
|
||||
|
||||
**`seed.status` returned `401` on every vantage point actually tested.** No
|
||||
stop-the-plan condition was observed.
|
||||
|
||||
### Why the two measured rows are NOT a C-6 result
|
||||
|
||||
Both runs originated **on the node under test**. Packets to the node's own addresses are
|
||||
delivered by the local stack and never traverse the LAN, so neither run exercises the
|
||||
external path an attacker would use, and neither run passes through any host or upstream
|
||||
filtering that applies only to foreign packets. They are recorded because they establish
|
||||
two real facts — the probe works against a live daemon, and session enforcement is intact
|
||||
— but C-6 asks specifically whether a **different machine** can reach the surface, and
|
||||
that question is still open.
|
||||
|
||||
### Verbatim probe output (measured rows)
|
||||
|
||||
```
|
||||
$ bash scripts/security/rpc-exposure-probe.sh --target 127.0.0.1 --scheme http --port 80 --label loopback
|
||||
RPC exposure probe — label=loopback endpoint=http://127.0.0.1:80
|
||||
audit item C-6 · KEY-01 (F-01) · read-only mode
|
||||
|
||||
[loopback] health 200 REACHABLE endpoint answers from this vantage point
|
||||
[loopback] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result)
|
||||
[loopback] seed.status 401 PASS session enforcement active for non-allowlisted methods
|
||||
[loopback] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200
|
||||
exit=0
|
||||
```
|
||||
|
||||
```
|
||||
$ bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label self-lan-ip
|
||||
RPC exposure probe — label=self-lan-ip endpoint=http://<node-lan-ip>:80
|
||||
audit item C-6 · KEY-01 (F-01) · read-only mode
|
||||
|
||||
[self-lan-ip] health 200 REACHABLE endpoint answers from this vantage point
|
||||
[self-lan-ip] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result)
|
||||
[self-lan-ip] seed.status 401 PASS session enforcement active for non-allowlisted methods
|
||||
[self-lan-ip] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200
|
||||
exit=0
|
||||
```
|
||||
|
||||
### Corroborating host state (observed, but NOT a substitute for the LAN measurement)
|
||||
|
||||
Recorded because it predicts the LAN result and tells the operator what to expect:
|
||||
|
||||
- nginx listens on **`0.0.0.0:80` and `[::]:80`** (`ss -ltn`), i.e. on every interface,
|
||||
not on loopback only. The daemon itself is bound loopback-only on `127.0.0.1:5678`, so
|
||||
all external reachability is via nginx.
|
||||
- The host packet filter does **not** block port 80: `iptables -S INPUT` is
|
||||
`-P INPUT ACCEPT` with a single jump into Tailscale's chain, and the `nft` ruleset
|
||||
contains only Tailscale's `ts-input`/`ts-forward` chains — no rule matching tcp/80.
|
||||
|
||||
Together these make an `EXPOSED` LAN result very likely. **That is a prediction, not a
|
||||
measurement, and C-6 stays open until a second machine produces the status code.**
|
||||
|
||||
### Incidental finding — `/rpc/` is not a second door
|
||||
|
||||
`auth.isOnboardingComplete` on nginx's `location /rpc/` block
|
||||
(`image-recipe/configs/nginx-archipelago.conf:192`) returned **404** from both vantage
|
||||
points. The block proxies the full URI to the backend, which only routes `/rpc/v1`, so
|
||||
the unauthenticated surface is reachable through exactly one path. This narrows F-01's
|
||||
exposure surface by one path and should be re-checked if the nginx config changes.
|
||||
|
||||
---
|
||||
|
||||
## KEY-01 refusal check — NOT PERFORMED
|
||||
|
||||
**Requirement:** on a node running 10-01's gate, an unauthenticated `seed.restore`
|
||||
carrying attacker-supplied words is refused, and `identity/node_key` and
|
||||
`identity/nostr_secret` are byte-identical afterwards.
|
||||
|
||||
**Blocker — no node in the fleet is running 10-01's gate yet.** Verified on the dev-box
|
||||
rather than assumed:
|
||||
|
||||
```
|
||||
$ ls -l /usr/local/bin/archipelago
|
||||
-rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago
|
||||
$ git log -1 --format='%H %ci' 879de59e
|
||||
879de59eccb489d590c8e0fca6ae79098df68200 2026-08-02 13:05:35 -0400
|
||||
$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago \
|
||||
&& echo PRESENT || echo ABSENT
|
||||
ABSENT
|
||||
```
|
||||
|
||||
The installed binary was built at 06:37; 10-01 landed at 13:05 the same day, and the
|
||||
gate's refusal string is absent from the running binary. A `--destructive` run against
|
||||
this node would therefore **not** be refused — it would replace the node's identity. The
|
||||
dev-box is a live dev-pair deploy target in real use, so the run was not made.
|
||||
|
||||
**This check is blocked on deployment, which the phase brief explicitly excludes from
|
||||
this plan.** It cannot be closed by any amount of work inside the repository.
|
||||
|
||||
---
|
||||
|
||||
## Fresh-node onboarding non-regression — NOT PERFORMED
|
||||
|
||||
**Requirement:** a genuinely un-onboarded instance completes the whole wizard with 10-01's
|
||||
gate in place (the anti-brick proof for correctness trap 1 and the D-03a signal
|
||||
correction), then refuses `seed.restore` immediately afterwards.
|
||||
|
||||
**Blocker — no un-onboarded instance exists.** The intended harness is shape (A) of
|
||||
`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md`
|
||||
(a second daemon under its own `ARCHIPELAGO_DATA_DIR`/`ARCHIPELAGO_BIND`/
|
||||
`ARCHIPELAGO_PORT_OFFSET`), and that todo is still **pending** — the harness has not been
|
||||
built. It would additionally need a binary built from `879de59e` or later, which the
|
||||
running daemon is not.
|
||||
|
||||
Note for whoever builds it: that todo records that several constants ignore
|
||||
`ARCHIPELAGO_DATA_DIR` and point at `/var/lib/archipelago` literally
|
||||
(`bitcoin_rpc.rs:10`, `container/lnd.rs:131`, `electrs_status.rs:15`,
|
||||
`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242`, `disk_monitor.rs:41`), so a shape-A
|
||||
instance must not install Bitcoin, LND, electrumx or Pine/HA — it would read and write the
|
||||
live node's files. The onboarding walkthrough this check needs does not install apps, so
|
||||
the hazard is avoidable, not blocking.
|
||||
|
||||
---
|
||||
|
||||
## Pre-OTA fleet check carried over from 10-01
|
||||
|
||||
10-01's summary records a state that its gate makes unrecoverable: a node with
|
||||
`onboarding.json = {"complete": true}` but **no** `user.json` can no longer call
|
||||
`auth.setup`, and the recovery path needs a session it cannot create. Recovery is one SSH
|
||||
command (`rm /var/lib/archipelago/onboarding.json`), but the fleet must be checked
|
||||
**before** the OTA ships (D-10).
|
||||
|
||||
| Node label | `user.json` | `onboarding.json` | Verdict |
|
||||
|---|---|---|---|
|
||||
| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally; the gate refuses re-keying, which is the intent |
|
||||
| rest of fleet | — | — | **NOT CHECKED** |
|
||||
|
||||
Command to run per node:
|
||||
|
||||
```bash
|
||||
ls -l /var/lib/archipelago/user.json /var/lib/archipelago/onboarding.json 2>&1
|
||||
cat /var/lib/archipelago/onboarding.json 2>/dev/null
|
||||
```
|
||||
|
||||
A node is at risk only if `onboarding.json` says `complete: true` **and** `user.json` is
|
||||
absent.
|
||||
|
||||
---
|
||||
|
||||
## What is still required to close C-6 and KEY-01
|
||||
|
||||
Every item below needs an operator with fleet access; none can be done from the repository.
|
||||
|
||||
1. **LAN exposure.** From a second machine on the node's LAN:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <node-lan-ip> --scheme http --port 80 --label lan`
|
||||
2. **Tor exposure.** `torsocks bash scripts/security/rpc-exposure-probe.sh --target <onion> --scheme http --port 80 --label tor`
|
||||
3. **Mesh exposure.** From a peer node over the FIPS mesh ULA:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <fips-ula> --scheme http --port 80 --label mesh`
|
||||
(the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296` — so a
|
||||
`200` confirms the mesh half of F-01's reachability claim). An unreachable transport is
|
||||
recorded as `UNREACHABLE` with its error, never omitted.
|
||||
4. **Deploy 10-01 to a disposable node**, then, from a second machine:
|
||||
`bash scripts/security/rpc-exposure-probe.sh --target <disposable-node> --destructive --label refusal`
|
||||
with `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret`
|
||||
captured on the node immediately before and after. The response must carry the
|
||||
`Not supported:` prefix and the two digests must match character for character.
|
||||
5. **Build shape (A)** and walk the wizard end to end on a 10-01 binary
|
||||
(intro → options → path → seed → seed-verify → did → identity → backup → verify → done,
|
||||
then set the password), reloading once on the seed screen to confirm the same 24 words
|
||||
return. No `Not supported:` and no `Rate limit exceeded` may appear at any point. Then
|
||||
re-run step 4 against that same instance to confirm the door closed behind onboarding.
|
||||
6. **Check the remaining fleet** for the `onboarding.json`-without-`user.json` state above.
|
||||
|
||||
Until items 1–3 are done, audit item **C-6 remains UNVERIFIED**. Until item 4 is done, the
|
||||
KEY-01 refusal is proven only by 10-01's unit tests against temp directories, never against
|
||||
a running daemon over HTTP.
|
||||
@@ -1,208 +0,0 @@
|
||||
# KEY-02 — build-host evidence for the rootfs identity strip
|
||||
|
||||
**Audit item:** C-4 of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (§868), which
|
||||
belongs to finding **F-03** (fail-open, never-retried first-boot secret regeneration over
|
||||
a fleet-shared rootfs).
|
||||
|
||||
**Status: ⛔ UNVERIFIED — awaiting a run on a real ISO build host.**
|
||||
|
||||
The code change is committed and unit-tested; the tar listing that proves its effect on a
|
||||
real build has not been produced yet, because it requires a build host with podman/docker
|
||||
and enough disk for a full rootfs rebuild. Do not read anything below the "Result" heading
|
||||
as a passing check until it is filled in.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Builder commit (Task 1) | `21043096` — fail-closed first-boot regeneration |
|
||||
| Builder commit (Task 2) | `408b328c` — rootfs identity strip |
|
||||
| Builder commit (follow-up) | single-producer unification, build-time generator assertion, self-heal timer |
|
||||
| Builder file | `image-recipe/_archived/build-auto-installer-iso.sh` (LIVE; `image-recipe/build-debian-iso.sh` execs it) |
|
||||
| Build host | _to be recorded_ |
|
||||
| Date run | _to be recorded_ |
|
||||
| RECIPE_HASH observed | _to be recorded — read it from the stamp file, see the caveat below_ |
|
||||
|
||||
---
|
||||
|
||||
## The expectation is deliberately INVERTED relative to the audit
|
||||
|
||||
This is the single most important thing to understand when comparing this document with the
|
||||
audit, and the reason it is stated before the commands rather than after.
|
||||
|
||||
The audit's C-4 entry says:
|
||||
|
||||
> **Expected:** SSH host keys and the TLS key **present** (they are baked — see
|
||||
> `build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id`
|
||||
> absent or zero-length. Anything else changes F-03's severity.
|
||||
|
||||
That expectation described the **broken** state the audit found, and recording it was how the
|
||||
audit measured the size of F-03. Phase 10 plan 10-03 Task 2 removed that material. So:
|
||||
|
||||
**After this change, the audit's stated expectation is the FAILURE condition.** If SSH host
|
||||
keys or the TLS private key still appear in the tar, the strip layer did not run — most
|
||||
likely because a cached `archipelago-rootfs.tar` was reused. That is not a regression in the
|
||||
check; it is the check working.
|
||||
|
||||
The two negative findings the audit recorded are unchanged and must still hold:
|
||||
`var/lib/systemd/random-seed` absent, `etc/machine-id` absent or zero-length.
|
||||
|
||||
---
|
||||
|
||||
## Commands to run
|
||||
|
||||
Run all of these **on the build host**, from the repo root, on a checkout that contains
|
||||
commits `21043096` and `408b328c`.
|
||||
|
||||
### 1. Force a full rebuild
|
||||
|
||||
The strip layer lives inside the `RECIPE_HASH` region (between the `# STEP 1: Build complete
|
||||
root filesystem` and `# STEP 2: Build minimal installer` markers), so the hash changes and the
|
||||
cached tar is invalidated automatically. `--rebuild` is passed anyway so that a stale tar
|
||||
cannot mask the result for any reason:
|
||||
|
||||
```bash
|
||||
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild
|
||||
```
|
||||
|
||||
`UNBUNDLED=1` is mandatory per `CLAUDE.md` and project memory — the default env silently
|
||||
builds the wrong full-bundle variant.
|
||||
|
||||
### 2. List the identity artefacts in the shipped tar
|
||||
|
||||
`WORK_DIR` is `image-recipe/build/auto-installer`, so:
|
||||
|
||||
```bash
|
||||
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \
|
||||
| grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago'
|
||||
```
|
||||
|
||||
### 3. Expected result after this plan
|
||||
|
||||
- **no** `etc/ssh/ssh_host_*` entries at all
|
||||
- **no** `etc/archipelago/ssl/archipelago.key` and **no** `archipelago.crt`
|
||||
(the `etc/archipelago/ssl/` **directory** must still be present — the first-boot staging
|
||||
swap needs somewhere to land)
|
||||
- **no** `var/lib/systemd/random-seed`
|
||||
- `etc/machine-id` present with size **0**, or absent. Either satisfies "not shared"; record
|
||||
which one was actually observed rather than generalising.
|
||||
|
||||
Note on the TLS keypair specifically: it is now absent for two independent reasons, not one.
|
||||
The Dockerfile no longer generates it at all (that layer was removed so there is a single
|
||||
producer), *and* the strip layer still deletes it as belt-and-braces in case a future layer
|
||||
starts baking one. Seeing it present therefore means both defences were bypassed.
|
||||
|
||||
### 4. Confirm the provenance file rode along
|
||||
|
||||
```bash
|
||||
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar | grep rootfs-identity-stripped
|
||||
```
|
||||
|
||||
Expected: one entry, `opt/archipelago/rootfs-identity-stripped`. Its absence means the strip
|
||||
layer did not execute and the whole check is void.
|
||||
|
||||
### 5. Confirm the regeneration path and its self-heal timer are still shipped
|
||||
|
||||
This is the brick check, and it is not optional. A stripped rootfs whose first-boot
|
||||
generation script failed to ship would leave every flashed node with no SSH host key and
|
||||
nothing to create one. The timer is part of the same check: without it, a node whose
|
||||
generators fail every in-boot retry has no unattended way back.
|
||||
|
||||
```bash
|
||||
ls -l image-recipe/build/auto-installer/installer-iso/archipelago/scripts/first-boot-secrets.sh \
|
||||
image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service \
|
||||
image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.timer
|
||||
```
|
||||
|
||||
Expected: all three present, `first-boot-secrets.sh` executable.
|
||||
|
||||
### 5b. Confirm the build-time generator assertion actually ran
|
||||
|
||||
The rootfs build fails outright if `openssl` or `ssh-keygen` is missing or non-executable,
|
||||
because that is the one way first-boot generation can fail deterministically — retries and
|
||||
reboots would never fix it, so it must never reach a node. A successful build therefore
|
||||
already proves the generators are present, and the build log says so:
|
||||
|
||||
```bash
|
||||
grep 'first-boot secret generators present' <build log>
|
||||
```
|
||||
|
||||
If you did not capture the log, assert it against the tar instead:
|
||||
|
||||
```bash
|
||||
tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \
|
||||
| grep -E 'usr/bin/(openssl|ssh-keygen)$'
|
||||
```
|
||||
|
||||
Expected: both present and mode `-rwxr-xr-x`.
|
||||
|
||||
### 6. Record the RECIPE_HASH the builder actually used
|
||||
|
||||
```bash
|
||||
cat image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256
|
||||
```
|
||||
|
||||
**Caveat — do not compute this hash from the repo file.** `image-recipe/build-debian-iso.sh`
|
||||
copies the archived builder to a temp path and rewrites its relative paths before exec'ing it,
|
||||
and `RECIPE_HASH` hashes `"$0"` — the rewritten copy. The hashed region contains 35 such
|
||||
rewritten path expressions, and `SCRIPT_DIR` is substituted with an absolute path, so the hash
|
||||
is specific to the build host and checkout location. For reference, hashing the region of the
|
||||
committed repo file directly gives `d2dc4df5427fe73d48227aab08cdf6debfe8dd554e6b18e3718f8d37ea9d675c`,
|
||||
which is **expected to differ** from the stamp above.
|
||||
|
||||
---
|
||||
|
||||
## Result
|
||||
|
||||
_Paste the raw output of steps 2, 4, 5 and 6 here, then set the status at the top of this
|
||||
document to VERIFIED with the date and build-host label._
|
||||
|
||||
```text
|
||||
(pending — not yet run on a build host)
|
||||
```
|
||||
|
||||
**Verdict:** _pending_
|
||||
|
||||
---
|
||||
|
||||
## What this does and does not prove
|
||||
|
||||
**Proves (once run):** the rootfs tar extracted verbatim onto every disk flashed from the ISO
|
||||
carries no SSH host key, no TLS private key and no populated machine-id — so a first-boot
|
||||
regeneration failure degrades to "no key, the service refuses to start" rather than
|
||||
"fleet-shared key, silently", which is the substance of F-03.
|
||||
|
||||
**Does not prove:** that two nodes flashed from the same ISO actually end up with different
|
||||
keys. That is audit item **C-3** (§779) and needs two physical machines; it remains
|
||||
separately UNVERIFIED. C-4 is a build-host check only.
|
||||
|
||||
### Guidance for C-3: SSH and TLS are now equally sharp signals
|
||||
|
||||
An earlier revision of this document said SSH host keys were the sharper divergence signal for
|
||||
C-3, because the installer had a per-install TLS fallback that would produce a differing cert
|
||||
even if first-boot generation had failed. **That asymmetry no longer exists.**
|
||||
|
||||
There is now exactly one producer of each secret — `gen_tls()` and `gen_ssh()` inside
|
||||
`first-boot-secrets.sh` — and no other code in the ISO build creates either. The Dockerfile no
|
||||
longer bakes a TLS keypair and the installer's "ensure SSL cert exists" block is gone. So for
|
||||
C-3, treat both the same way:
|
||||
|
||||
```bash
|
||||
# on each node
|
||||
ssh-keyscan -t ed25519 localhost 2>/dev/null | ssh-keygen -lf -
|
||||
openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256
|
||||
```
|
||||
|
||||
**Pass:** both fingerprints differ between the two nodes. **Fail:** either matches — a matching
|
||||
TLS fingerprint is now exactly as damning as a matching host key, whereas before it could have
|
||||
been explained away by the fallback.
|
||||
|
||||
Also check, on each node, that the run actually succeeded rather than merely being quiet:
|
||||
|
||||
```bash
|
||||
ls -l /var/lib/archipelago/.secrets-regenerated # present on a healthy node
|
||||
cat /var/lib/archipelago/first-boot-secrets.failed 2>&1 # absent on a healthy node
|
||||
systemctl status archipelago-first-boot-secrets.timer # enabled; the self-heal path
|
||||
```
|
||||
|
||||
The audit's original C-3 fail condition — a `WARNING:` line in the log alongside an existing
|
||||
marker — can no longer occur by construction: the marker is only written when both generators
|
||||
succeeded. If you ever see that combination, the fix has been reverted.
|
||||
@@ -1,6 +1,6 @@
|
||||
# KEY-05 — Entropy enforcement: per-site classification and mechanism record
|
||||
|
||||
**Requirement:** ROADMAP `KEY-05`. **Plan:** `.planning/phases/10-key-material-hardening/10-06-PLAN.md`.
|
||||
**Requirement:** ROADMAP `KEY-05`.
|
||||
**Supersedes:** backlog `R-13`. **Absorbs:** `R-05` (duplicate-`rand` visibility) and `R-09`
|
||||
(CSPRNG-readiness record). **Resolves:** `F-10a` in
|
||||
`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`, which recorded raw match counts and
|
||||
@@ -44,7 +44,7 @@ they are enabled wrongly. Both are gated behind Task 5, a `gate="blocking-human"
|
||||
|
||||
## Source precedence
|
||||
|
||||
`.planning/phases/10-key-material-hardening/10-CONTEXT.md` (2026-08-01) lists **F-07 / R-05**
|
||||
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.
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
# 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
|
||||
the issue tracker.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -39,8 +39,7 @@
|
||||
|
||||
**Provenance rules used throughout.** Every architectural claim is grounded in either (a) a
|
||||
`file:line` from this tree, or (b) RESEARCH.md Part C
|
||||
(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`,
|
||||
which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the
|
||||
(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]`.
|
||||
|
||||
@@ -615,6 +614,5 @@ current security posture and should not wait for the rest.
|
||||
- `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above
|
||||
answers two of its open items.
|
||||
- `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses.
|
||||
- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`
|
||||
— Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap
|
||||
format comparison.
|
||||
|
||||
Reference in New Issue
Block a user