e46af8cfe5548db512716ef2fcc7e31db6998241
984
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e46af8cfe5 |
feat(security): self-heal legacy containers on declared bind drift
Legacy pre-quadlet containers kept publishing 0.0.0.0 after the catalog pinned their app to loopback, because host_port_bindings_drifted only compared host PORT numbers — closing them needed a manual package.update per app per node. The drift check now also compares the bind ADDRESS, but only when the manifest declares one: an empty bind never fires, since recreating a loopback-published container to wildcard on silence is exactly the v1.7.121 Bitcoin-RPC incident. With this, every node recreates its legacy containers to the declared state on its own after the OTA. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f08ed79b8a |
fix(security): FIPS v6 relay hands gated ports to the app gate
The mesh relay is a raw unauthenticated forward to the app's loopback, and whether it or the gate owned a fips0 ULA port was decided by a bind race — the dev box happened to be safe because the gate bound first. The relay now skips ports declared auth: gated and tears down any existing bridge for a port that became gated since it was bridged (catalog refresh), releasing the bind for the gate's next sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3760a00ea3 |
fix(security): Tor onions for gated ports forward to the gate, not the app
Tor carries no session cookie, so HiddenServicePort → 127.0.0.1:<port> reached the app around the gate — the last transport the gate did not cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the app's 127.0.0.1, so no app needs a second port), and regenerate_torrc forwards declared-gated ports there. Undeclared ports keep today's target: absence of the field is not an instruction. The 127.0.0.2 claim deliberately does not count toward the unprotected audit — a port whose only claim is the Tor loopback is still wide open on the LAN and must keep warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6d9d87caa6 |
chore: sync Cargo.lock with the 1.7.121-alpha version bump
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd58242935 |
chore: release v1.7.121-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
f6b5245b0d |
fix(security): deliver config fixes to a running app the marker calls uninstalled
Found while VERIFYING |
||
|
|
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 ( |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 ( |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
06e0e6954e |
fix(01-16): recreate the gateway when its credential was rotated (FED-07)
The checkpoint on archi-dev-box proved rotation alone doesn't close FED-07: the credential file went unique while the RUNNING container kept serving the compromised one, because the Quadlet path rewrites a unit without restarting it and fedimint-gateway is classified restart-sensitive, so drift was detected and deliberately ignored on every tick. Rotation now records the app id, and the drift check consumes that flag to recreate even a restart-sensitive app, with a WARN naming the reason. This mirrors the published-port carve-out a few lines above, which already makes the same trade for the same reason: a container that is already broken (there) or already compromised (here) is not protected by leaving it running. Restart-sensitivity protects working services. A gateway answering to a credential published in this repository is not working, it is compromised, and gateway admin can drain Lightning liquidity — indefinite exposure loses to a few seconds of restart. Rotating-but-only-alerting was rejected: the monitoring system fires on metric thresholds only, so it would have needed new event-alert plumbing to deliver something strictly weaker. Re-verified on the same node, same scenario: rotation at 06:39:23, recreate at 06:39:27, PID 3923125 -> 148426, running credential now matches the file, container healthy with the same name and ports, gatewayd.db intact at 18 files with IDENTITY present, 32 containers untouched, no repeat rotation. 3 new tests. Also lands the missing 01-19 and 01-20 SUMMARYs: both had code committed 2026-07-31 but no summary and no roadmap tick, so they read as unstarted. Phase 1 is 11/20. FED-09 carries 15h of Tor uptime and 0 permission-fixes across 542 doctor runs on archi-dev-box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9e2d2ef236 |
feat(01-16): rotate existing installs off the shipped gateway credential (FED-07)
01-11 stopped new installs from ever taking a shipped credential, but did
nothing for the nodes that already did — those gateways still answer to a
password published in this repository.
rotate_compromised_gateway_credential() detects an EXACT match against the
denylist and replaces the pair; absent, unique, or merely unrecognised values
are left alone and return false. That distinction is the point: an operator
who deliberately set their own credential also has an "unrecognised" one, and
rotating it would be the same class of harm as leaving the default in place.
It hangs off resolve_dynamic_env beside ensure_generated_secrets, gated on the
gateway's app id, so an affected node heals on its next reconcile tick. There
is deliberately no teardown here: the new hash changes the resolved secret env,
which changes secret_env_hash, which the drift check reads as a container-label
mismatch — so the platform's own recreate path rebuilds the gateway around its
unchanged data directory, ports, volumes and name.
Rotation is self-terminating (the value written is not on the denylist, so the
next tick is a no-op) and errors propagate rather than being swallowed, because
the atomic write leaves the previous credential intact on failure.
Bcrypt generation was factored out of ensure_one into write_bcrypt_pair, which
both generation and rotation call — 01-11's SUMMARY claimed such a helper
existed but the arm was still inline, and rotation cannot reuse
ensure_gateway_credential because its idempotent fast path returns early
exactly when the file is present, which is the case rotation acts on.
Also fixes cargo fmt drift left by
|
||
|
|
8b51b7e2dc |
fix(quick-260731-upz): make the master-seed RNG explicit (ARCHY-1 / F-02)
bip39::Mnemonic::generate(24) resolves through Mnemonic::generate_in to &mut rand::thread_rng() INSIDE the bip39 crate (bip39-2.1.0/src/lib.rs: 311-313 -> :296-298 -> :267-283), so the entropy source behind Archipelago's entire key hierarchy -- node Ed25519 did:key, node Nostr key, FIPS mesh key, per-identity keys, the BIP-84 wallet, the LND aezeed entropy, and the fleet release-root SIGNING key -- was chosen by a dependency default rather than stated at the call site. Not a vulnerability today: rand 0.8.5's thread_rng is a fork-protected ChaCha12 CSPRNG seeded from getrandom(2). But it is precisely the structural shape of the 2026-07-30 COLDCARD entropy defect (T1), where a refactor silently rebound seed generation to a non-cryptographic PRNG with no compile error and no test failure. - New private helper generate_mnemonic_with<R: CryptoRng + RngCore> calls bip39's injectable generate_in_with; MasterSeed::generate passes OsRng explicitly, with the rationale pinned in a doc comment - mnemonic_generation_uses_injected_rng: drives generation from a deterministic test RNG and asserts the result equals from_entropy(exactly the bytes that RNG emitted) -- direct proof the INJECTED rng is consumed -- plus a known-answer pin and a determinism check. This test cannot be written against the previous code: there was no seam to inject through - mnemonic_generation_is_256_bit: the OsRng path yields 24 words and two successive productions differ No change to derivation paths, word count, the empty-BIP-39-passphrase decision, or the at-rest encryption envelope. Verified: CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: -> 25 passed, 0 failed. Full analysis: docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (F-02, §4, §7). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4265254700 |
fix(01-11): remove every shipped Fedimint gateway credential (FED-07)
Six code paths configured the Lightning gateway with a bcrypt hash committed to this repository — and one deploy path with a plaintext password literal — whenever the per-install secret was missing. Anyone holding a copy of the repo held the admin credential for every gateway that ever took a fallback. container::secrets now owns the credential end to end: ensure_gateway_credential (idempotent, delegates to ensure_one's bcrypt arm) and gateway_bcrypt_hash, which returns Err when the secret is missing/empty and when the stored value is on the KNOWN_DEFAULT_GATEWAY_HASHES denylist — so this codebase cannot hand back the compromised value even to a node already carrying it. get_app_config was widened to Result so a credential-less install cannot reach podman run at all; configure_fedimint_lnd takes the resolved hash instead of re-reading with its own fallback. The four shell paths stop generating credentials entirely (dropping the htpasswd host dependency) and skip container creation with a printed reason rather than substituting anything. Naming converges on the manifest's fedimint-gateway-hash/.pw, with legacy fedimint-gateway-password values copied forward rather than regenerated so no node loses a working unique credential. Plan 01-16 owns rotation of installs already carrying the default. Verified: cargo build clean; cargo test -p archipelago 999 passed (2 boot_reconciler timing tests failed under concurrent load, green in isolation, untouched by this diff); bash -n clean on all five scripts; the compromised literal now appears exactly once in the tree, as the denylist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b5367ebc4 |
fix(01-01): route remaining federation mutators through the store lock (FED-01)
Task 2 of 01-01-PLAN.md, closing the gap left after Task 1's initial commit
(
|
||
|
|
258a91781c |
fix(release): correct v1.7.119-alpha changelog/manifest lifecycle-gate note
create-release-manifest.sh's changelog extraction pulls every non-blank line between the version header and the next "## ", not just "- " bullets — so the previous commit's "### Known gap" markdown heading and its paragraph leaked into releases/manifest.json (and, via sync-whats-new.py, the Settings "What's New" modal) as a malformed, truncated entry (the closing clarification sentence was cut by the extractor's 10-line cap). Rewritten as a single "- " bullet, matching every other CHANGELOG entry, so it renders cleanly and completely in both the OTA manifest and the in-app modal instead of showing raw "### " syntax to node operators. Also folds in core/Cargo.lock's version bump, which create-release.sh's own commit step omits from its `git add` list. Same binary/frontend artifacts as the prior commit (identical sha256/ size in the regenerated manifest) — only the changelog text changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
baaa4e8ea1 | chore: release v1.7.119-alpha | ||
|
|
37d293be59 |
style: cargo fmt — fix formatting drift blocking the release gate
Whitespace-only reflow in storage.rs/seed.rs/update.rs (rustfmt line- wrapping rules) and app_ports.rs (array literal reflow after the port list grew). No logic change. tests/release/run.sh's cargo-fmt --check stage was failing on this before v1.7.119-alpha could be cut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e5c38866ca |
fix(01-19): embed route hints in invoice creation for private channels
Demo images / Build & push demo images (push) Has been cancelled
handle_lnd_createinvoice posted to LND's /v1/invoices with only value/memo, so LND defaulted private to false and returned invoices with empty route_hints. Any node whose only channels are private/unannounced was unpayable through the wallet UI's Receive flow. Diagnosed on archy-x250-mad2, whose only channel (to Olympus by ZEUS) is private with ~40.8k sats usable inbound; three wallet-UI invoices never received an HTLC. Audited every other invoice-creation call site and found a second one with the identical omission: create_invoice, the seller-side/peer-file paid- content flow (content.rs -> handler for paid downloads). Same bug, same fix, wider blast radius than the one-node report suggested -- paid-file sales were unreceivable on private-channel nodes too. Extracted both call sites' invoice_body construction into one shared build_invoice_request_body() that sets private: true unconditionally, and added a unit test pinning that field so neither site can silently drift back to false. private:true is harmless on nodes with public channels -- LND still prefers a direct public route and the hint is just an unused alternate path. |
||
|
|
3b3d22d0ce |
fix(botfights): 1.2.2 — allow node-dashboard iframe embedding via ARCHY_EMBEDDED=1
Demo images / Build & push demo images (push) Has been cancelled
1.2.x's auth-hardening work added Hono secureHeaders() with a default X-Frame-Options: SAMEORIGIN, which unconditionally blocked the Archipelago node dashboard's iframe (different origin by port) — a real regression versus 1.1.0, which never sent this header. Fixed upstream in the botfight repo (commit 8eb27ed): X-Frame-Options is now conditional on ARCHY_EMBEDDED, disabled only for the first-party node-embedded instance. apps/botfights/manifest.yml: image/version -> 1.2.2, adds ARCHY_EMBEDDED=1 to environment, drops the interim metadata.launch.open_in_new_tab workaround (no longer needed — the app can now be framed). app-catalog/catalog.json, scripts/image-versions.sh, neode-ui/public/catalog.json bumped in lockstep via scripts/generate-app-catalog.py. core/archipelago/src/fips/app_ports.rs regenerated (formatting only, same port set). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
08ee5ed036 |
feat(seed): audit kernel CSPRNG readiness + RNG non-determinism regression test
Seed entropy comes from bip39 -> rand::thread_rng -> getrandom(2), which blocks until the kernel pool is initialized -- but that ordering was invisible in logs on first-boot ISO flows where the seed is generated early. MasterSeed::generate() now probes getrandom(GRND_NONBLOCK) and logs whether the pool was already seeded (warn if it would block). Also adds a regression test that 64 generated mnemonics are all unique with sane word diversity, guarding against a fixed/seeded RNG ever being wired into seed generation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2f99db5e6b |
fix(01-01): serialize federation node-store writes, close remove-vs-sync race (FED-01)
- Add FEDERATION_STORE_LOCK (tokio::sync::Mutex<()>) guarding every load-mutate-save cycle in federation/storage.rs, closing the race where the 90s auto-sync loop's stale pre-removal snapshot could silently re-save a peer the operator just removed (no error logged anywhere). - Split load_nodes/save_nodes/tombstone_did/untombstone_did into thin locked outer wrappers + lock-free *_inner bodies so remove_node and add_node can hold the guard across their whole tombstone+save critical section without self-deadlocking (Mutex is not re-entrant). - Route load_nodes, save_nodes, remove_node, add_node, tombstone_did, untombstone_did, set_trust_level, and update_node_state through the lock (set_trust_level pulled forward from Task 2's scope — required for test_concurrent_writes_do_not_lose_updates, part of Task 1's own required-green test suite, to pass; documented in SUMMARY). - Convert save_nodes_inner to an atomic write: serialize to a sibling nodes.json.tmp in the same directory, then tokio::fs::rename onto the real path, so a crash mid-write never leaves a partially-written nodes.json for a concurrent reader. - Add 3 new regression tests, two of which are fail-first proven: heavy tokio::spawn-based concurrency (not just tokio::join!, since remove_node's extra tombstone I/O hop structurally biased a simple 2-task race toward the safe ordering) reliably reproduced both the lost concurrent write and the removed-node-reappears bug pre-fix; both are green post-fix along with the existing suite (13/13). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8bea3707ca |
feat(lightning): instant pay feedback, balances never vanish mid-payment
Demo images / Build & push demo images (push) Failing after 4m20s
Framework-pt report: a paid invoice stalled the UI with no success shown, and lightning/total balances disappeared until it settled. Three compounding causes, three fixes: - Backend payinvoice's synchronous wait drops 120s → 8s. Fast payments (the majority) still settle in one round trip; slow multi-hop routes return pending + payment_hash quickly and the caller's 3s poll takes over — instead of the modal freezing for up to two minutes. - payLightningInvoice gains an onPending hook: SendBitcoinModal and the scan modal now flip to a visible "Settling…" success pane the moment the payment goes pending (safe to close), and the ongoing poll upgrades it to Paid — or replaces it with LND's real failure. - One slow lnd.getinfo poll (5s budget) flipped the Home wallet card to "disconnected", hiding balances the user already knew. Three consecutive failures are now required (~30s) before the card gives up; last-known balances keep rendering throughout. rpc-client tests 75/75. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
49ec294dea |
fix(update): concurrent apply reads as progress, not failure; idle-IO extraction
Demo images / Build & push demo images (push) Successful in 5m18s
"Another update operation is already running" surfaced as a scary failure while the update was in fact applying fine (OptiPlex, v1.7.118 rollout). The apply path now joins the in-flight install — same overlay, same wait-for-new-version polling — and a concurrent download attempt shows a calm in-progress note (EN+ES strings added). The backend's tarball extractions run under ionice -c3 nice -n10 so a 200MB update can't starve podman/status calls into multi-minute timeouts on small disks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
14feb1feb9 |
chore: release v1.7.118-alpha
Demo images / Build & push demo images (push) Failing after 2m19s
|