Commit Graph
2424 Commits
Author SHA1 Message Date
archipelagoandClaude Opus 5 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>
2026-08-02 15:04:14 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 15:03:02 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:44:01 -04:00
archipelagoandClaude Opus 5 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 879de59e and lacks the refusal string), and no second machine
was available to probe from. Nothing moved UNVERIFIED -> VERIFIED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:26:57 -04:00
archipelagoandClaude Opus 5 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 879de59e landed at 13:05, and the refusal string is absent
from it. A --destructive run against the dev-box would replace its identity
rather than be refused, so it was not made.

Also carries 10-01's pre-OTA fleet check (onboarding.json complete-true
without user.json): dev-box safe, rest of fleet unchecked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:25:05 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:21:40 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:20:53 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:19:15 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:16:46 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 14:15:01 -04:00
archipelagoandClaude Opus 5 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 (8dd57bcb, 2026-04-19), and the 90s loop
landed later (837cc028, 2026-06-19) describing itself as "new 90s
periodic federation auto-sync (none existed)" — its author simply hadn't
seen the existing one. Running both doubled the write-race exposure
against nodes.json that plan 01-01 locked down.

The 90s loop survives; it already did strictly more (per-peer sync-result
recording, asymmetry self-heal). The deleted loop's one unique behavior —
refresh_federation_mesh_peers() after a completed pass (#42), which pushes
newly-learned names/roster into the live mesh peer table so chat contacts
refresh without a restart — is preserved at the tail of the survivor. That
call is a local, idempotent re-seed from nodes.json with no network I/O,
so running it per-pass rather than per-half-hour is cheap.

Also carried over: MissedTickBehavior::Delay, so a pass delayed by suspend
or heavy load resumes the cadence instead of firing a burst of catch-up
ticks. And node-load errors are now logged and skipped explicitly rather
than swallowed by a catch-all, so an empty roster and an unreadable one
are no longer indistinguishable.

Not carried over: the deleted loop's 5s per-peer stagger. Its stated
reason was avoiding concurrent connects against the Tor SOCKS proxy, but
both loops iterate peers sequentially and await each sync, so there were
never concurrent connects to stagger; keeping it would only push a
multi-peer pass past the 90s cadence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:13:18 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 13:51:43 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 13:14:10 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 13:05:35 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 12:25:47 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 11:39:10 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 11:32:50 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 11:12:07 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 10:08:42 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 10:08:17 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 10:08:14 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 10:03:26 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 09:48:19 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 09:30:06 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 09:14:27 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 08:57:08 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 08:55:33 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 08:49:57 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 08:45:35 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 08:27:03 -04:00
archipelagoandClaude Opus 5 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 fa26c5fc: useLightningRequired()
resolved the Pinia store at composable-call time, so merely having the gate in
SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts
it bare). The store is now resolved lazily inside the function that needs it —
a gate should never be what breaks a component's ability to mount. That one
shipped because I verified fa26c5fc with targeted tests and a build but had
not re-run the full suite since 5718179e.

Verified: full suite 103 files / 827 tests green; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:12:49 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 07:51:08 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 07:43:24 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 07:36:25 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 07:30:43 -04:00
archipelagoandClaude Opus 5 dba3a30af9 docs(10-06): plan KEY-05 crate-wide defaulted-RNG enforcement
Adds 10-06-PLAN.md covering KEY-05 (F-10a / R-16). 10-01..10-05 untouched.

Six tasks, sequenced so CI stays green at every intermediate commit:
classify all 43 call sites with file:line evidence; a tracer that wires the
sealed KeyGenRng allowlist, the degenerate-entropy predicate and the CSPRNG
readiness ledger end-to-end through the mnemonic seam; two migration tasks;
a blocking human checkpoint for cargo-deny scope and legitimacy; then the
gates are enabled last and observed failing a real build.

The clippy ban is a compile failure under the existing -D warnings CI step,
so core/clippy.toml is deliberately not committed until every site --
including test code, since --all-targets counts it -- has migrated.

Wave 2: shares seed.rs with 10-05 and api/rpc/auth.rs with 10-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:24:43 -04:00
archipelagoandClaude Opus 5 5718179e2f feat(wallet): offer to install a Lightning node instead of failing an invoice
Demo images / Build & push demo images (push) Successful in 3m16s
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.

useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.

The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.

Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".

Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.

Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:23:37 -04:00
archipelagoandClaude Opus 5 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>
2026-08-02 07:20:20 -04:00
archipelagoandClaude Opus 5 5cf44c9a58 docs(10): correct F-10's scope and add KEY-05 — crate-wide CSPRNG enforcement
The audit recorded F-10 as two call sites in container/secrets.rs. The real
defaulted-RNG surface is 41 sites across 15 files: session.rs (16),
pine_ha.rs (6), wallet/bdhke.rs (4 — ecash key material), mesh/x3dh.rs (2 —
key-agreement material), storage_crypto.rs (1 — AEAD nonce), +10 more.

Nothing is broken today: rand::random()/thread_rng() are ChaCha12 seeded from
getrandom(2). What changes is blast radius — F-10's Low rating rested on
'per-app credentials rather than the master key hierarchy', which does not
survive the true scope. Re-rated Medium as F-10a.

Records why the original audit missed it: F-10 was reached by tracing the
manifest-secrets path, and no step enumerated defaulted-RNG use across the
crate independently of the traced paths.

F-10's original text is left unedited so the correction is auditable rather
than retroactive. R-13 superseded by R-16; tracker item replaced.

Adds KEY-05 to Phase 10: sealed allowlist trait at key-gen seams, clippy
disallowed-methods ban (compile-time, CI-enforced), cargo-deny on duplicate
rand majors, degenerate-entropy runtime check, persisted CSPRNG-readiness
verdict. Also retires the false 'impl CryptoRng for CountingRng' at
seed.rs:656.

Records the user's execution gate: Phase 10 does not start until the
concurrent Phase 1 agent is finished and their changes are synced. KEY-05 is
unplanned — the existing 5 plans predate it and a 6th is required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:01:00 -04:00
archipelagoandClaude f9b0659c4a wip: save session context — BotFights demo-prep work, off-plan (Phase 09 already complete)
Handoff for the reactive demo-day session that followed 09-06/09-07
(both already complete). Covers: security audit (6 IDOR fixes across
the botfight repo), Cashu payout claim UI, existing-bot AI-config UI,
botfights 1.2.11 built+deployed to both demo nodes, catalog
signed+published.

Also: discovered and fixed 4 botfight-repo commits that were local-only
and never pushed to origin — pushed as part of this handoff step
(botfight @ d00e792..10d4209 -> origin/main).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 06:36:48 -04:00
archipelagoandClaude Opus 5 23ae86c13a docs(state): record Phase 10 planned and ready to execute
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:15:30 -04:00
archipelagoandClaude Opus 5 1623b4f764 docs(quick-260731-upz): close out the entropy audit — research, summary, follow-up todo
The executor was instructed to leave docs artifacts to the orchestrator; this
commits them: the research that drove the audit, the task summary, and the
archi-dev-box test-node todo raised during the same session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:13:34 -04:00
archipelagoandClaude Opus 5 56f7b367ff docs(01-16): checkpoint FAILED on archi-dev-box — rotation never reaches the container
Ran Task 2's blocking checkpoint on a real node. The rotation works and was
proven end to end: it fired ~15s after restart, wrote a fresh unique
credential (0600, service-owned), logged exactly one line naming the .pw path
with no value in it, left every other secret and the gateway's data untouched,
and kept the container's name and ports.

But the assumption the plan rests on is WRONG, and the checkpoint is what
caught it. 25 minutes after rotating, /proc/<pid>/environ showed the running
gatewayd still using the PRE-ROTATION credential while the file and podman
secret held the new one. The orchestrator explains itself in its own logs:

  Quadlet unit drift-synced — file rewritten, .service NOT restarted
      (operator restart picks up new config)
  container drift detected during boot reconcile;
      leaving running restart-sensitive app untouched

Two deliberate guards: the Quadlet path never restarts a unit it rewrites, and
fedimint-gateway is classified restart-sensitive so drift is detected on every
tick and then ignored — logged at 15:51, 15:53, 15:54, 15:56 and counting.

So on a real affected node the credential file becomes unique while the
gateway keeps answering to the compromised one until an unrelated reboot, and
the operator reading the .pw gets a password the gateway rejects — T-01-77
inverted. FED-07 is NOT closed and this plan alone cannot close it.

Not hand-rolled around, per the plan's own instruction. The fix needs a design
decision: whether a compromised credential is the case that should override
restart-sensitivity, or whether rotation must raise an operator-facing
"restart required" alert instead of logging into the void.

Incidentally disproved: restarting archipelago does NOT kill containers here
(29/29 then 31/31 survived; "Adopted 31 existing container(s)"). The service
is system.slice/KillMode=control-group while containers live in
user-1000.slice/…/libpod-*. The CLAUDE.md SIGKILL rule predates Quadlet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 16:04:12 -04:00
archipelagoandClaude Opus 5 095664a8a7 docs(01-16): hold FED-07's on-node checkpoint, correct its requirement status
FED-07 was marked Complete when 01-11 landed, which was premature: the
requirement text explicitly includes "existing installs with the default
password get a migration path", and that migration has never been exercised on
a node. Corrected to code-complete/verification-pending.

Checkpoint step 1 was run read-only on archi-dev-box: the node is CLEAN (hash
present, 600, service-owned, not the shipped default) and has NO gateway
container — the app is installed but nothing runs and its data dir is empty.
So rotation cannot fire naturally here, and the steps that matter most (data
survives the recreate, new credential authenticates, old one rejected) have
nothing to exercise without installing and seeding first.

Deferred deliberately rather than run unattended: 30 containers are up with
4-8 days uptime (IndeeHub, Immich, BTCPay, netbird, strfry, …), the
archipelago system service is active, and restarting it SIGKILLs containers
until Quadlet is the default.

The todo carries the full context plus two adjacent findings: fedimint-gateway
is missing from handle_package_credentials (so a rotated password has no UI
retrieval path), and photoprism ships a fixed admin password in its manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:38:38 -04:00
archipelagoandClaude Opus 5 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 42652547 in install.rs.

Verified: 6 new tests, secrets suite 16/16; full suite 1008 passed with one
known wall-clock flake (green 4/4 in isolation). NOT verified on a node —
Task 2's blocking checkpoint has not been run, so FED-07 stays open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:17:56 -04:00
archipelagoandClaude Opus 5 d238bad012 docs(roadmap): add WALLET-05 — make the PSBT air-gap round trip real
The two-scan dance (node shows unsigned PSBT as animated QR, signer signs,
node scans the signed PSBT back, finalize + broadcast) is ~80% plumbed and 0%
usable. Verified gaps:

- No UI: lnd.create-psbt / lnd.finalize-psbt and their rpc-client.ts:417
  wrappers are called by nothing but unit tests.
- No animated-QR encoder: qrcode/qrloop are deps and the inbound path
  (useAnimatedQRDecoder + WalletScanModal) works, but nothing encodes a PSBT.
- Wrong format for real signers: qrloop is Ledger's; Passport/SeedSigner speak
  BC-UR (ur:crypto-psbt), Coldcard Q speaks BBQr. BC-UR is the priority given
  the existing Passport-Prime-compatible SeedQR work.

Gated on 10-05: create-psbt funds from LND's own wallet, so until LND is
watch-only against the external signer the offline device signs inputs whose
keys the node already holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:23:47 -04:00
archipelagoandClaude Opus 5 6c821730b0 docs(roadmap): add Phase 11 — Wallet Experience & LND UI Parity
First-run wallet-type chooser, seed handling reusing the shipped SeedQR +
seed-words components, and evidence-based umbrelOS LND UI parity.

Gated on Phase 10's 10-05: the set of wallet types WALLET-01 can offer is a
direct consequence of the watch-only verdict that plan produces, and 10-05 also
deletes the dead Core wallet path so this phase never represents it in the UI.

Records the already-shipped inventory (channels panel, send/receive/scan/
settings modals, SeedRevealPanel, LndSeedBackupPrompt, utils/seedqr.ts) so the
parity matrix closes real gaps instead of rebuilding existing surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:19:36 -04:00
archipelagoandClaude Opus 5 2781bd5a6c docs(10): correct D-03's gate signal — key_exists is true on every booted node
D-03 named NodeIdentity::key_exists as one of the two gate signals. Server::new
(server.rs:63-72) calls load_or_create on both branches, and load_or_create
(identity.rs:48-51) generates and writes a random temporary node key when none
exists — so key_exists is true on any node that has booted once, onboarded or
not. A gate keyed on it would refuse seed.generate on a fresh node and brick
onboarding fleet-wide.

The flaw came from the audit's own suggested remediation (§214-221) and was
repeated in the planning brief; the planner caught it against the code.

D-03's intent (two signals, OR-ed, fail safe on drift) is unchanged. Corrected
signal set: is_setup() / is_onboarding_complete() / seed_exists(), pinned by a
test rather than a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:07:06 -04:00
archipelagoandClaude Opus 5 04c4d3460c docs(10): plan Key-Material Hardening — 5 plans, 2 waves (KEY-01..KEY-04)
Closes the three exploitable findings from the 2026-07-31 entropy/seed audit.

Wave 1 (parallel):
- 10-01 KEY-01: shared onboarding gate refuses seed.generate/seed.restore/
  seed.save-encrypted/backup.restore-identity/auth.setup on a provisioned node,
  plus an auth.onboardingComplete guard and retry-budget-derived rate limits.
  Independently shippable (D-11): no depends_on, no shared files.
- 10-03 KEY-02: first-boot secret regeneration retries with backoff then fails
  closed; rootfs tar ships identity-free so failure degrades to "no key".
- 10-05 KEY-03: delete the uncalled bitcoin.init-wallet-from-seed xprv-import
  path (D-07b); make LND's PSBT round trip first-class with a key-origin report.

Wave 2:
- 10-02 (deps 10-01) KEY-01/KEY-04: on-node C-6 exposure measurement, live
  refusal proof, fresh-node onboarding non-regression.
- 10-04 (deps 10-03) KEY-02/KEY-04: fleet detection of image-baked host secrets,
  guarded rotation behind a D-06 decision checkpoint, C-3 two-node verification.

Planning-time scoping correction recorded in 10-01: D-03 names
NodeIdentity::key_exists as the on-disk "onboarded" signal, but server.rs:63-71
writes a temporary node_key on every boot, so that signal is true on fresh nodes
and would brick first-boot onboarding. D-03's dual-signal intent is preserved
with is_setup / is_onboarding_complete / seed_exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:04:57 -04:00
archipelagoandClaude Opus 5 97b8fa485d docs(10): KEY-03 final scope — delete Core wallet path, harden LND PSBT
Core's wallet is outdated and used by nothing, so bitcoin.init-wallet-from-seed
is deleted outright rather than migrated: uncalled, authenticated and
password-gated, never ran on archi-dev-box, and its only job is deriving and
stringifying the master BIP-84 xprv.

D-07's parity-proof migration and its one-way checkpoint are withdrawn — there
is no wallet to migrate. A small discovery check folds into KEY-04; a wallet
found there is a finding to stop on, not an auto-migration trigger.

PSBT is already solved by LND and already implemented: lnd.create-psbt
(WalletKit FundPsbt) and lnd.finalize-psbt (finalize + broadcast), both
rate-limited, on LND v0.18.4-beta. KEY-03 becomes: delete the Core path and
make that flow first-class, tested and documented, including that the PSBT
carries the BIP-32 key-origin data a hardware signer needs.

Records the standing honesty constraint that Lightning channel/revocation/HTLC
keys are not air-gappable at all, and defers the BDK+ElectrumX cold vault to
its own phase (D-07c).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:43:22 -04:00