rotate-release-root
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6db66db87f |
chore(trust): rotate the release root to z6Mkfu5LT…DLWT
DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below.
The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed
in a chat transcript and is treated as compromised. It signs both OTA
manifests and the app catalog, so anyone holding it could sign updates
the fleet would install.
Pins the new key in trust::anchor and moves EXPECTED_DID in all three
signing/publishing scripts.
ORDERING IS CRITICAL — nodes pin the OLD key:
* The release CARRYING this commit must be signed with the OLD key.
That is the only signature a node running the previous binary will
accept, and it is what installs the binary pinning the new key.
* Only the release AFTER that may be signed with the new key.
* Signing this release with the new key makes every node reject it,
ending OTA fleet-wide and requiring hands-on recovery per node.
sign-catalog.sh moves in the same commit, so the app catalog must also be
re-signed with the new key once this ships, or nodes accept the binary
and reject the catalog.
Key verified before pinning: the hex and the did:key are the same
keypair, checked with a base58 decoder round-tripped against the previous
known-good pair. An earlier candidate hex (cb830e13…) was rejected
because it decoded to a different DID than the one supplied — pinning it
would have made every node reject every future update.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1929f6a870 |
style: rustfmt the appgate, federation and manifest changes
The release gate runs cargo fmt --check and these were hand-written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9abf9072a3 |
docs(changelog): curate v1.7.121-alpha release notes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ab2c8b6e96 |
fix(security): silence is not consent — undeclared ports are never acted on
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.
1. Gating any `session` port regardless of `bind` published Bitcoin's
loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
of deploy.
2. The `bind`-keyed replacement looked safe because it protected
`bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
an EMPTY bind, so they fell through. One container recreate from
pinning them to loopback and breaking Zeus and every remote wallet.
`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:
* `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
Session, i.e. shows in the audit as something that should be behind
the gate. Reporting is always safe.
* `auth_is_declared()` — whether the daemon may ACT. Only an explicit
declaration authorises changing how a port is published.
Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.
Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
edc9a172e9 |
fix(mesh): federated peers are messageable without meeting over LoRa first
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.
`send_message` chose its transport from the attached radio:
let use_typed_envelope =
archy && matches!(device_type, Meshcore | Reticulum);
Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.
Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.
This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.
Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
719446c05f |
fix(companion): stop the endless rebuild loop on *-ui companions
Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10 minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were all one reconcile away from the same loop. `context_is_newer_than_image` decides to rebuild when the build context's newest mtime is later than `podman image inspect .Created`. The rebuild that follows is a full layer-cache hit, so podman reuses the identical image and leaves .Created untouched — the condition that triggered the rebuild is still true afterwards. The check cannot converge: it rebuilds on every reconcile tick forever, burning CPU and churning the container. It bites after any deploy that refreshes /opt/archipelago/docker/*, which makes the contexts newer than the shipped images — so this is fleet-wide on every OTA, not local to one node. Fix: stamp the context mtime that was built into an image label and compare against that instead. A label is part of the image config, so a cache-hit build with a new value still produces a new image — the thing being tested does change, and the comparison settles after exactly one rebuild. Verified against real podman before writing it: two cache-hit builds with different label values produced distinct image IDs (6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed inspect format was checked against an image with real labels, and a missing label prints empty (handled, along with "<no value>"). Images built before this carry no label and fall back to .Created, so behaviour is unchanged for them and each self-heals on its first reconcile after upgrade — nodes fix themselves rather than needing the manual `podman build --no-cache` pass this needed by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3716b6e9c3 |
fix(security): two gate bugs that would have made the rollout a no-op
Both found while setting up the on-node test, and both fail silently in the same direction — the gate reports success while protecting nothing, which is the exact failure the module was written to prevent. 1. Loopback-pinned ports were skipped entirely. `identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`, reasoning that a loopback publish is not externally reachable. But `listener.rs` requires loopback-pinning as the PRECONDITION for gating — while an app holds 0.0.0.0:<port> the kernel will not let the gate bind that port at all. So the two contradicted each other: pinning an app, the one action that lets the gate take over, was also what removed it from the gated set. Completing the entire migration would have gated nothing, and GateStatus would have reported zero unprotected ports while doing it. `bind` cannot carry this decision, because two unrelated intentions produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so the LAN CANNOT reach it (fronting it would newly expose it on every host address, behind a login but exposed where it deliberately was not), whereas a migrated app is pinned precisely so the gate CAN. Inferring from `bind` breaks one or the other, so the intent is now declared: `PortAuth::Local` means the first case. The three ports that are host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already `bind: 127.0.0.1`) say so, and a loopback publish with `auth: session` stays gated. A test pins that property. 2. The port map was never refreshed. `AppGate::refresh()` existed, was documented as making catalog changes apply without a restart, and was called by nothing. The map was built once in `new()`, so an app installed while the daemon runs would never be gated — and would never appear in `unprotected` either, so the node would report itself fully enforced while serving a brand-new app to anyone who asked. The sweep now refreshes before classifying. Tests: 22/22 appgate, 73/73 archipelago-container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cc9e19589c |
fix(release): refuse to commit an unsigned OTA manifest
Every cycle has needed a manual check that releases/manifest.json got signed, because the script would happily commit and tag one that hadn't. The signing step is conditional: with no TTY and no RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit at step 7 then ran regardless, so the release commit — and its tag — carried an unsigned manifest. publish-release-assets.sh already refuses to ship one, but that backstop arrives a step too late. Nodes fetch releases/manifest.json straight from branch `main` (the same URLs this script prints for verification), so the COMMIT is what exposes it to the fleet, not the publish. By the time publishing is refused, the unsigned manifest is already on main and nodes are already declining to auto-apply. So the same gate now runs before the commit: presence of a signature, signed_by matching the release root, and `ceremony verify` for the crypto. A release commit carrying a manifest no node will accept has no valid use, so this refuses to create one rather than leave a tag that has to be re-cut. The earlier warning is corrected too — it promised the run would continue, which is no longer true. Verified the predicate against three manifests: signed -> allow, signature stripped -> refuse, signed_by swapped to another DID -> refuse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0de67ca6ae |
feat(security): app gate — authenticate every app port
Demo images / Build & push demo images (push) Successful in 4m18s
Reproduced again on this node today: with no session cookie, six app ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175 Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so served on every host address. Same bug class as the /lnd-connect-info and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app. LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>, so this is one gate rather than four. It lives in the daemon rather than a per-app sidecar (umbrel's app_proxy model): rootless, no extra container per app, and it can reuse machinery that already exists. It invents no authentication policy. verify_password, TOTP secret decryption, verify_code with used-step replay protection, the session store, and — importantly — the SAME LoginRateLimiter instance as the JSON-RPC path, so an attacker cannot get a fresh budget of password guesses by moving to an app port. Only the transport differs, an HTML form instead of JSON-RPC, because a browser being sent to an app cannot speak JSON-RPC. 2FA comes for free: a session still pending its TOTP step fails validate(), so the gate rejects it without knowing what a second factor is. Details worth keeping: - 401, not a redirect. A redirect to a login page is indistinguishable from the app itself redirecting, and machine clients would follow it and parse HTML as their API response. - Cookie and Authorization are stripped before proxying. The app has no use for the node session and must never be able to log or forward it. - The challenge page names and pictures the app being opened, so the visitor can confirm what they are authenticating to. - device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for machine clients. None = node-wide, which every existing companion token is; migrating them by guessing a scope would silently revoke access nobody asked to revoke. An empty list is rejected rather than minted, since it reads as unrestricted while authorising nothing. The rollout is necessarily per-app and the gate is built to say so. A container publishing 0.0.0.0:<port> claims every host address, so the gate cannot bind that port until the app is pinned to bind: 127.0.0.1 and recreated — gate-first is impossible, and all-at-once would recreate every container on a node simultaneously. Every port it cannot claim is logged at warn each sweep and recorded in GateStatus::unprotected, surfaced by security.app-gate-status. The failure mode being designed against is a gate that binds nothing, logs at debug, and reports success while every app stays exactly as open as before — worse than no gate, because it stops anyone looking. Same reasoning that ruled out an nft drop-in, whose absence is a silent no-op. Not yet done: pinning the 39 gated ports to loopback, repointing HiddenServicePort at the gate, and on-node verification. Tests: 21/21 appgate, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
63d0183dd2 |
fix(ui): stop the dashboard cards leaving a backdrop-filter seam
A vertical line crossing both dashboard cards, appearing at random on hover and hard to catch deliberately. Diagnosed from the screenshot rather than by reproduction. Decoding it and scanning column by column found a lone brightness step at CSS x=633 that never returns — every legitimate container edge in the page shows up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850, 875, 1437), so an unpaired one is not a border. Sampling by region placed it inside the cards and nowhere else: 10/13 rows inside My Apps, 11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same screen x in both cards, which means the boundary lives in screen space and cuts whatever backdrop-filter surface it crosses. style.css already neutralises backdrop-filter for the shared glass classes inside the dashboard's animated perspective/scroll containers, because Chromium/Brave mis-rasterise it there — that block was written for the black-rectangle corruption. `.home-card-shell` declares its own `backdrop-filter: blur(18px)` in Home.vue and was never added to the list, so it was the only unmitigated blur surface on the dashboard. That is exactly the set of pixels the seam appears in. A hover repaint re-rasterises part of the backdrop, and the refreshed half meets the stale half at the damage boundary. Adding it to the existing list also makes the shell consistent with the tiles beside it: its fill is already rgba(0,0,0,0.65), the same as .glass-card, which renders unblurred here. The list is hand-maintained, which is how this shipped — a component declaring backdrop-filter in its own <style> is simply not covered and nothing fails. So the fix comes with a test that parses Home.vue for locally-declared backdrop-filter rules and asserts each is in the mitigation list. Verified it catches the real bug: reverting the one-line fix makes it fail naming `.home-card-shell`. Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0c4826f8cc |
feat(security): declare which app ports may skip authentication
Groundwork for the app gate (item 1): before anything can enforce authentication on app ports, the node has to know which ports are *supposed* to be reachable without it. `PortMapping` grows `auth` (PortAuth::Session | None, defaulting to Session) and `auth_rationale`. The default is deliberately the protected one. Every app port on this node answered with no credential at all over LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 — precisely because exposure was what you got by saying nothing. Inverting the default means a new app is protected unless its manifest argues for an exemption. Validation makes the argument mandatory: `auth: none` without a rationale is rejected, and so is a rationale without `auth: none` (that combination means the author wrote an exemption and did not get one — shipping it silently would leave them believing otherwise). 17 ports across 12 apps are declared exempt, each with its reason. They are the ports that cannot sit behind an HTTP login page at all: Lightning p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS — Zeus and remote wallets dial these directly), Bitcoin p2p gossip, electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published ports — now defaults to gated. Bitcoin's RPC 8332 is deliberately NOT exempted: it is already `bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption it does not need would put a line in the audit list that means nothing. If the loopback bind is ever dropped, it fails closed. Two corpus tests keep this honest: every shipped manifest must parse under the new rules, and the exempt set is pinned at 17 so any change to the node's unauthenticated surface has to be a deliberate edit. Tests: 73/73 archipelago-container, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24ce8b39e8 |
feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
Promotion to Trusted is a privilege escalation — a Trusted peer can read node state, be deployed to, and is exempt from the `!= Untrusted` gates federation/DWN/messaging use. It must therefore cost a fresh proof that the person at the keyboard is the operator, not merely that a session cookie exists. Same reasoning as node.rotate-identity and TOTP setup, both of which already re-verify. Both entry points are covered: - `federation.invite` gates on the RESOLVED level, not on an explicit request for Trusted: "Link Your Nodes" sends no `trust_level` at all and falls through to the Trusted default. The invite is a bearer grant of Trusted to whoever redeems it, so minting it IS the escalation. Observer invites are untouched. - `federation.set-trust` gates only when the peer is not already Trusted, so the dropdown re-emitting its own value doesn't demand a password for a no-op. Demotion is deliberately NOT gated: making something less privileged must never be harder than leaving it alone, or the safe action becomes the inconvenient one. The backend is the sole authority on what counts as an escalation — it returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and retries only on that, so the rule lives in exactly one place and the frontend never pre-judges. TrustPasswordModal.vue (modelled on RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps back to the node's real level on change, since a cancelled or failed promotion would otherwise leave the dropdown displaying a level the node never accepted. The operator path stamps TrustSource::Manual; set_trust_level grew an `Option<TrustSource>` so automatic adjustments (the discovery-handshake demotion safety net) pass None and leave the recorded provenance alone rather than laundering an uninvited-join peer into looking approved. Follow-up, deliberately out of scope: `federation.join` also reaches Trusted when redeeming someone else's Trusted invite, with no re-auth. Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f0b71f86aa | docs(13): declare AIUI-01..06 in the canonical ROADMAP requirements line | ||
|
|
223afc26f7 | docs(reqs): register AIUI-01..06 for phase 13 traceability | ||
|
|
eb224709d7 | docs(13): create phase plan — 15 plans in 8 waves | ||
|
|
60625499ae |
fix(13): make D-13 track independence real in the wave graph
Plan-checker revision iteration 1 — 1 blocker + 2 warnings. BLOCKER (context_compliance, D-13): the music track blocked phase completion despite being locked as non-blocking. 13-15 depended on 13-11, which chains back through 13-07 to 13-04, so the phase could not close without the entire music chain. Took the checker's option (b): 13-15 depends_on is now ["13-06","13-09","13-14"] — 13-06 added so the content-grid check stays a real gate, 13-11 dropped so no path reaches 13-04/13-07/13-11. UAT step 7 is now content-only and blocking; new step 7b is the music view as record-and-defer, the same shape step 10 already used for Routstr. Verified: 13-15's transitive closure contains no music plan. WARNING (scope_reduction): T-13-32 claimed the filebrowser-client.ts JWT-in-query-string leak was "fixed" while only guaranteeing it was not propagated. Now actually fixed — streamUrl returns a query-free same-origin URL and relies on the path=/ cookie login() already sets; filebrowser-client.ts and a new regression test are in 13-06's files_modified. T-13-32 is scoped to new code; new T-13-39 owns the pre-existing leak and names the residual (the JWT is still 24h, now confined to the cookie jar). WARNING (verification_derivation): the edge-probe reconciliation did not match the files. Corrected in 13-VALIDATION.md — 10 probe findings vs 9 edge entries kept apart, 13-07's 3 truths retagged as authored rather than probe-surfaced. No truths deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
203966030b |
docs(13): create phase plan — 15 plans in 8 waves
AIUI conversational node control & content surfaces, decomposed tracer-first: 13-01 leads with one end-to-end read-only tool proving the whole spine (AIUI chat -> postMessage -> authenticated RPC -> Rust agent loop -> real node data), then expands. Waves 1-8 across three tracks that stay independent per D-13: - control/assistant: 13-01, 13-05, 13-08, 13-10, 13-12, 13-13, 13-14 - content: 13-06 - music library: 13-04, 13-07, 13-11 (no control/content plan depends on it) - security & delivery: 13-02, 13-03, 13-09 - on-device sign-off: 13-15 Notable decisions recorded in the plans: - Open Q1: delete-and-replace the live unauthenticated port-3142 Claude proxy with a session-gated Rust forwarder; the OpenRouter open relay is removed. - Open Q2: /aiui/-scoped CSP connect-src plus a per-session rate limit; the iframe sandbox attribute is explicitly rejected with reasons. - Open Q3: a live Routstr spike (13-03) gates the Routstr backend (13-13). - Open Q4: one "assistant." dispatcher prefix arm, so the existing session/CSRF/RBAC gate applies unchanged before dispatch. - Promote (not add-alongside) CallerScope as the primary caller/permission noun; the mesh-specific controls become one variant's resolution inputs. - schemars rejected as an unaudited crate; JSON Schema is hand-written. - AI-SPEC's `cargo test --test assistant_evals` corrected to an in-crate module: core/archipelago is a binary-only crate with no lib target. Also adds COVERAGE.md (Routstr capability matrix, every opt-out reasoned). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e00c73ed2d |
docs(1.7.121): pause point — task list, status and resume notes
v1.7.120-alpha is shipped and verified; do not re-cut it. Two fixes landed after it: federation trust escalation ( |
||
|
|
5088aef556 |
fix(lnd-ui): pin the image and host-network it so OTA actually updates it
Reported: Framework PT took the OTA and got the new bitcoin-ui but not lnd-ui. Two causes, both in the update path rather than the app. 1. LND_UI_IMAGE was "lnd-ui:latest" while BITCOIN_UI_IMAGE was pinned to 1.7.119-alpha. Podman does not re-pull a tag it already holds locally, so a node that ever pulled lnd-ui:latest keeps that copy forever and every subsequent release silently no-ops. Pinned to 1.7.119-alpha, so a version change is what triggers the pull — the same mechanism that made bitcoin-ui update correctly. 2. first-boot-containers.sh declared lnd-ui as bridge with -p 18083:80. docker/lnd-ui/nginx.conf listens on 18083 DIRECTLY (it must, to proxy the backend on 127.0.0.1:5678 same-origin), so that maps a host port onto a container port nothing serves — reproduced on-node as HTTP 000. This is the THIRD copy of the same declaration: container-specs.sh and apps/lnd-ui/manifest.yml were both already corrected, this one was missed, and it is the copy fresh installs use. Now host-networked with no published ports, matching its siblings and the other two copies. The underlying hazard is that one container spec lives in three files that can disagree; recorded as a follow-up rather than refactored here. Also opens .planning/RELEASE-1.7.121-TASKS.md — every outstanding item for the next release with its evidence, so nothing in a fast-moving queue gets lost between sessions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c0cfc72a05 |
fix(security): peers must not be able to grant themselves Trusted
Reported: "peers seem to be slipping into trusted status somehow which is
absolutely terrible for security". Two independent fail-open paths, both
granting Trusted with no operator decision anywhere in the loop.
1. federation.peer-joined is UNAUTHENTICATED (middleware's no-session
list — federated peers call it over Tor without cookies) and reachable
on /rpc/v1, which is peer-allowed. It does verify an ed25519 signature,
but against THE PUBKEY THE CALLER SUPPLIED, so it proves the caller
holds its own key and nothing about whether we ever invited it. A join
presenting no invite_token fell through to
None => TrustLevel::Trusted.min(claimed_trust)
and claimed_trust itself defaults to Trusted when the field is absent.
So anything able to reach the node could generate a keypair, omit the
token, and be recorded as Trusted. Now capped at Observer: an invite
WE minted is the only path to Trusted. `min` is kept so a peer's own
lower claim is still honoured — this can only ever reduce trust.
2. merge_transitive_peers added every peer advertised by a Trusted source
as Trusted. That makes trust viral rather than transitive-by-one-hop:
the merged node is itself synced with, its peers merged in turn, so a
single invite anywhere in the graph eventually marked the entire graph
Trusted on every node. Now Observer — which is what this feature's own
spec always said. NodeStateSnapshot.federated_peers is documented as
"adds them as Observers on her side… doesn't auto-promote Observer-via-
Bob to Trusted". The code contradicted the comment directly above it.
Observer is deliberate rather than Untrusted: the merge exists for
routing, and Observer still passes the `!= Untrusted` gates that
federation, DWN and messaging actually check, so a legacy peer degrades
instead of breaking. Per the operator's decision, existing peers are NOT
auto-demoted — silently rewriting live trust relationships across the
fleet would be worse than the bug.
Instead they are made auditable: FederatedNode.trust_source records WHY a
level was granted (invite | uninvited-join | transitive-merge | manual).
It deliberately has no default provenance — None means "recorded before
this existed", which is exactly the population worth reviewing.
The one failing test was asserting the vulnerable behaviour
(merge_transitive_peers_skips_source_and_local_node expected Trusted); it
now asserts the security property and says why, so the escalation cannot
be reintroduced by making a test go green.
Verified: 42/42 federation tests, cargo check --all-targets clean.
Still open, tracked in .planning/RELEASE-1.7.121-TASKS.md: surface
trust_source in the UI, and require the node password to grant Trusted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ff2cb5aa0e | docs(13): add pattern map | ||
|
|
72b07fbe9c | docs(13): generate AI-SPEC.md — hand-written Rust agent loop + domain context + eval strategy | ||
|
|
d3ee5486ab | docs(13): add validation strategy | ||
|
|
7134ae903d |
docs(13): research phase domain — AIUI conversational control
Verifies the AIUI-01 gating question against source (no tool-calling anywhere in this codebase today; Pine's HA intents are read-only Q&A, not an action-executing loop), surfaces a live unauthenticated Claude-proxy exposure (port 3142) and a same-origin iframe sandbox gap not previously named, and maps existing Cashu/Nostr primitives onto the Routstr integration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3b8ac7cb1c |
docs(state): v1.7.120-alpha shipped and verified live
Records the two release-process traps for the next cut: the manifest is committed before signing (so the fleet would refuse the OTA), and gitea-vps2 is the same server as gitea-ai with a dead token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7f9dd2172d | docs(state): record phase 13 context session | ||
|
|
48c7f5d02f | docs(13): capture phase context | ||
|
|
4a5588c59a |
chore(release): commit the signed v1.7.120-alpha manifest
create-release.sh builds and commits the manifest BEFORE the signing step, so the release commit carried an UNSIGNED manifest. Nodes fetch releases/manifest.json from branch main and refuse to auto-apply an unsigned one, so publishing without this would have shipped an OTA the fleet silently declines. Signature verified against the pinned release root before committing: signed_by did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur Cargo.lock carries the 1.7.120-alpha version bump from the release build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9de0a17670 |
chore: release v1.7.120-alpha
Demo images / Build & push demo images (push) Successful in 3m30s
|
||
|
|
0fec507af3 |
docs(roadmap): add Phase 13 — AIUI conversational node control and content surfaces
AIUI is embedded and styled but not functional: the chat cannot act on the node and its content views are not wired to real data. Phase 13 scopes making it work — Pine's human-language intent->action capability reachable from typed chat, conversational settings, and the peer-files/music/movies/node-content surfaces rendered live. The gating requirement is AIUI-04: a user-granted capability sandbox. An LLM in the browser is now adjacent to wallet keys, macaroons and node identity, so secrets stay server-side behind scoped tokens, capability grants default closed and stay revocable, destructive operations need a human confirmation, and peer-supplied text is treated as untrusted input to the model context. This must not widen the Phase 10 hard-refuse gates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6a617f3a6c |
docs(whats-new): add the v1.7.120-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m45s
Generated by scripts/sync-whats-new.py, which the release gate checks. Without it the Settings > What's New modal would have skipped straight from v1.7.119 to v1.7.121 — the release notes users actually read, as opposed to CHANGELOG.md which they do not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b15b160294 |
style: rustfmt the code added in 01-04 and the reconcile fix
The release gate's cargo-fmt stage failed on my own additions — the tests in message_types.rs and lnd/info.rs and the reconcile branch in prod_orchestrator.rs were written programmatically and never passed through rustfmt. Formatting only; rustfmt is semantics-preserving and the gate re-runs the suites before building. Caught by the gate rather than in review, which is the gate working. Also a reminder that a piped command's exit code is the pipe's, not the script's: the task notification reported success while the log said CREATE_RELEASE_EXIT=1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
41de23e71f |
docs(requirements): mark UIFIX-04 and UIFIX-06 complete
Bookkeeping left uncommitted by an earlier session. It records work that
is already shipped —
|
||
|
|
57891099a4 |
docs(changelog): record the stuck-nav fix for v1.7.120-alpha
States plainly that the speed is unchanged — the fix gates the teleported chrome, not the KeepAlive caching that made tab switching instant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a5b923fa0b |
fix(ui): teleported nav must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m30s
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.
Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.
Two offenders, matching the report exactly:
- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
<body>, gated only on `mobileShowChat` — never on whether Mesh was the
screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
NO gate at all, so it leaked out of every view that uses it. Fixing the
shared component fixes every caller at once: Vue propagates
activated/deactivated from the KeepAlive boundary down through the
subtree, so a child can guard itself.
BaseModal already solved the transient-dialog half of this class in
|
||
|
|
b945738d62 |
docs(state): record v1.7.120-alpha staging and its on-node verification
Includes what was NOT verified — the torrc block is deployed but dormant, since regenerate_torrc only fires on a Tor services change and the change is inert until bitcoind gets an -onion flag in Phase 12. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4d67f56bc4 |
docs(changelog): curated notes for v1.7.120-alpha
Leads with the reason to take the update: two ports handed anyone who could reach them full control of the node's money. Written for an operator, not a developer — what was exposed, who could reach it, and what to treat as compromised. Includes the gaps rather than burying them: the 5x lifecycle gate was not run, two fleet nodes still share SSH host keys (rotation is a deliberate operator decision, not an oversight), and Core can now reach Tor but is not yet routed through it. create-release.sh hard-fails without this section, so it lands before the release run rather than during it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8ee81e04c9 |
docs(roadmap): add Phase 12 — Bitcoin node settings, Core/Knots parity
Per the operator: every option umbrelOS surfaces must be reachable in the UI, Knots-only options surfaced separately from the ones Core shares, and network mode a setting whose DEFAULT is Tor rather than clearnet. Scoped as a phase rather than done inline because bitcoind's arguments are currently hardcoded in three places (first-boot-containers.sh, container-specs.sh, apps/bitcoin-knots/manifest.yml) — the same triplication that produced the lnd-ui HTTP 000 defect. There is nowhere for a UI to write, so BTCSET-01 is a settings model those three render FROM, not another restatement. Two constraints recorded up front so they are not discovered late: - Knots-only flags gated to Knots is a CORRECTNESS requirement — offering one on Core yields a node that refuses to start. - Several options are not freely reversible: txindex forces a reindex, prune is destructive and needs a full resync to undo. On a node that is somebody's wallet backend those must be labelled and gated, not silently applied. Any change at all restarts bitcoind, interrupting LND, electrs and the fedimint gateways. Inbound onion is explicitly out of scope: it needs Tor's ControlPort, which is deliberately disabled for security, so the node reaches .onion peers but stays unlisted. The UI must say so rather than imply otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f04941934b |
feat(tor): give archy-net containers a SOCKS path so Core can use Tor
Enabling half of "Bitcoin Core has no Tor proxy at all", handed over from the app-UI work. Core reported `onion reachable=False, proxy=''` with all 11 peers on clearnet, and the reason was not a missing bitcoind flag: the container sits on the archy-net bridge (10.89.0.0/24 here), so 127.0.0.1:9050 inside it is its OWN loopback. The host's Tor was genuinely unreachable, and no flag on bitcoind could have fixed that alone. torrc now binds a second SOCKS listener on the archy-net gateway. The gateway is DERIVED at runtime via `podman network inspect`, never hardcoded: archy-net is created without an explicit subnet, so podman allocates one. It is 10.89.0.0/24 on this node with no guarantee of that elsewhere, and a hardcoded guess would fail silently — binding SOCKS to an address no container can reach, which looks identical to working. Two deliberate safety properties: - FAIL CLOSED. If archy-net is absent or its inspect output does not parse, no second listener is emitted and SOCKS stays loopback-only. An exposure boundary is not something to widen on a guess. - 127.0.0.1 is accepted FIRST in the SocksPolicy. SocksPolicy applies to every SocksPort, so an accept-list naming only the bridge subnet would have locked the daemon out of its own loopback SOCKS — breaking the node's Tor usage in a way that looks nothing like "we added a listener". The list is accept-loopback, accept-subnet, reject *. This widens Tor SOCKS from loopback-only to the archy-net subnet, which is a real change to the node's exposure surface and was explicitly approved by the operator rather than assumed. Inbound onion for Core remains impossible without reversing the deliberate "ControlPort disabled for security" decision — this is outbound only, and the node stays unlisted on Tor. Not yet wired: bitcoind still has no -onion flag, because the operator wants network mode to be a UI setting with Tor rather than clearnet as the default. Hardcoding the flag in the three places that currently define bitcoind's arguments would be the wrong shape for that, so it is deferred to the settings work rather than done twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fbec70069f |
fix(nginx): serve AIUI's absolute /assets/ requests from aiui/assets/
Taking over a parked item from the app-UI work. AIUI's built index.html
emits ABSOLUTE /assets/<hashed> paths, so the browser asks for
/assets/index-BC2fBBaW.js. That lands in the MAIN UI's assets dir, where
it does not exist — the real files are in aiui/assets/. Both of AIUI's
two entry assets 404'd, so the embedded sidebar loaded nothing.
The config already contained a /aiui-assets/ location whose comment names
this exact problem ("AIUI may reference /assets/ without /aiui/ prefix"),
but it only catches requests to /aiui-assets/, a path AIUI never asks
for. It described the bug without fixing it.
/assets/ now falls back to a named location that rewrites into
aiui/assets/ and 404s from there. A fallback rather than copying the two
files up one level, because a frontend deploy replaces web-ui wholesale —
update.rs preserves the aiui/ DIRECTORY, not copies made into assets/ —
so a copy is erased by the very next deploy while this survives one.
Both server blocks (HTTP and HTTPS) are patched; named locations are
per-server, so each needs its own.
Verified on archi-dev-box after reload:
/assets/index-BC2fBBaW.js 200, 305256 bytes, application/javascript
/assets/index-BJkaQ2c4.css 200, 150716 bytes, text/css
/assets/does-not-exist.js 404 (the fallback is not over-broad)
/assets/index--lyLAgu1.js 200 (real main-UI chunks still come from
/assets/vendor-CmYeCqL_.js 200 the main dir — try_files hits them
/assets/index-CiMaoNII.css 200 before the fallback is consulted)
/ /aiui/ /health 200
Hash collision between the two builds is not a concern: Vite hashes are
content-derived, and any main-UI asset that exists is served by try_files
before the fallback runs.
Noted while doing this, not fixed here: the node's own
/etc/nginx/sites-enabled/archipelago is 378 lines BEHIND this repo file
(984 vs 1362) — it predates the IPv6 listener and the @asset_missing
no-store handling, among others. The node was patched minimally in its
own shape rather than overwritten, since a wholesale copy of a config
this diverged is not a safe unattended action.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
c4aece4883 |
docs(01-04): on-node verification, and the send half is inert here
Deployed
|
||
|
|
6b3693dcc8 |
docs(state): 01-04 complete; Phase 1 resumed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
666990c684 |
feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05)
Task 3, completing 01-04. mesh.lightning-peers returns the peers that have advertised a Lightning URI: filtered, deduplicated, deterministically ordered, and an empty array rather than an error when nobody has — "nobody yet" is a normal state on a fresh node, not a fault. mesh.send-lightning-info advertises this node's own URI to ONE chosen peer. There is deliberately no broadcast form: this discloses the node's payment endpoint, and who learns it is the operator's choice rather than a side effect of being in radio range (T-01-13). It refuses to send when LND advertises no URI, instead of sending an empty one a peer would store as an undialable target. The list-building and target-parsing logic is extracted into pure functions because this file has no handler test harness and the handlers need a live mesh service. That keeps the three contracts that actually matter provable rather than merely readable: - dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key, lowercased — never the firmware routing key, so a radio contact and its federation twin collapse to one entry (T-01-11) - "newest advertisement wins" compares PARSED RFC3339 timestamps, not strings: 09:30-01:00 is later than 10:00Z while sorting earlier as text, and there is a test that fails if that is ever string-compared - ordering is name-then-contact_id and asserted byte-identical across eight rotations of the input, because a HashMap's iteration order is not stable and a picker that reshuffles between reads means an operator can click a different node than the one they aimed at The peer allow-list is untouched: server.rs has an empty diff and is_peer_allowed_path still occurs 13 times (T-01-15). Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy --all-targets clean in every touched module (two useless_format lints in the new test code fixed, not waived). The SUMMARY records one deviation honestly: Task 1's tests were written alongside its implementation rather than before, so no pre-implementation failing output exists. A mutation test was run in its place — disabling the pubkey validation fails 3 of the 5 tests — which proves the assertions bind, and the mutation was reverted and verified gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
decb7c713b |
feat(01-04): the two Lightning facts the channel-open picker needs (FED-05)
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh message a peer uses to advertise theirs. lnd.getinfo now deserializes identity_pubkey and uris, which its response struct simply did not declare before (RESEARCH.md Pitfall 5). The identity mapping is split into a pure map_identity() so it is testable without a live LND. A pubkey that is not 66 hex characters maps to None rather than being forwarded: the same rule lnd.openchannel enforces, applied where the operator is reading their own node's identity instead of at the moment they try to open a channel. An absent field yields an honest absence — never a fabricated or placeholder identity. MeshMessageType::LightningInfo = 26 is additive on a wire format shared with every fleet node: 26 was unused, so a peer that predates this fails to decode it rather than mis-decoding it as something else. Its payload is deliberately two fields — this rides LoRa, where every byte is paid for on air, and the optional alias is skip_serializing_if so an absent one costs nothing (asserted, not assumed). is_valid_lightning_uri() validates before anything is stored, because this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional numeric :port, exactly one '@'. It deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose. Two preservation hazards found while wiring MeshPeer.lightning_uri, both of which would have silently emptied the picker: - decode.rs's identity-advert path does a WHOLESALE insert, preserving only advert_name and lat/lon by hand. Reticulum re-emits identity adverts every announce tick, so a stored URI would have been wiped about once a minute. Now preserved, alongside the same guard the name and position already had. - session.rs's refresh_contacts and mod.rs's federation seeding rebuild the peer record wholesale too. Neither carries a Lightning datum, so both now carry the previous value forward rather than nulling it. A malformed inbound URI is rejected before the write, leaving any previously stored good URI intact — otherwise anyone in range could blank out a real peer's picker entry (T-01-12). Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new), cargo check --all-targets clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e205f2c34a |
docs(security): prove the delivery path on hardware; close window 15
Controlled test on archi-dev-box with operator approval. The daemon was
stopped first so the reconciler could not repair the state before the
re-exposure was confirmed — without a confirmed 200, the later 401 would
be consistent with the state never having been broken at all.
1. stale conf installed + container restarted -> POST /bitcoin-rpc/
returned 200 with a real block height and Allow-Origin: *
2. daemon started 20:00:36, nothing else touched
3. 20:02:19 reconcile rendered the conf and logged the expected warn
line naming bitcoin-ui/archy-bitcoin-ui, then restarted it
4. POST -> 401, Allow-Origin origin-scoped
5. conf byte-identical to the pre-test known-good, container healthy
Both halves are now proven on real hardware: a05956c4's template (the
gate works) and f6b5245b's delivery path (the gate reaches a container
the reconciler had been skipping).
Also records the operator's decision AGAINST credential rotation — no
macaroon, no Bitcoin RPC password — with the trade it accepts stated
plainly, so it is not silently re-litigated later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
186a2c36c8 |
fix(lnd-ui): manifest declared bridge 18083:80 like the spec did
Second copy of the wiring fixed in
|
||
|
|
b2ed27dcfb |
fix(bitcoin-ui): send no-cache for index.html; pin the rebuilt image
Two things needed for the new UI to actually reach users. The rendered nginx.conf served index.html with only ETag/Last-Modified and no Cache-Control, so browsers applied heuristic caching to it. Confirmed on archi-dev-box: after rebuilding and recreating the container, :8334 and /app/bitcoin-ui/ both served the new markup immediately, but the app iframe in the main UI kept showing the previous UI until a hard refresh. docker/lnd-ui/nginx.conf has always carried this header, which is why only bitcoin-ui showed the stale copy. Using "no-cache" (revalidate) rather than "no-store" keeps the ETag doing its job when nothing has changed. Validated by mounting the rendered config into a throwaway container from the built image and running nginx -t. (An earlier attempt to test it inside the running container was meaningless — conf.d/default.conf is a read-only bind mount, so the copy failed and nginx -t just re-checked the original.) The 8 container::bitcoin_ui tests still pass; their assertions cover the placeholder, the 8332 proxy_pass and the listen directive, none of which this touches. BITCOIN_UI_IMAGE was still pinned to 1.7.84-alpha, so a fresh install would pull a bitcoin-ui from many releases ago regardless of what the OTA ships — first-boot-containers.sh tries the registry image before building from source. Bumped to 1.7.119-alpha, matching the current release, and the image is pushed under that tag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aaa89789d2 |
fix(lnd-ui,bitcoin-ui): OTA-breaking lnd-ui spec, 404 channels link, iframe copy, node URI
All four found by verifying on archi-dev-box rather than assuming.
container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.
lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.
Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.
lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.
Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.
Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (
|
||
|
|
5c9d5dc424 |
docs(state): record the on-node verification outcome and what stayed open
Names the four open items explicitly, including the one that is easy to lose: the reconcile fix is deployed but unexercised, so the node's 401 proves the template and not the delivery path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5a21b58f69 |
docs(security): record what actually closed :8334, and what it does not prove
The node is closed and verified 401 with origin-scoped CORS. But an
unrelated bitcoin-ui rebuild at 18:36 cleared the stale conf before the
reconcile fix was deployed at 19:06, so the 401 proves a05956c4's
template and NOT the delivery path
|
||
|
|
2684fa7cd1 |
docs(windows): close window 13 — host_secrets observed on a real node
system.stats on archi-dev-box returns host_secrets with verdict 'per-node' and three evidence lines (machine-id anchor 2026-04-09; every SSH host key and the TLS key newer than the anchor). Previously proven against the file contract in unit tests only. Honest limitation: this is one node, not the dev pair — archy-x250-dev has been offline for two days, so the second node is unreachable, not skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2e68384234 |
fix(lnd-ui,bitcoin-ui): canonical app icon, header stacking, square QR, button parity
Five review fixes across both node UIs. 1. LND now uses the app-store icon. It was shipping its own 182KB lnd.svg while the app store, My Apps and the signed catalog all render neode-ui/public/assets/img/app-icons/lnd.png (catalog.json points at /assets/img/app-icons/lnd.png). Same file is now vendored into the image, so the app header, the launcher and the store agree. That icon is a full-bleed square with an opaque white background rather than a transparent glyph, so it fills the frame and is clipped to the inner radius — exactly how bitcoin-ui frames its own icon — instead of being inset with padding on a dark plate. 2. Header no longer squishes at tablet widths. Both headers had a single 768px breakpoint, so between 768 and 1024 the title and description got crushed against the controls on the right (four status cards on bitcoin-ui) and overlapped. Both now use three breakpoints: fully stacked and centred below 768, logo + title on one row with the controls wrapped underneath below 1024, single row above. The app name and description are centred on mobile. 3. QR codes stay square. .conn-layout is a flex row on desktop and flex items stretch by default, so the white QR plate was being pulled to the height of the fields column and the square QR sat letterboxed in it. The plate is now a fixed square inside a black glass panel that absorbs the extra height, so the panel matches the fields and the QR stays square. 4. Buttons read as one family. The Settings button and both modal dismiss buttons used the flat .glass-button while every other button on the page used .info-card-button; they now all use the latter, via new .compact (inline) and .icon-only (square) variants so the shared style works at button size rather than only as a full-width card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ba493fb0fc |
docs(security): write up the Bitcoin RPC proxy that stayed open after it was fixed
The half that landed correctly (LND, clean 401) made the half that did not harder to notice, because the first check an operator would run returns a pass. Records the probes, the three-fact root cause, and the pass condition for re-probing a node. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7f40fa1e93 |
docs(windows): record the live bitcoin-ui RPC exposure as window 14
Verified live on archi-dev-box, code fix committed in
|
||
|
|
f6b5245b0d |
fix(security): deliver config fixes to a running app the marker calls uninstalled
Found while VERIFYING |
||
|
|
f4a323226e |
feat(bitcoin-ui): add Insights, Peers and Connect tabs for UmbrelOS parity
The Bitcoin UI was one long scroll: sync card, RPC/ZMQ cards and the relay-sharing panel stacked on a single page, with node details hidden in a modal. umbrelOS's rebuilt Bitcoin Node app splits the same surface across a Home/Insights/Settings dock and shows considerably more. Added, all of it additive — the sync state machine, the status-snapshot staleness logic and the relay-sharing panel are untouched and just move inside a tab panel: - Five tabs (Node / Insights / Peers / Connect / Sharing) using the same segmented control as lnd-ui, which becomes a fixed bottom dock under 768px with safe-area padding. - Insights: the four stats umbrel's StatSummary shows (Connections, Mempool, Blockchain Size, Node Uptime), a Latest Blocks strip, and Block Size / Fee Rate / Block Rewards charts — the same three umbrel plots, drawn as CSS bars so nothing has to load a chart library past the CSP's script-src 'self'. - Peers: sortable, filterable table with umbrel's exact columns (Peer, Network, Relay TXNs, In/Out, Connected) plus ping. Network is derived from getpeerinfo's own `network` field, falling back to address matching, so Tor/I2P/CJDNS/Local/Clearnet are labelled correctly. - Connect: RPC and P2P details with a Local/Tor selector, QR codes and per-field copy buttons (umbrel's ConnectionDetails), including its unencrypted-LAN warning on Local. Block statistics come from getblockstats, one call per block for the last ten, cached by height so only the new tip is re-fetched. The live tabs poll only while visible rather than adding a third unconditional 5s timer. Also removed the hardcoded "archipelago123" from copyRPCInfo. That string was never the node's actual RPC password — the real one is a manifest-declared generated secret rendered into this app's nginx upstream and deliberately never sent to the browser — so copying it could only ever mislead. The Connect tab says where the password actually lives instead. qrcode.js is vendored from docker/lnd-ui (same file, already CSP-clean) and added to the Dockerfile's COPY set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
341bff4b44 |
feat(lnd-ui): rebuild the LND app UI with UmbrelOS feature parity
The LND UI was a single scrolling page with a flat gradient instead of a background, a permanently-disabled "Wallet" card, and four tabs buried inside a Settings modal. Everything a node runner actually wants to see — routing revenue, peers, liquidity, activity — was absent. Rebuilt against the feature set of umbrelOS's Lightning Node and Bitcoin Node apps (getumbrel/umbrel-lightning, getumbrel/umbrel-bitcoin), rendered in Archipelago's own idiom rather than copying their visual design: - Real background. bg-web5.jpg was already being COPYd into the image by the Dockerfile and simply never referenced; it now drives the same perspective-layer + 0.8 overlay treatment bitcoin-ui uses. Paths stay relative because the app is served at / on :18083 but under /app/lnd/ when proxied by the host nginx, where absolute /assets 404s. - Glass cards with the masked gradient border, matching bitcoin-ui exactly. - Six top-level tabs (Overview / Channels / Activity / Insights / Connect / Settings) replacing the one-page scroll — umbrelOS's Home/Insights/ Settings dock, widened for Lightning. On mobile the bar becomes a fixed bottom dock with safe-area padding; on desktop it is a segmented control. - Overview: total/lightning/on-chain balances, a Max Send vs Max Receive liquidity bar (Umbrel's framing), and peers/channels/capacity/routing stat tiles. - Insights: routing revenue over 24h/7d/30d from /v1/fees, channel totals, network graph stats, and a sortable+filterable peers table with Umbrel's Tor/I2P/Local/Clearnet classification. - Activity: merged Lightning payments, settled invoices and on-chain transactions on one timeline, filterable by rail. - Connect: the existing lndconnect QR flow, plus a Node ID panel with the pubkey and advertised URI (umbrel's NodeIdModal). - sats/BTC unit switch persisted to localStorage (Umbrel's SatsBtcSwitch). Channel management deliberately links out to the existing Archipelago channels view at /apps/lnd/channels rather than being reimplemented here; this app only shows a read-only channel overview. Sync progress tracks the synced_to_chain/synced_to_graph booleans rather than inventing a block-based percentage, because LND exposes no IBD ratio. All data comes through the existing authenticated GET proxy at /proxy/lnd, so no backend, manifest or nginx change is required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a850bb6cdd |
docs(10-06): summary — Phase 10 complete, with the gate-effectiveness caveat
All five KEY-05 layers landed. Records the two things a reader would otherwise get wrong: - the 2 boot_reconciler test failures in the full-suite run are parallel-load flakes (4/4 pass in isolation), not regressions; - layer (b)'s clippy gate is LIVE but not yet EFFECTIVE, because 42 pre-existing warnings already fail the same -D warnings step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a3283cffb4 |
feat(10-06): enable the entropy lint and supply-chain gates (KEY-05 b/c)
Layer (b) — core/clippy.toml bans rand::random and rand::thread_rng crate-wide, each with a reason naming KEY-05 and pointing at the evidence doc. No CI change was needed: the Rust job already runs `cargo clippy --all-targets --all-features -- -D warnings` from core/, so a disallowed_methods hit is already a build failure. --all-targets covers tests deliberately — a fixture keeping the default is a template for the next production call site. Ordering was asserted before the file was written, not after: the residual count of unmigrated call sites is 0, so this cannot turn CI red for other agents on this shared tree. Layer (c) — core/deny.toml makes the rand major split change-detecting: global multiple-versions = "allow", a per-crate deny-multiple-versions for rand, and a dated grandfather skip pinning =0.9.2 exactly. The tree as it stands passes; a third version or a change to either member fails. Both gates were OBSERVED working, not assumed: - Reintroducing one banned call produced the disallowed_methods error with the reason text reaching the developer at the failure point; reverting returned the residual count to 0. - `cargo deny check bans` exits 0 as-is. Removing the grandfather entry made it exit 2 and print both dependency trees, independently confirming F-07's account of where each rand version comes from. Restored, it exits 0 again. Policy (checkpoint Task 5, human-approved): bans-only. The advisories gate is NOT enabled — it fails builds when a new CVE is published against an existing dep with no local change, which on a tree where several agents push continuously would block everyone at an arbitrary hour, with remediation often meaning a bump to an exactly-pinned crypto dependency. No break-glass procedure exists. F-07's advisory half stays OPEN and is recorded as such. cargo-deny is pinned to 0.20.2 and installed from crates.io rather than via EmbarkStudios/cargo-deny-action, because that action exposes no input to pin the tool version — an unpinned supply-chain checker would reintroduce, at the CI layer, the exact "backend fixed by configuration rather than stated" shape this plan exists to remove. crates.io is also the source vetted at the Task 5 legitimacy gate (EmbarkStudios, repo resolves, ~4.79M downloads). RECORDED HONESTLY: layer (b)'s gate is live but not yet EFFECTIVE. The tree carries 42 pre-existing clippy warnings — unused imports, dead code, ~39 style lints — that are already errors under -D warnings, so that CI step cannot pass today for reasons unrelated to KEY-05. Until a dedicated lint-clearing pass lands, a new banned RNG call would be one error among many rather than a distinctive build-stopper. Pre-existing and out of scope; clearing it right before an OTA would be poor sequencing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c966395eb9 |
fix(security): never auto-publish the wallet UI proxies as Tor onions
Found while checking whether the /lnd-connect-info leak ( |
||
|
|
0a1d314ffa |
feat(security): add LND macaroon rotation for the /lnd-connect-info leak
Operator tool for the fix in
|
||
|
|
09a1f7621c |
feat(10-06): name every entropy source and guard key draws (KEY-05 a/d)
Closes F-10a. Nothing here fixes a present defect: on the pinned rand 0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG seeded from getrandom(2). What they lack is a STATED backend — it is fixed by dependency and build configuration rather than by the calling code, with no compile error if that changes. That is the structural shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast radius includes Cashu blinded-key-exchange values, X3DH prekey material, session bearer tokens and a ChaCha20-Poly1305 nonce. Layer (a) — every production key, nonce and token draw now names rand::rngs::OsRng at its own call site. The mnemonic seam is bound to entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a private module, so the set of RNGs that can drive the master key hierarchy is exactly what one file says it is. This retires the false promise at seed.rs:656: rand::CryptoRng is a marker with no compiler-checked content, and the crate now contains zero impls of it. Layer (d) — key material and AEAD nonces of >=12 bytes run a degenerate-entropy predicate that refuses all-zero, all-identical and wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no chi-squared. Each of the three shapes has a false-positive probability computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and a predicate whose false-positive rate cannot be computed cannot be argued safe on a key-generation path. There is deliberately no retry — a retry would paper over the broken RNG this exists to surface. Layer (e) — the kernel-CSPRNG readiness verdict at master-seed generation is now durable (backlog R-09). It was previously computed, logged and thrown away, so a node could never answer after the fact whether its keys were born from a seeded pool. The record holds a schema version, timestamp, verdict and event name — no entropy, no key bytes. Formats and wire shapes are proven unchanged rather than asserted: storage_crypto and the credential store each open a HARDCODED pre-migration ciphertext vector (a same-process round trip would pass even if the envelope had changed), the vector was produced by an independent RFC 8439 implementation so it pins the documented nonce||ciphertext format rather than this implementation's output, and the x3dh prekey bundle and bdhke values keep their field set and order. totp.rs migrates its SOURCE only: the % charset.len() reduction and the 32-char charset are untouched. The bias there is presently zero (32 divides 256) and fixing the latent bias is R-12, which stays deferred. Verified: cargo build clean; cargo test -p archipelago 1068 passed, 2 failed. Both failures are container::boot_reconciler timing tests (second_pass_fires_after_interval, shutdown_terminates_loop) in a file this change does not touch — pre-existing, not caused here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a05956c4ce |
fix(security): require a session for the LND connect info and Bitcoin RPC proxies
CRITICAL. Two app-UI ports handed unauthenticated callers full control of the node's money. Both verified live on archi-dev-box 2026-08-02 over the fips0 mesh ULA with no cookies. GET /lnd-connect-info returned 200 with the LND ADMIN MACAROON, the TLS cert, the gRPC/REST ports and the node's onion address — a complete remote wallet-drain package, and the onion means an attacker keeps that ability after losing network access. POST /bitcoin-rpc/ reached Bitcoin Core RPC with credentials the proxy injected on the caller's behalf, with a wallet loaded, so wallet methods were reachable too. Both were reachable because ports 18083 (lnd-ui) and 8334 (bitcoin-ui) bind 0.0.0.0 AND sit on the fips0 mesh allowlist in fips/app_ports.rs. Any mesh peer, LAN host or Tailscale peer could take either path. The root cause is one mistaken idea in two places: that a check performed by a reverse proxy is an auth check. It is not — it only holds for traffic that arrived through that proxy. /lnd-connect-info's comment said "nginx validates session cookie (presence check), backend is bound to 127.0.0.1 so only nginx can reach it". Both clauses were false in production: the lnd-ui container runs its OWN nginx on :18083 that proxies straight to the backend forwarding whatever cookies arrived, including none, and that second front door never performed the check the premise named. So authorisation moves to the resource: - /lnd-connect-info now requires a session, like /proxy/lnd/ beside it. The 401 carries CORS headers so the wallet UI shows a readable error rather than an opaque CORS failure. - New GET /auth/session-check returns 204/401 and nothing else, giving container nginx an auth_request gate it can actually use. - bitcoin-ui's /bitcoin-rpc/ is gated by that auth_request. Its `Access-Control-Allow-Origin *` is also gone: on a proxy that injects credentials, it let any page a user visited drive the node's RPC. Preflight is answered before the gate, since OPTIONS carries no cookies. The nginx template is include_str!'d and re-rendered on every reconcile pass, so this ships atomically with the binary. Operators must treat the LND admin macaroon and the Bitcoin RPC password on every affected node as compromised and rotate them AFTER this is deployed — rotating first just re-leaks through the same hole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
689c4cca1a |
docs(10-04): complete fleet host-secret detection and rotation plan
SUMMARY for 10-04, plus three WINDOWS.md entries (11-13) so the unverified items stay visible at ship time: the rotation never exercised on real hardware, host_secrets never observed in a live system.stats, and the C-3 finding itself — three live nodes still on shared SSH host keys, two of them also sharing a TLS private key, none of them rotated. STATE.md and ROADMAP.md deliberately not touched: both carry other agents' uncommitted work in this shared tree and the orchestrator owns them for this wave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a806a658a6 |
docs(10-04): C-3 FAILED — three live nodes share their SSH host keys
Audit checklist item C-3 ("the highest-value check here") is no longer
UNVERIFIED. It failed, and the failure is a live F-03 instance rather than a
theoretical one.
Three distinct fleet nodes — archipelago-1, archy-x250-beta and archipelago —
present byte-identical ECDSA, ED25519 and RSA host key fingerprints. Two of
them (archipelago-1, archy-x250-beta) also present the same TLS certificate,
so they share the TLS private key as well.
Gathered read-only and remotely: ssh-keyscan plus an anonymous TLS handshake.
No node was logged into, nothing was written, nothing was rotated. A weaker
instrument than the checklist's on-node commands, chosen because it needs no
access and therefore covers the reachable fleet rather than two nodes — and it
is sufficient for the FAIL condition, which is any fingerprint appearing twice.
Ruled out the obvious alternative (one machine registered three times on the
tailnet): all three answered live TCP within the same minute, and tailscale
ping resolves them to different physical endpoints on different continents
under different tailnet accounts.
One finding worth more than the count: `archipelago` has a UNIQUE TLS cert
(CN=austin-sapien) and SHARED SSH host keys, because it was renamed and
server.set-name re-mints the cert via regenerate_tls_cert() while touching
nothing else. So TLS uniqueness is not evidence that a node's key material is
per-node — any renamed node gets a unique certificate for free. Checked on TLS
alone, that node would have looked clean. Recorded because it justifies the
audit script reporting the two key classes separately instead of issuing one
node-level verdict.
All three are listed under "shared verdict, deliberately not rotated" with the
reason and the next step. A verification task that remediates is a
verification task that takes a node offline.
Also records what this does NOT establish, each with the evidence still
needed: same-ISO provenance, the script's own verdict on those nodes, that a
rotation preserves the operator's live session on real hardware, that
host_secrets reaches system.stats on a real node, and the four nodes that were
unreachable at scan time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
373c3bb302 |
fix(10-04): ship the host-secrets audit unit in the OTA runtime payload
[Rule 3 — blocking] bootstrap.rs installs systemd units from the runtime payload at image-recipe/configs/, but create-release-manifest.sh copies only archipelago-doctor.service and .timer into that directory. The new archipelago-host-secrets-audit.service would therefore never exist on any node: bootstrap looks for it, `src.exists()` is false, and it silently installs nothing. No error, no log line — the whole deployed-node half of 10-04 would have been inert on arrival. Two enumerations of the same list in two languages in two files is the drift that caused it, so the loop now carries a KEEP IN SYNC pointer naming the array in bootstrap.rs, and the redundant `if [ -f doctor.service ] || [ -f doctor.timer ]` wrapper is gone — the per-unit `-f` test inside the loop already does that job, and the wrapper would have skipped the whole block on a tree that had the new unit but not the doctor ones. Outside 10-04's declared files_modified. Taken because the alternative was to ship a deliverable that cannot reach its target and file the gap as a follow-up. Staged by path; no other agent had uncommitted work in this file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ed9334f15 |
feat(10-04): let a deployed node report — and fix — fleet-shared host keys
10-03 closed the build half of F-03: the ISO no longer bakes SSH host keys or
a TLS keypair into the shared rootfs, and first-boot regeneration fails closed.
Nodes already in the field receive none of that — the first-boot script is
installed by the installer, not shipped by OTA — so a node that hit the old
fail-open path is still running key material that every downloader of its ISO
also holds, and its completion marker guarantees it will never try again.
scripts/security/host-secrets-audit.sh decides, from the node's own disk alone,
which of those it is. Four signals in a fixed precedence: missing material can
never be shared material; the fail-open fingerprint (marker present plus the
literal `WARNING: TLS regeneration failed` / `WARNING: ssh-keygen -A failed`
lines the old script emitted) is direct evidence and outranks timestamps and
also names WHICH class survived; then key mtime against a first-boot anchor
(.secrets-regenerated, falling back to the installer's LUKS key then
machine-id). Verdicts are per-node / shared / fail-closed-missing / unknown,
and every one of them carries the evidence strings that produced it, each
naming the file it was read from.
per-node is never claimed from an absent signal. No anchor means `unknown`, and
a standing first-boot-secrets.failed record also means `unknown` — a clean
mtime is not evidence that generation succeeded. That is T-10-37: a false
per-node verdict leaves an exposed node looking clean, which is worse than no
verdict at all.
Rotation (D-06: detect-report-then-apply, recorded in
docs/security/KEY-02-FLEET-ROTATION.md):
- --detect is the default and is read-only; it always exits 0, because
detection is informational and must never fail a boot.
- --apply without --yes writes nothing at all, not even its own verdict file.
"Touches nothing" is worth being able to say without a footnote.
- --apply --yes refuses unless the verdict is `shared`, so the wrong node
cannot be rotated even deliberately.
- It stages the full replacement TLS pair AND host-key set before touching
anything live and aborts if either fails; records the OLD fingerprints
before the swap; does TLS first (a dead web UI is recoverable over SSH, the
converse is not); replaces host keys by mv-onto-the-existing-path rather
than rm-then-mv, so the directory is never momentarily empty; and RELOADS
sshd, never restarts it, so the operator's own session survives its own
rotation.
bootstrap.rs ships the boot unit through the existing run_runtime_assets
promotion and enables it --now, so the verdict lands with the OTA rather than
at the next reboot. handle_system_stats gains a host_secrets object read from
the on-disk verdict — cheap, never an error however malformed the file, and
deliberately carrying no fingerprints, because a payload polled every few
seconds does not need digests an operator on the node can already read.
tests/first-boot-secrets/rotation-tests.sh: 8 cases against temp roots through
the HOST_SECRETS_ROOT seam. Negative controls run and reverted, each reddening
exactly one case: dry run writing its verdict file (STATE-DIR-CHANGED); the
old fingerprints recorded after the swap instead of before (caught by an
ordering observation, not a content comparison — the systemctl stub records
whether the file existed at the moment of the first reload); a tolerated
generation failure leaving a half-rotated node; and `per-node` claimed with no
anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
78b3ec879b |
docs(01-18): Task 1 deploy verified on archi-dev-box; six-fix sign-off still open
Deployed the frontend to archi-dev-box only (--frontend-only, no fleet, no alpha-tester, no Tailscale, no OTA, no release) and proved all six UIFIX fixes are in the bundle the node actually serves. - Resolved the live chunk set from sw.js first: /opt/archipelago/web-ui/ assets keeps every prior deploy's hashed chunks, so a naive disk grep returns hits from dead chunks and would have produced a false pass (threat T-01-83, and it was a real trap here). - Fetched each live chunk over HTTP from http://archi-dev-box and grepped it: all eight probe strings for UIFIX-01..06 PRESENT. - Real Chromium boot check on the node at 1440x900 and 390x740: app mounts, 0 console errors, 0 page errors, 0 failed requests. - archy-x250-dev recorded as an explicit gap: offline, last seen 2d ago, no MagicDNS record; still has neither this plan set's nor phase 2's frontend. Task 2's six numbered checks are all recorded NOT VERIFIED. They need an authenticated session on the node (UI returns 401 / redirects to /login, and no credential was guessed against a node holding real funds), and two of them are not testable as the node stands: it owns exactly one purchased item (image/jpeg) and has zero video and zero audio content anywhere, so the purchased-video, purchased-music and picture-in-picture checks have nothing to open. No source file modified, no fix applied inline, and STATE/ROADMAP/ REQUIREMENTS deliberately left untouched - UIFIX-01..06 are NOT closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0214114c7b |
docs(10-02): summary — probe built and committed, C-6 still UNVERIFIED
Task 1 done. Tasks 2 and 3 are blocked on unmet preconditions and were
NOT auto-approved: no fleet node runs 10-01's gate (installed binary
predates
|
||
|
|
f2f89b5fe3 |
docs(10-02): record C-6 evidence so far — probe-method correction, 3 transports still open
C-6 is NOT closed by this commit and is not marked verified.
Measured (read-only, on-node):
- loopback and self-LAN-IP: auth.isOnboardingComplete 200 (EXPOSED),
seed.status 401 (session enforcement intact) — no stop-the-plan finding.
- /rpc/ returns 404: nginx's second proxy block is not a second door, so
the unauthenticated surface is reachable through /rpc/v1 only.
NOT measured — needs a second machine: LAN, Tor, FIPS mesh ULA.
NOT performed — the KEY-01 refusal check and the fresh-node onboarding
walkthrough. No node runs 10-01's gate yet: the installed binary was built
at 06:37 and
|
||
|
|
257ca7e6ac |
docs(10-06): classify all 43 defaulted-RNG call sites with file:line evidence (KEY-05, F-10a)
F-10a recorded raw grep counts and deliberately declined to classify them. This resolves that: every one of the 43 matches under core/archipelago/src now carries a production/test verdict (evidenced by its file's `#[cfg(test)] mod tests` line), what the drawn value becomes, whether the degenerate-entropy guard applies, and a disposition. Tally: 41 migrate, 2 comment, 0 allow. No site needed an exemption, so the crate-wide ban will have no holes to audit. Two corrections to F-10a, each derived independently with its evidence line: session.rs is 4 production sites not 16 (mod tests begins :471), and mesh/x3dh.rs:100/:114 are u32 prekey identifiers, not key material -- the X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113. The enforcement blast radius is pinned with `cargo metadata` output rather than asserted: models, helpers and js-engine are not workspace members, so the two core/models matches are outside the clippy build graph and are recorded as a stated limitation rather than omitted. Requirement: KEY-05. Supersedes R-13, absorbs R-05 and R-09. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
96dba73a16 |
docs(10-04): record D-06 rotation trigger as detect-report-then-apply
Task 1 of 10-04 is a blocking decision checkpoint, rated one-way: rotating a node's SSH host key invalidates every known_hosts entry for it fleet-wide and the old private key is destroyed by the swap. Chosen: detect-report-then-apply. auto-on-boot would fire simultaneous known_hosts breakage across the fleet during an OTA with no operator holding the new fingerprints, and a rotation that fails partway on a remote node (.228 is at a remote site and in real use) needs physical console access. It also cannot be dev-paired, which contradicts the standing verify-on-the-dev-pair- first policy — by the time it has been observed on the dev pair it has already run everywhere. The cost of the chosen option — exposure persists on any node nobody revisits — is bounded by making the verdict visible in system.stats rather than by automation, and by keeping a list in this document of every node that reported `shared` and was deliberately not rotated. Records what the decision binds: detect-only default, --apply inert without --yes, the boot unit carries no apply path, and --apply --yes refuses on any node whose verdict is not `shared`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
527f602322 |
feat(10-02): add read-only-by-default RPC exposure probe (C-6 / KEY-01)
- Measures EXPOSURE (auth.isOnboardingComplete) and SESSION ENFORCEMENT (seed.status) separately; the audit's C-6 probed with seed.status alone, which is not allowlisted and returns 401 by design, so its "Fail: 200" criterion could never fire. - Read-only by construction: methods come from a fixed READONLY_METHODS array, never from an argument; the one mutating request is behind --destructive with a red disposable-nodes-only banner. - The refusal check uses the published BIP-39 all-abandon/art test vector, so no real key material is ever generated, handled or printed. - No node address, onion address or credential embedded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cdad880629 |
docs(10,01): record the summaries for the four completed plans
Demo images / Build & push demo images (push) Successful in 3m15s
Written by the previous session's executors for 01-17, 10-01, 10-03 and 10-05, all of which are complete and whose code is already committed. The session was cut off by a dropped SSH connection before these were staged, so they were sitting untracked. Recording them so the phase history is not lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c5a82cba06 |
fix(credentials): mark the encrypted store so a random nonce cannot fake plaintext
The on-disk format was detected by sniffing the first byte for `[` or `{`.
Encrypted blobs begin with a random 12-byte nonce, so roughly 1 in 128
saves produced a valid encrypted file whose first byte was 0x5B or 0x7B;
those were misread as plaintext JSON, failed `String::from_utf8`, and the
store became permanently unreadable. This was surfacing as a flaky
`test_list_credentials_no_filter`, but it is a real data-loss bug: a node
whose ciphertext happened to start with one of those bytes could not load
its credentials.
Writes now carry a fixed `ARCHYCRED1` marker, which cannot collide with a
random nonce, so detection of the current format is exact.
Legacy unmarked files are detected by SUCCESSFUL AEAD DECRYPTION rather
than by another byte sniff. A verifying Poly1305 tag under the node key is
a cryptographic discriminator (~2^-128 false-positive rate), strictly
stronger than any structural guess — which is why the deferred item's
suggested "keep the first-byte sniff as the legacy fallback" was not the
shape adopted. Plaintext JSON remains the last resort, and is still
reachable on a node that has no node key at all.
An undecodable file now errors instead of returning an empty store, so a
transiently unreadable file is never silently replaced by an empty one
that the next save would commit to disk (CLAUDE.md: migrations never
destroy data). Legacy files upgrade on write, never on read.
Tests drive the collision deterministically via an explicit nonce rather
than waiting on the 1-in-128 draw, and cover all three on-disk
populations, the read-path-does-not-rewrite guarantee, and tamper
rejection. 28 passed, 0 failed.
Closes the 10-01 deferred item.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
937d836c53 |
fix(01-05): delete the redundant second periodic federation sync loop (FED-02)
Two near-identical periodic federation sync loops were running side by side. git history shows the overlap was accidental, not load-bearing: the 30-minute loop landed first ( |
||
|
|
dad40c23f1 |
fix(10-03): prove the first-boot TLS key and cert are actually a pair
Parsing each half back proves each is well-formed; it never proves they belong together. A key from one generation beside a cert from another passes both individual parse checks, gets blessed, and then nginx refuses to start at the exact moment the marker claims first boot succeeded. gen_tls() now extracts the public key from each half and compares them before the swap, and needs_tls() applies the same check to what is already installed, so a mismatched pair that reached disk some other way (an older build, a half-finished manual edit) is repaired instead of quietly breaking nginx. Extraction subsumes parsing, so this replaces the separate -noout parse checks rather than adding to them. Kept deliberately in step with regenerate_tls_cert() in core/archipelago/src/api/rpc/system/handlers.rs, which does the same comparison on the running node after a rename. Test harness: the openssl stub keypair now carries the generation it came from, and STUB_OPENSSL_MISMATCH emits a cert from a different one — the pair that passes both parse checks and still breaks nginx. New case 9 covers both directions: fail closed when the mismatch arises during generation, repair exactly once when found already on disk, and no spin on the run after either. 9/9 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ae55db38d4 |
fix(tls): make cert regeneration on rename atomic and validated
regenerate_tls_cert() passed -keyout /etc/archipelago/ssl/archipelago.key and -out .../archipelago.crt, so openssl wrote straight into the files nginx is serving from. If openssl died partway, was killed, or the disk filled, the live key and cert were already truncated — a routine `server.set-name` could take HTTPS down with no way back. Reproduced: the live key goes from a valid 2048-bit PEM to 33 unparseable bytes. Mirror the discipline gen_tls() already uses in the ISO builder: generate into .new siblings of the destinations (same directory, so the final mv is a rename(2) and therefore atomic), parse both halves back with `openssl pkey` and `openssl x509` and compare the extracted public keys to prove they are valid and belong together, and only then swap them in. On any failure the existing key and cert are left byte-for-byte untouched and the error is returned. Staging files are cleared before the attempt and on every exit path, success or failure. Permissions: the staging key is created by `install -m` carrying the live key's own mode and owner *before* openssl writes into it (openssl truncates an existing -keyout file rather than recreating it), so the new private key is never group- or world-readable, not even between generation and a chmod. A live mode that grants group/other any access is not reproduced — the key falls back to 0600 — so the swap can never widen permissions. Cert content and parameters are unchanged: same subject, same SAN construction, same rsa:2048, same 3650 days. This is an atomicity and validation fix, not a crypto change. Testing seam: the hardcoded sudo prefix and absolute paths made this untestable, so the logic moved into a small TlsMaterial struct holding the ssl dir, the openssl binary path and a privileged flag. Production is TlsMaterial::production(); tests point it at a temp dir, drop sudo, and substitute a stub openssl. Against the pre-fix shape the two atomicity tests fail (live key modified; garbage accepted); against this change all five pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
879de59ecc |
fix(10-01): gate identity-mutating onboarding RPCs on provisioned nodes (F-01)
Closes F-01 (Critical) of docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md. seed.generate/seed.restore/seed.save-encrypted/backup.restore-identity/ auth.setup are all in UNAUTHENTICATED_METHODS, and several reach NodeIdentity::from_seed or restore_encrypted_backup, which overwrite identity/node_key unconditionally. One unauthenticated POST from the LAN or from any FIPS mesh peer hijacked a live node's Ed25519 identity, Nostr node key and FIPS transport key. - new api::rpc::onboarding_gate::ensure_onboarding_open: refuses once ANY of is_setup() / is_onboarding_complete() / seed_exists() says provisioned, failing safe on I/O errors. NodeIdentity::key_exists is deliberately NOT a signal — server.rs:63-71 writes a temporary key on every boot, so a gate keyed on it would refuse seed.generate on a never-onboarded node. Pinned by allows_on_fresh_temp_dir_even_though_node_key_exists. - ensure_user_account_exists: the inverse guard for auth.onboardingComplete, which is unauthenticated and sets the flag the gate reads — without it, one call locks a fresh node out of its own onboarding. - seed.restore body extracted to restore_node_identity_from_words so the regression suite drives the real path; seed.verify left open with a written verdict (non-mutating). - refusal text begins "Not supported:" so it survives sanitize_error_message and names the authenticated system.factory-reset recovery path. - per-method rate limits for the four onboarding mutators, sized ~6x the measured client retry budget so a 429 cannot reintroduce the error at the DID-creation screen. First-boot onboarding is untouched: all three signals are false throughout the seed steps, and auth.setup runs last (Login.vue:405-425). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49345b67ed |
fix(openwrt): clear all 4 clippy lints so the CI -D warnings gate is real
CI (.github/workflows/ci.yml) already runs `cargo clippy --all-targets --all-features -- -D warnings`, but archipelago-openwrt emitted 4 warnings on a clean checkout, so the gate was red by default and enforced nothing. Fixed each lint at the source; no #[allow] added. - clippy::cmp_owned (wan.rs:146) — dropped the .to_string() that built an owned String purely to compare against "1"; &str == &str compares the same content. - clippy::unnecessary_sort_by (wifi_scan.rs:75, :177) — replaced sort_by(|a, b| b.signal.cmp(&a.signal)) with sort_by_key(|n| std::cmp::Reverse(n.signal)). Both are stable descending sorts on signal, so tie order is unchanged. Deliberately NOT -n.signal, which would misorder i32::MIN. - clippy::trim_split_whitespace (wifi_scan.rs:156) — removed the .trim() before .split_whitespace(); the latter already skips leading/trailing whitespace and never yields empty items, so parsing is unchanged. All three are semantics-preserving rewrites: no change to comparison results, sort ordering, or channel parsing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6ed876376a |
docs(todo): onboarding step to name your node (sets the real hostname)
Backend already exists: server.set-name runs hostnamectl set-hostname and regenerates the TLS cert with a SAN for the new name. This is a UI step. Records the hazard that decides the design: renaming changes both the mDNS .local name and the TLS cert, so a rename mid-flow can drop the user's session in the middle of onboarding — potentially between seed generation and seed verification. Placement is therefore a design decision, with three options laid out (last-before-Done, first, or collect-early-apply-late). Also flags RFC-1123 slugification (users will type "Dorian's Node"), whether the rename propagates to the Reticulum display name and mesh surfaces, the reconnection UX, and whether the step is skippable. Sequenced after the in-flight regenerate_tls_cert atomicity fix, since renaming is exactly the path that fix protects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0d513a0ef7 |
docs(10-05): record the Core-wallet fleet census — 4 nodes clear, 6 unchecked (D-07b)
Task 3 of plan 10-05, run by the operator over Tailscale on 2026-08-02 using the read-only procedure in KEY-03-SIGNING-POSTURE.md. No escalation: nothing found. Examined and CLEAR (4): archi-dev-box, shorty-s/.228, archy-x250-beta, archy-x250-pa. On every one there is no wallet named `archipelago` — the deleted handler's default wallet_name — `listwallets` returns only the unnamed default, and that default reports blank=true, keypoolsize=0, txcount=0, balance=0. The only named wallets are Fedimint gatewayd-*. The result holds across two container vintages (bitcoin-knots and bitcoin-core), so it is not four copies of one image behaving identically. Not examined (6), recorded with reasons rather than omitted: framework-pt, archipelago-1, archipelago and archy-dev-pa (SSH permission denied — password rotated/not held), archipelago-5 (timed out during banner exchange), and archy-x250-dev (offline). Password auth was deliberately not attempted: 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. The conclusion is stated at the strength the evidence supports — no *examined* node holds a wallet the deleted handler created, and no examined node holds any wallet with keys or funds. It is deliberately NOT generalised to "the fleet is clear" while six nodes are unknown. F-13 is closed by deletion regardless: the code that could create such a wallet is gone from every future build. No key material appeared in any output and `listdescriptors true` was never run. Also corrects the now-stale R-04/F-13 entry in UNIFIED-TASK-TRACKER.md, which still described `handle_bitcoin_init_wallet_from_seed` and a watch-only migration as pending work — that code no longer exists. Marks it done-by- deletion and adds the six unchecked nodes as a standing item, flagged as a natural fold-in for KEY-04's on-node work but tracked independently so it does not vanish if KEY-04 is re-scoped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
454388226c |
feat(01-05): surface federation sync failures to the operator (FED-02)
A failed federation sync existed only as a `debug!` line on the node, so a peer that had not synced in days looked identical in the UI to one that synced a minute ago. Now the failure is persisted per peer and rendered. - `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side mirror of the existing `last_transport` / `last_transport_at` pair. - `federation::record_sync_result(data_dir, did, outcome)` — records the message on `Err`, CLEARS both fields on `Ok` so the badge disappears when the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner` load/save convention established by plan 01-01. An unknown DID is a silent Ok that writes nothing, so a peer removed mid-pass is never resurrected by an in-flight sync's error write. Skips the save entirely when nothing changed, keeping the steady state read-only rather than rewriting nodes.json (and contending for the lock) every 90s. - Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not bytes so truncation cannot split a UTF-8 sequence (T-01-18). - The 90s auto-sync loop calls it on both arms; the existing `debug!` line is kept — persisting is additive, not a replacement for logs. - `federation.list-nodes` emits both fields when set, omits them when unset. - NodeList renders a red SYNC badge beside the transport badge on both the trusted-node and peer rows, message + age in the `title` so the row stays single-line. Tests (written first, confirmed failing — 16 compile errors, E0425 on `record_sync_result` and E0609 on `last_sync_error`): - persists_error / success_clears_error / missing_did_is_noop / on_empty_store_is_noop / truncates_long_error - NodeList: badge present when set, ABSENT when unset (the guard against a badge that always renders), and present on an observer peer row. cargo test -p archipelago federation — 42 passed, 0 failed. vitest NodeList.test.ts — 4 passed. npm run build — green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
262998747e |
feat(10-05): report BIP-32 key origin on lnd.create-psbt, and record the honest signing posture (D-07b/D-09)
With Bitcoin Core's wallet deleted, LND's PSBT round trip is the only external-signer path Archipelago has, and D-09's key-origin protection moves from Core descriptors (of which none remain) to the PSBT itself. Adds `psbt_key_origin_report(&str) -> Result<PsbtKeyOriginReport>` to lnd/wallet.rs, reporting `input_count`, `inputs_with_key_origin` and `all_inputs_have_key_origin`. An input counts as carrying key origin when either its `bip32_derivation` or `tap_key_origins` map is non-empty. A PSBT with zero inputs reports false rather than vacuous truth. Parsed with the already-present `bitcoin` and `base64` crates; no dependency added. `lnd.create-psbt` gains an additive `key_origin` object on its response and a `tracing::warn!` with the counts when key origin is missing, because that is the exact condition under which a hardware signer refuses the PSBT. Computed best-effort: a decode failure degrades to `null`, never to an error, so a user's send cannot fail because an inspection helper could not parse something. `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (which deliberately auto-signs with LND's hot keys) are untouched. Three tests, with fixtures built programmatically from the `bitcoin` crate rather than pasted as opaque base64: with-derivations, without-derivations, and malformed-is-an-error-not-a-panic. KEY-03-SIGNING-POSTURE.md gains an honest per-step coverage map of the fund -> export -> sign offline -> import -> finalize -> broadcast round trip. Of six steps, only the new inspection has automated coverage; steps 1, 4, 5 and 6 have none, and there is no air-gap transport (no animated QR, no .psbt file exchange) — export/import is copy-paste of base64. Untested paths are named as untested. Records the verdict that decides whether any of this is an air gap: on a default node an external signer CANNOT meaningfully sign a PSBT from `lnd.create-psbt`, because LND holds the keys for every input it selects. Evidence: the PSBT is funded from LND's own wallet; `ensure_wallet_initialized` creates a full key-holding wallet via /v1/initwallet; the generated lnd.conf carries no `remotesigner.*` block; and a search of apps/, scripts/, core/archipelago/src and image-recipe/ for remotesigner/createwatchonly/ nochainbackend returns zero matches. No fleet node is provisioned watch-only. What ships is PSBT transport, not air-gapped custody — the gap is provisioning, not plumbing. Adds 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. Also adds a status banner to PSBT-SIGNING-ARCHITECTURE.md recording that its Phase 1 was superseded by deletion rather than delivered, so §0's "single highest-value change" and §2.1's invariant now read against a code path that no longer exists. Banner only; §5.4's honesty table is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
40b77e392a |
fix(10-03): don't bless a cert minted under an untrustworthy clock
The failure fail-closed cannot catch, because generation SUCCEEDS. This unit runs very early (DefaultDependencies=no, Before=ssh/nginx), long before time has synced. `openssl req -x509` stamps notBefore from whatever the clock says, so on a node with a dead RTC or a flat CMOS battery the cert can be years out: clock ahead -> clients reject it as "not yet valid", a harder failure than the usual self-signed warning; clock behind -> notAfter is already in the past once time syncs. The completion marker was then set and never revisited — a node permanently serving a cert nothing accepts. Finding 1, reported rather than assumed: this image does NOT use systemd-timesyncd. It installs and enables chrony, and chrony-wait.service — the unit that is Before=time-sync.target — is not enabled. So time-sync.target is inert here and ordering After= it would buy nothing. Enabling chrony-wait to make it meaningful would stall boot behind NTP on a node with no network, and these nodes are routinely offline at first boot. Not deadlocking boot outranks cert-date elegance, so the ordering is deliberately left alone. Fixed locally instead, in two parts: 1. Backdate notBefore by 24h so ordinary skew between node and client cannot invalidate a fresh cert. -not_before/-not_after arrived in OpenSSL 3.5 and the rootfs is debian:trixie which ships it, but the capability is PROBED, not assumed — guessing wrong would fail every attempt and brick the node, the exact outcome all of this exists to prevent. Without the flags we simply do not backdate and rule 2 still covers the dangerous case. 2. Refuse to bless a cert dated by a clock outside a plausible window (2026-01-01 .. 2056-01-01). The material stays installed so the node is usable and sshd comes up, but the bad dates are recorded as failed=cert-dates and the cert is regenerated automatically once time syncs. Generation is now driven by need rather than by "is the marker absent", and ConditionPathExists=! is removed from the unit so a node that already completed can still be re-examined — skipping the unit is precisely how such a node stays broken forever. The script exits in milliseconds when everything is fine. Anti-spin is one condition: a date-driven regeneration happens ONLY when the clock is currently plausible. A node whose clock is still wrong re-checks and mints nothing. Regression caught while writing this: driving generation purely by content made needs_ssh() false whenever any host key existed, which would have left an image-baked fleet-shared key in place forever — F-03 reopened. The marker check is back in both needs_ functions and case 1 (which prestages a baked key and asserts it was replaced) is what caught it. Case 8 covers mint-under-wrong-clock, repair-after-sync, and both spin directions. Controls: blessing regardless of clock reddens only case 8 (run1-BAD-DATES-NOT-RECORDED); removing the anti-spin guard reddens only case 8 (SPINNING-reminted-while-clock-still-wrong(1->2)). The second control initially passed against a broken guard because the assertion compared certificate dates, and a re-mint under a frozen clock produces a byte-identical notBefore — the assertion now counts mints, which is the only thing that distinguishes "left alone" from "regenerated again". Not covered here: nodes already deployed from earlier ISOs never receive this script (it is installed by the installer, not by OTA), so fleet remediation for them remains 10-04/OTA work in core/**, which is held by other executors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9622926868 |
fix(10-05): delete the Bitcoin Core wallet path that duplicated the spending key (F-13, D-07b)
`handle_bitcoin_init_wallet_from_seed` derived the BIP-84 account extended *private* key, stringified it, and imported `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)` into a Bitcoin Core descriptor wallet created with `disable_private_keys=false` and an empty passphrase. That put a second copy of the node's spending key in Core's `wallet.dat`, outside the daemon's Argon2 + ChaCha20-Poly1305 envelope. That duplication into weaker protection was audit finding F-13 (High). Deleted rather than rewritten watch-only (D-07b supersedes D-07/D-07a): - No caller anywhere. Repo-wide search leaves exactly one occurrence of the method name (its own dispatcher registration) and two of the symbol in code (definition + dispatch call); every other hit is prose in docs. - LND is the wallet the product drives. Across neode-ui/src every `bitcoin.*` call is read-only status (getinfo/prune-status/onion); the wallet UI sends via `lnd.sendcoins`. - It never ran on archi-dev-box: no wallet named `archipelago` exists there, and the one loaded wallet reports blank=true, keypoolsize=0, txcount=0. - It was authenticated AND password-gated, so F-13 was key-at-rest duplication, not an exposed endpoint. No migration is performed and none is planned. This removes code, not wallets: nothing on disk is touched, no funds move, no wallet.dat is modified. If a node is ever found holding a wallet this handler created, that is a finding to surface and stop on, not a trigger to auto-migrate. `seed::derive_bitcoin_xprv` loses its only non-test caller and is retained deliberately with `#[allow(dead_code)]` and a stated reason: it keeps its existing test coverage and it is the derivation D-07c's deferred BDK cold vault will need. Records the evidence, the D-08/D-09 consequences and the D-07c deferral in docs/security/KEY-03-SIGNING-POSTURE.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8255b69af2 |
fix(01-17): pin the FIPS/Tor pills and let the peer-card badge row wrap (UIFIX-01)
Audited every transport-pill render site in the cloud surfaces at 390x740
and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer
cards, PeerFiles.vue header) and both already appear on a phone; three
file-level sites carry none, by decision recorded in the SUMMARY.
- Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport
badge. Measured at 320px, a longer trust label squeezed the badge until
its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a
second line intact. Inert whenever the row fits, so desktop is unchanged.
- New TransportPills.test.ts: one site-specific assertion per render site,
so removing a pill fails the build. Dorian asked that these never be
removed in a future cleanup; nothing in the repo pinned them before.
- Unknown-transport cases assert no pill is fabricated (T-01-78), and the
labels/colours are asserted against PeerFiles.vue's canonical mapping
rather than a duplicated table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d9b3a7d5e0 |
fix(10-03): quote the Dockerfile heredoc so comments cannot execute
`cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE` was unquoted, so the build shell performed command substitution on the Dockerfile body. Any backtick in a Dockerfile COMMENT was executed on the build host and its output spliced into the generated file. Six comments did this. One of them ran `systemctl start archipelago-fips.service` against the build machine on every ISO build; the others were harmless only by accident of being command-not-found. Fixes the class, not the six instances. The delimiter is now quoted, so the body is emitted verbatim and a future backticked comment is inert. Verified the boundary by line range first: the other backticked comments in this file (:264, :809, :1188, :1289, :1506, :1605, :3597, :3651) are ordinary shell comments outside any unquoted heredoc and were never at risk — they are untouched. The body needs exactly four build-time values and they are all package names (LINUX_IMAGE_PKG, GRUB_EFI_PKG, GRUB_EFI_SIGNED_PKG, GRUB_PC_PKG), on four consecutive lines. So quoting was practical: the heredoc is split into DOCKERFILE_HEAD and DOCKERFILE_TAIL, both quoted, with a single explicit printf interpolating those four names between them. Escapes that existed only because the heredoc was unquoted are undone in the same pass: six trailing `\\` become `\` (Docker line continuations) and four `\$` become `$` (RUN arguments reach the shell verbatim — Docker does not substitute variables in RUN). Verified by rendering the generated Dockerfile before and after with the same inputs and diffing them normalised (continuations joined, whitespace collapsed). Both are 190 normalised lines and the ONLY differences are the six comments regaining their text — every instruction is byte-identical. Before: "# the archipelago backend calls" / after: "# the archipelago backend calls `systemctl start archipelago-fips.service`". Test: case 7 asserts every heredoc writing Dockerfile.rootfs has a quoted delimiter, and when one is not, reports which body lines would execute. The assertion is on the delimiter, not on backticks — with quoting a backticked comment is legal and six of them are back in the body on purpose, so flagging backticks would flag a non-bug and fail on the very comments this restored. This bug is invisible to `bash -n`; an instance of it introduced earlier in this plan hung a syntactically-clean build for two minutes before being caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2efab5f219 |
fix(10-03): unify secret generation to a single producer + self-heal (F-03)
Unify rather than delete. The defect in F-03 was never "a second attempt to
create a key exists" — it was that failure was silent and the completion marker
lied about it. A second attempt is only dangerous when it is an unaudited second
PRODUCER carrying its own idea of success, its own absent retry policy and its
own absent failure record.
Single producer. gen_tls() is now the only code in the ISO build that creates
/etc/archipelago/ssl/archipelago.{key,crt}; gen_ssh() the only code that creates
/etc/ssh/ssh_host_*. Two secondary producers are gone:
- the Dockerfile's `openssl req` layer, which baked a keypair the strip layer
deleted moments later in the same build;
- the installer's "ensure SSL cert exists for nginx HTTPS" block, which before
the strip almost never fired and after it would have fired on every install.
Proof is mechanical, not a claim: every executable `openssl req` / `ssh-keygen
-A` invocation in the builder now lives inside the generator heredoc, and the
test suite fails if one appears outside it.
Build-time assertion. The one realistic total failure is a missing generator
binary, which is deterministic — no retry or reboot fixes it. A rootfs RUN layer
now fails the build if openssl or ssh-keygen is missing or non-executable.
openssl and openssh-server are both already in the package list (and
openssh-server hard-depends openssh-client, which ships ssh-keygen), so today
this is cheap insurance; it earns its place the first time someone edits that
list.
Self-heal, never dead-end. Fail-closed governs SERVING; retry governs
RECOVERING, and they are different things. Adds
archipelago-first-boot-secrets.timer (OnBootSec=5min, OnUnitActiveSec=15min),
installed and enabled with a hand-written symlink fallback because chroot
systemctl enable can fail silently. The service's own ConditionPathExists=!
makes every trigger a no-op once the marker exists, so a healthy node pays
nothing. On success the script now restarts consumers that are in `failed` —
try-reload-or-restart is a no-op on a failed unit, so without this a recovered
node would have valid keys on disk and nginx still down.
Never serve a bogus key. gen_tls parses both halves back with `openssl pkey`
and `openssl x509` before the swap, so a truncated or half-written artefact is
never what nginx reads.
Tests: 6 cases, each with an isolated negative control (transcripts in SUMMARY).
- case 4, TLS fails every attempt on a stripped root -> no key from any source.
Control: reintroduce a fallback key creation -> only case 4 red.
- case 5, self-heal: a failed run then a later successful run -> key present,
marker set, failed units restarted. Control: dead-end on a node that already
failed -> only case 5 red.
- case 6, single-producer invariant. Control: reintroduce the installer block
-> only case 6 red, naming the line.
Residual risk, stated plainly: a machine where generation can never succeed
still ends up with no SSH and no TLS. Build-time assertion removes the
deterministic cause, retry plus timer removes the transient ones, so what
remains is genuinely broken hardware — and it says so on the console and in
/var/lib/archipelago/first-boot-secrets.failed rather than quietly serving a
key nobody audited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ff6902dd9d |
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> |
||
|
|
201ef474e7 |
docs(10-03): record C-4 build-host evidence procedure (UNVERIFIED)
Task 3 of 10-03 is a blocking checkpoint: proving the shipped rootfs tar is identity-free needs a real ISO build host with podman/docker and disk for a full rootfs rebuild. This commits the prepared evidence document with the exact command sequence, marked UNVERIFIED, rather than claiming the check passed. The document states the inverted expectation explicitly. The audit's C-4 entry expected SSH host keys and the TLS key to be PRESENT — that described the broken state it was measuring. After the strip layer those must be ABSENT, so the audit's stated expectation is now the failure condition. A future reader comparing the two would otherwise conclude the check regressed. Also records two things the operator would otherwise get wrong: - RECIPE_HASH must be read from the stamp file, not computed from the repo file. build-debian-iso.sh rewrites the builder's relative paths into a temp copy before exec, and the hash covers "$0"; the hashed region has 35 such rewritten expressions plus an absolutised SCRIPT_DIR, so the value is specific to the build host and checkout path. - C-4 is a build-host check only. Two-node key divergence is C-3 and stays separately UNVERIFIED; the note explains why SSH host keys are the sharper signal there than TLS, given the installer's per-install TLS fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
408b328c39 |
fix(10-03): strip fleet-shared identity material from the rootfs tar (F-03)
The rootfs is a container image exported to a tar and extracted verbatim onto every disk flashed from the ISO, and the ISO is published. It baked two things nobody asked for: Debian's openssh-server postinst generates /etc/ssh/ssh_host_* during the container build, and the `openssl req` layer writes the TLS keypair. Both were therefore identical on every node and known to every downloader. Add a final RUN layer to Dockerfile.rootfs that removes /etc/ssh/ssh_host_*, removes the archipelago TLS keypair (keeping the ssl directory so the first-boot staging swap has somewhere to land), truncates /etc/machine-id to systemd's documented "regenerate on next boot" state, and drops a non-shared /var/lib/dbus/machine-id if one exists as a real file rather than a symlink. It also writes /opt/archipelago/rootfs-identity-stripped so a node can answer after the fact whether its rootfs came from a stripped build; no timestamp, so the RECIPE_HASH cache stays reproducible. This is what makes 10-03's fail-closed regeneration structural instead of procedural: with the material gone, a regeneration failure degrades to "no key, service refuses to start" rather than "fleet-shared key, silently". The `openssl req` layer is deliberately left in place — it keeps proving openssl is present and keeps the SAN template next to its consumer; the strip layer is what makes the output non-shared. Two comment corrections that follow from the strip: - The installer's TLS block is no longer a rarely-taken safety net; it now fires on every install. It is per-install and never image-wide, so it does not reopen F-03, but it does mean a first-boot failure still leaves the web UI with a cert while SSH has nothing. Comment updated to say so. - The first-boot script header overstated the fail-closed cost for TLS for the same reason; corrected to claim certainty only for SSH. This edit is inside the RECIPE_HASH region, so the next build is forced to rebuild the rootfs tar — required for the C-4 evidence to mean anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
210430967d |
fix(10-03): fail closed on first-boot secret regeneration failure (F-03)
The first-boot per-device secret regeneration was fail-open: both branches logged a warning and continued, and `touch "$MARKER"` ran unconditionally outside both `if` blocks. Combined with the unit's ConditionPathExists=! and the script's own marker fast-path, one transient failure left that node on the image-wide shared SSH host key and TLS private key permanently and silently — and the ISO is a published artefact, so every downloader holds those keys. - Retry each generator 3 times with backoff (D-05), so a transient first-boot condition recovers inside the same boot instead of being terminal. - Write the completion marker ONLY when both TLS and SSH succeeded, so a failed boot leaves the unit eligible to run again on the next boot. - On terminal failure: durable record at /var/lib/archipelago/first-boot-secrets.failed naming which generator failed, plus console + logger + stderr, and exit 1 so the unit lands in `failed` rather than `active`. The record is cleared on a later success. - Add FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF seams. Unset in production the behaviour is byte-identical; set, they let the fail-closed property be asserted rather than claimed. - Order the unit After=systemd-random-seed.service (no-op today, correct if a seed file is ever baked). - State the operational trade in the script header: after the rootfs strip, a terminal failure means no SSH and no TLS and needs the physical console. That was chosen deliberately over running on fleet-shared keys. tests/first-boot-secrets/run-tests.sh extracts the shipped heredoc body from the builder and drives it against a temp root with stubbed generators: both succeed, openssl fails every attempt, ssh-keygen fails twice then succeeds. Moving the marker touch back outside the success branch makes case 2 fail with MARKER-SET-ON-FAILURE, which is the regression this pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c502ff0e0a |
docs(todo): migrate VPS2 IP to domain across registry references
98 operational files still carry 146.59.87.168 — the domain was only adopted for the git remote, not for container registry references. Bulk is app manifests' image: lines, plus .gitmodules, both CI workflows, the signed catalog.json, and two Android companion files with compiled constants. The 117 hits in .planning/ are historical records and stay. Not a find-and-replace: the domain serves Gitea over HTTPS:443 while images are pulled from :3000 over plain HTTP, and podman treats host:3000 and domain as different registries — so every node re-pulls under the new name and any node that can't resolve or trust the new host fails to pull. It also invalidates the signed catalog (needs a re-sign ceremony) and the APK ships compiled constants. Rollout order: registry serving on the domain → manifests → catalog re-sign → APK rebuild. Steps 2-4 are actively breaking until step 1 holds. Analysis from the concurrent agent's session before it ended; recorded so it is not lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5a11fa7588 |
docs(todo): capture companion 0.5.27 handover — node/web-side clipboard + QR work
Companion build 0.5.27 (versionCode 47) shims navigator.clipboard natively, so in-app copy/paste is fixed with zero web changes — but the contract must not be clobbered (no unconditional re-define, no Object.freeze). Still open web-side: main.ts's fake readText() makes SendBitcoinModal's Paste button render and silently no-op in plain-HTTP browsers; 30 writeText call sites across three inconsistent patterns, ~10 of which toast 'Copied!' regardless of success; scanner prewarm/torch/constraints/no-reinit. Also records three factual corrections to docs/qr-scanner-snappiness-handover.md (ZXing not ML Kit; FORMAT_QR_CODE + KEEP_ONLY_LATEST already in place; do NOT drop to 720p — 1080p is a deliberate 0.5.22 fix for dense bolt11 QRs). Routed at Phase 11: the signed-PSBT paste affordance and the scanner items are the same surface as WALLET-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
204d4523da |
fix(ui): a modal must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m20s
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the wallet's send/receive modal floating over the destination. The Lightning modal itself did close — the parent did not. Tab views are KeepAlive'd, so navigating deactivates the owner rather than unmounting it, and its Teleported modal keeps rendering. BaseModal now emits close on any route change while shown, fixing the class in one place rather than per button. Every modal here is a transient dialog; none should survive navigation. Two tests pin it, including that a hidden modal stays quiet. Also fixes a test-only regression from |
||
|
|
c3d5bcd271 |
fix(wallet): gate lightning on CHANNELS, not just node state
Demo images / Build & push demo images (push) Successful in 3m24s
A running LND with zero channels happily mints an invoice — it is simply unpayable, because nobody has a route in. So the state-only gate let receive through and handed the user a useless invoice, and let send walk to confirm. Neither errored, so the funding modal (wired to failures) never fired. requireLightningReady(direction) now asks lnd.listchannels and checks the liquidity that actually matters for the attempt: total_inbound to receive, total_outbound to send. It fails OPEN on an RPC error — a transient blip should not block a working wallet. The no-funds mode says plainly that a channel is needed, in the direction's own terms (inbound vs outbound), and offers both routes: "Open a channel" straight to the channels screen where the Zeus/Olympus flow is already prefilled, and "Setup Guide" to the run-lightning-node walkthrough for someone who wants the whole path explained. Buttons wrap rather than squeeze on narrow screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
700947ea3c |
feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Demo images / Build & push demo images (push) Successful in 3m18s
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than hotlinked on purpose — these nodes run offline/airgapped, and a remote image would both break there and leak a request to a third-party host on every render. A missing asset falls back to a neutral bolt glyph so a row can never render a broken-image box. Mobile: the choice row keeps icon + name + blurb together and drops the action to its own full-width line under 26rem, instead of squeezing the description into a two-word column next to a button. Funding mode: a node that is running but has no funds / no inbound liquidity is neither "install one" nor "start it", so the same modal gains a third mode that explains it and routes to the run-lightning-node goal, where funding and channel-opening already live — reusing that flow rather than duplicating it. It fires where the user actually meets the problem: on a failed attempt. handleLightningFailure() maps a running node's send/receive failure onto the funding modal, matched on message text because LND surfaces "no route", "no channels" and "insufficient balance" as plain strings with no distinct code — and all three mean the same thing to a user: fund me. Verified: 5 gate tests; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fa26c5fc56 |
fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton
Three defects from testing the previous commit on archi-dev-box: 1. The gate keyed on `id in packages`, which is not "installed and usable" — package-data carries an entry for a Lightning app that is known but not running. On a box with no lnd container at all the gate passed and the raw error came through as "Operation failed. Check server logs for details." Now keyed on PackageState.Running. 2. Because installed-but-stopped is a real and different situation, the modal has two modes: absent offers the install choices, stopped says the node isn't running and offers "Open My Apps". Neither dead-ends in an error. 3. Lightning SEND let you walk all the way to confirm-send with no node. The gate now runs in review(), before the confirm step — failing at submit after a review screen is the defect, not a smaller version of it. Also adds CopyButton, the start of one consistent copy affordance: icon + label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a document.execCommand fallback so copy still works over plain http on a LAN IP (navigator.clipboard rejects on insecure origins, which is how a lot of nodes are reached). Converted the wallet's own copies — the lightning invoice the user reported, plus the on-chain/Ark addresses and the payment hash/txid. 20 of 25 copy sites across 15 other files still use ad-hoc markup; converting them is mechanical but was not attempted here rather than half-done. Verified: 5 gate tests; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
90ce4bcc46 |
docs(10): correct F-10a's own overstatement — x3dh sites are identifiers, not key material
The F-10a scope correction committed hours earlier asserted semantics its evidence did not support. The KEY-05 planner caught it against the code: - mesh/x3dh.rs:100/:114 are u32 prekey IDENTIFIERS (spk_id, otk_id), not key agreement material. The X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113 and were never in scope. - session.rs's 16 raw matches read as 16 production token sites; #[cfg(test)] begins at :470, so it is 4 production + 12 test. - wallet/bdhke.rs is 2 production of 4 (#[cfg(test)] at :143) — and those two ARE genuine key material: generate_secret() :133 and random_blinding_factor() :139. The Medium rating still holds, on narrower grounds: bdhke's two production sites plus storage_crypto.rs:39's AEAD nonce. It no longer rests on x3dh. Struck rather than silently rewritten. F-10 was corrected on the grounds that understatement misleads the next reader; overstatement does the same, and this table managed both within a day. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |