Companion 0.5.28 — backup & restore (#128), NIP-46 remote signer (#139), companion-gated install pitch (#61 residual)

The companion-agent queue from the 2026-08-30 handoff, complete:

- Backup & Restore: hub sub-page, SAF export/import sealed in the
  node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
  with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
  nostrconnect:// QR pairing (scanner + OS deep link), per-request
  approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
  wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
  official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
  isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
  display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
  pure public-key function — verified against the fips crate).

Issues #61 (comment), #128, #139 closed on the tracker.
This commit is contained in:
Dorian
2026-08-31 19:34:05 +01:00
29 changed files with 4306 additions and 53 deletions
+116
View File
@@ -0,0 +1,116 @@
# HANDOFF — SSH over the FIPS mesh (node-side toggle), 2026-08-31
**For: the node OS agent.** From the companion agent, mid-0.5.28 testing. The
user wants to SSH their node from Termux over the phone's FIPS mesh instead
of keeping Tailscale around for it — the phone side is done and verified; the
remaining work is all node-side, and it wants to be a **first-class settings
toggle**, not a hand-edited firewall rule.
## What already works (do not rebuild this)
- The companion's embedded mesh is a **device-wide split tunnel**
(`ArchyVpnService` routes `fd00::/8` for the whole phone, no per-app
filter, `allowBypass`). Termux — or any app — reaches mesh addresses with
zero setup while the tunnel is up, on-LAN and away (anchor path).
- The hub's Nodes page now **displays and copies each FIPS node's `fips0`
ULA** (committed on `companion/0.5.28`).
- Verified live today: `ssh user@<node-ULA>` from Termux answers **RST** —
the path works end-to-end; something on the node is doing the refusing.
## The diagnosis (from today's field test + code read)
1. **`fips0` is default-deny inbound.** The hardening baseline
(`/etc/fips/fips.nft`, provisioned out-of-band) rejects un-allowlisted
ports with RST — the exact symptom the web-UI drop-in's comment documents
on :80 (`core/archipelago/src/fips/config.rs` ~L237). The daemon's own
drop-ins (`/etc/fips/fips.d/80-web-ui.nft`: 80/8443/5679,
`85-app-ports.nft`: app launch ports) **do not include 22**.
2. **sshd IPv6 listening is unverified.** `fips0` is IPv6-only; a sshd pinned
to `ListenAddress 0.0.0.0` RSTs on the ULA identically. The image installs
and enables openssh-server (`image-recipe/archipelago-scripts/install-to-disk.sh`
L177/L210) with default config (binds `::`), but a preflight in the toggle
should confirm rather than assume.
**Interim manual unblock (what the user can do today, keep valid):**
`/etc/fips/fips.d/90-ssh.nft` containing `ip6 saddr <phone-ULA> tcp dport 22
accept`, then `sudo nft -f /etc/fips/fips.nft`. A daemon-owned toggle must
**own that file name/lifecycle** so a hand-added rule and the feature don't
fight over the same slot.
## The ask: a "SSH over mesh" toggle
The user's instinct (seconded here): **a setting in the FIPS/network area of
the node UI**, default **off**. Sketch:
- **UI**: a small settings card in the pattern of
`neode-ui/src/views/settings/` (see `TransportPrefsCard.vue` for a
segmented-pref card + vitest). Toggle + a source-scope selector +
preflight status rows.
- **RPC**: `fips.ssh-over-mesh.get` / `fips.ssh-over-mesh.set` (dispatch arm
in `core/archipelago/src/api/rpc/dispatcher.rs` alongside the existing
`fips.*` arms at ~L544; handler in `api/rpc/fips.rs`). Persisted with the
other fips daemon-config state.
- **Enforcement**: mirror the existing drop-in lifecycle in
`core/archipelago/src/fips/config.rs` (~L243–320): when the toggle is on,
write `/etc/fips/fips.d/90-ssh.nft` on every daemon config install and on
toggle change; when off, remove it. Reload stays
`sudo nft -f /etc/fips/fips.nft`. Never touch `80-web-ui.nft` /
`85-app-ports.nft`.
- **Source scope** (the design decision worth an issue thread):
- *Paired phones only* — restricts to the phone ULAs/npubs the node has
actually paired with. Open question: does the node durably know which
inbound peers are "its" phones? FIPS accepts inbound peers without prior
registration, so this may need a small persisted "trusted peers" list
(seeded when `fips.pair-info` is issued, or on first successful dial).
Recommended default if the data can be made reliable.
- *Custom source list* — raw ULA list, per-rule `ip6 saddr <ula> …`
entries. Escape hatch; fine to ship alongside.
- *Any mesh peer* — what the user literally asked for, but flag it
honestly in the UI: with no registration requirement, this faces port 22
at every peer that can route to the node over the mesh. If offered at
all, gate it behind the same "I understand" confirmation pattern as
other danger-zone settings.
- **Preflights, surfaced in the card**: sshd enabled + listening on IPv6
(`[::]:22` or `*:22` via `ss -tln`), and whether
`PasswordAuthentication` is on — if it is, show a keys-only recommendation
(the firewall restriction is the belt; this is the suspenders).
## Acceptance (on-device)
- [ ] Toggle on, phone on LAN: `ssh user@<node-ULA>` from Termux connects.
- [ ] Phone away from LAN (anchor path): same result.
- [ ] Toggle off: connection refused again; `90-ssh.nft` gone.
- [ ] Daemon config install (upgrade/restart) preserves the on-state and
the rule; nothing duplicated.
- [ ] Non-default source scope actually restricts (try from a second mesh
peer, or a wrong ULA).
- [ ] Settings UI survives a page reload; RPC has a vitest like
`TransportPrefsCard.test.ts`.
## Addendum (2026-08-31, same day): the npub IS the address
While wiring this up we confirmed the mesh ULA is a **pure function of the
public key** — `fd ‖ sha256(x-only pubkey)[0..15]` (`fips/src/identity/node_addr.rs`
`from_pubkey` → `identity/address.rs` `from_node_addr`,
`FIPS_ADDRESS_PREFIX = 0xfd`). The daemon's DNS resolver (`fips/dial.rs`) just
answers what anyone can compute. Consequences for the node side:
- Docs/UI can advertise `ssh <user>@npub1…`-style addressing: Termux's
`Android/tools/fipssh` (shipped with the companion work) derives the ULA
from the npub with zero infrastructure, verified byte-identical against
the fips crate (`archy-fips-core` test
`npub_derives_the_same_mesh_ula_as_the_fips_identity`).
- If the settings toggle from this handover ever grows a "copy command"
affordance, `fipssh <user>@<npub>` is the natural shape (npub, not ULA —
it is the durable identity; the ULA follows from it).
- No node-side DNS surface is required for the SSH case; the resolver stays
what it is today (the node's own peer dials).
## Working rules
Same as the queue handoffs: small commits, tracker issue for this feature
(`ssh-over-mesh`), and the companion agent is downstream-only here — no
companion changes are required (the phone already routes and displays the
ULA). Optional nicety later, NOT part of this issue: the companion's FIPS
hub page could one day surface the toggle state — only worth it if the
`fips.ssh-over-mesh.get` RPC is trivial to add to the existing status call.
+88
View File
@@ -0,0 +1,88 @@
# Companion backup & restore — the phone side of a border crossing (#128)
**Status:** shipped in companion 0.5.28 (vc48). Issue: #128 ("Graphene phone
backup/restore — part of the companion app or passport prime combo").
## The problem
The companion holds real secrets: node addresses and login passwords, the
phone's FIPS mesh identity (which nodes peer with), and — since 0.5.28 — the
remote-signer key. Losing the phone, or wiping it to cross a border, loses all
of it. On GrapheneOS there is no cloud backup and there should be none here
either: the export is a plain file the user saves wherever they choose (USB
drive, computer, a folder synced their way), sealed with a passphrase.
## The envelope — the node's, not a second format
Backups use the node's ADR-005 encrypted-backup envelope
(`core/archipelago/src/backup/identity.rs`), byte-for-byte:
- Argon2id key derivation (RustCrypto `argon2`, default params — same as the
node's `Argon2::default()`), passphrase in, 16-byte random salt.
- ChaCha20-Poly1305 AEAD with a 12-byte random nonce.
- Envelope JSON:
`{"version": 1, "kind": "companion", "encrypted": true, "blob": "<base64(salt‖nonce‖ct)>", "timestamp": "<rfc3339>"}`
- The native code (`Android/rust/archy-fips-core/src/backup.rs`) is the same
crate family as the node's backup code; `decrypt` ignores unknown envelope
fields, so a **node** identity backup (which carries `did`/`pubkey`/`kid`)
also decrypts here — one envelope, two producers.
The encrypted payload is the companion's own JSON:
```json
{
"app": "archipelago-companion",
"payloadVersion": 1,
"appVersion": "0.5.28",
"createdAt": 1725100000,
"servers": ["<serialized ServerEntry>", …],
"active": "<serialized ServerEntry or null>",
"fips": {"secret","npub","address","peers","partyPeers","partyName","partyListen"},
"signer": {"secret": "<hex>"},
"flags": {"introSeen": true}
}
```
## Where the code lives
- **Crypto:** `Android/rust/archy-fips-core/src/backup.rs` (+ JNI
`NativeCore.backupEncrypt/Decrypt`). Host `cargo test` covers round-trip,
wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce
freshness.
- **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`).
- **UI:** a hub sub-page (`ui/components/BackupSection.kt`, opened from the
three-finger hub menu like Nodes/FIPS) — SAF file picker
(`CreateDocument` for export, `OpenDocument` for import), passphrase
fields, verified-backup preview, result summary. The suggested export
name is `archy-companion-backup-YYYYMMDD-HHmmss.json`.
## Restore semantics — never silently destructive
| What | On restore |
|---|---|
| Servers | Upsert (`ServerPreferences.upsertServer`): same npub merges (even when every address changed), new ones append |
| Active server | Set only when this phone has none (the fresh-wipe case) |
| FIPS identity | Restored only when this phone has none; node peers UNION by npub (`FipsPreferences.mergePeersJson`); party peers merge by npub |
| Signer key | Restored only when this phone has none |
| introSeen flag | Restored (no re-onboarding after a restore) |
The identity rules exist because a phone that already paired has a live mesh
identity nodes peer with; swapping it in from a backup would strand the
current pairing.
## Test checklist (on-device)
- [ ] Export → file saved, `version: 1`, `kind: companion`, base64 blob ≥ 44 chars.
- [ ] Wrong passphrase on import → "wrong passphrase" error, no state change.
- [ ] Correct passphrase → preview shows the right server count; restore on a
second install (or after clearing app data) reconnects to the node
without re-pairing, mesh included.
- [ ] Re-scan the node's QR after restore → no duplicate entry.
- [ ] The old phone's password for a node restores (login works on the new phone).
## Roadmap notes (node-side, tracked separately)
Node-side storage/quota/scheduling for companion backups ("passport prime
combo") is roadmap territory — this issue's scope was the phone side. The
envelope is ready to be a drop-in for the node's existing backup RPCs when
that lands.
+108
View File
@@ -0,0 +1,108 @@
# Companion NIP-46 remote signer — the phone side of Nostr Bunker (#139)
**Status:** shipped in companion 0.5.28 (vc48). Issue: #139 ("Remote signer
with companion app?"). Background research:
[`nostr-signer-login-research.md`](nostr-signer-login-research.md) — flow B of
that document is exactly the flow this implements, with the companion playing
the role the research assigned to Amber.
## What shipped: the phone IS the bunker (remote signer)
The companion holds a nostr key (generate or import an `nsec`) and speaks
NIP-46 as the **remote signer**:
1. A NIP-46 client — the node's login page, per the research doc's flow B,
or any `nostrconnect://`-emitting app — shows its pairing QR.
2. The phone scans it (hub → **Remote Signer** → *Scan pairing QR*), or any
QR-scanner app hands the `nostrconnect://` URI over as a deep link
(registered in the manifest).
3. The phone connects to the client's relay(s), subscribes to kind-24133
events p-tagged to its own key, and sends the `connect` request carrying
the secret — the same handshake direction rust-nostr's reference bunker
uses (`NostrConnectRemoteSigner::send_connect_ack`), which is what the
node's eventual nostr-connect client will wait for.
4. Requests arrive NIP-44-encrypted. Handled methods:
- `connect` → "ack" (validates our pubkey + the pairing secret)
- `get_public_key` → our pubkey
- `describe` → method list
- `ping` → "pong"
- **`sign_event` → an approve/deny card — kind label, content, tags,
time. Nothing signs without a thumb on Approve.** Deny replies
`"denied"`; a second request while one is pending replies `"busy"`
instead of replacing the visible card.
- anything else → `"not authorized"` (nip04/nip44 encrypt/decrypt are
deliberately NOT granted in v1).
5. Responses go back over the same encrypted kind-24133 channel.
The session lives while the app does (the login handshake takes seconds);
remembered-session auto-reconnect is the research doc's deferred flow C, and
stays deferred. NIP-04 is accepted on receive as a fallback (deprecated but
still spoken by real clients); all sending is NIP-44 v2.
## Where the code lives
- **Crypto:** `Android/rust/archy-fips-core/src/nostr.rs` — nsec/npub bech32
keys, BIP-340 schnorr event signing (NIP-01 id serialization), NIP-44 v2
payloads, NIP-04 fallback, `nostrconnect://` parsing. Host `cargo test`
runs the official NIP-44 vectors (conversation/message keys, padded
lengths, byte-exact encrypt vectors), the official BIP-340 sign vectors,
and round-trip/tamper/failure cases.
- **JNI:** `com.archipelago.app.NativeCore` (same .so as the FIPS mesh).
- **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client,
JSON-RPC dispatch, approve/deny state.
- **UI:** a hub sub-page (`ui/components/SignerSection.kt`, opened from the
three-finger hub menu like Nodes/FIPS) — key setup, npub/nsec display,
pairing scan, session status, the approve/deny card. The full-screen
pairing scanner (`QrGlassModal`) is hosted by NESMenu so it isn't clipped
to the panel's bounds. The `nostrconnect://` deep link routes to the
session and pops the hub open on the signer sub-page (`SignerLaunch`).
## Security notes (conscious deviations, reviewed)
- Incoming events are **not** signature-verified before decryption — the
same choice rust-nostr's reference bunker makes. The NIP-44 MAC is the
actual gate: forging content that decrypts with a valid MAC requires one
of the two conversation secrets. A future hardening pass may add event
verification first.
- The signer secret lives in app-private DataStore (same storage model as
the FIPS secret and node login passwords). It can additionally be sealed
inside an encrypted backup (see
[`companion-backup-restore.md`](companion-backup-restore.md)).
- `sign_event` approval is per-request and per-screen; there is no
"remember this client" auto-approve in v1.
## End-to-end test harness (the node side doesn't exist yet)
`Android/tools/nip46-test-client.py` plays the node's role: generates the
pairing QR in your terminal, runs the full handshake, requests
`get_public_key` + `sign_event`, and verifies the returned signature with an
independent pure-Python BIP-340 implementation (no code shared with the
phone's Rust core; both are pinned to the same official test vectors).
```bash
python3 -m venv /tmp/nip46env
/tmp/nip46env/bin/pip install websockets qrcode
/tmp/nip46env/bin/python Android/tools/nip46-test-client.py # --relay to override
```
Then on the phone: hub → Remote Signer → Generate key (once) → Scan pairing
QR → point at the terminal QR → Approve the incoming request. The harness
prints `END-TO-END PASS` when the phone-signed event verifies.
## Test checklist (on-device)
- [ ] Generate key → npub shows, copy works; import nsec → same npub.
- [ ] Harness handshake: pair → ack → `get_public_key` returns the phone's npub.
- [ ] `sign_event` request shows a legible card (kind label, content, tags);
Approve → harness verifies the schnorr signature; Deny → harness sees
`"denied"`.
- [ ] Deep link: open a `nostrconnect://…` URI from a QR app → SignerScreen
with the pairing already starting.
- [ ] Wrong/foreign QR → clear error, no state change.
## Roadmap (node-side, tracked separately)
The node-side bunker hosting/login flow (research doc flows A+B, the
`auth.login.nostr` slot, relay topology on the node's own strfry) is roadmap
territory via the `companion-agent`-labeled tracker issues; when it ships,
the phone side here already speaks its language.
+17 -8
View File
@@ -88,29 +88,29 @@ proprietary and Play-Services-backed.
## Integration sketch
> ⚠️ Coordinates and API surface below are from memory and were **not**
> verified against Maven Central — the machine this was written on had no
> network. Confirm the current artifact version and wrapper API on the first
> online Gradle sync before trusting the snippet.
> Verified 2026-08-31 against Maven Central and the wrapper source
> (`wrappers/android/zxingcpp/src/main/java/zxingcpp/BarcodeReader.kt` at
> `io.github.zxing-cpp:android:3.1.1`, the current release). Coordinates and
> API below are what the published artifact actually ships.
`Android/app/build.gradle.kts`:
```kotlin
// Replaces com.google.zxing:core for the live-camera path.
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
implementation("io.github.zxing-cpp:android:3.1.1")
```
`QrCodeAnalyzer` collapses to roughly:
```kotlin
private val reader = BarcodeReader().apply {
private val reader = BarcodeReader(
options = BarcodeReader.Options(
formats = setOf(BarcodeFormat.QR_CODE),
formats = setOf(BarcodeReader.Format.QR_CODE),
tryHarder = true,
tryRotate = true,
tryInvert = true,
)
}
)
override fun analyze(image: ImageProxy) {
try {
@@ -121,6 +121,15 @@ override fun analyze(image: ImageProxy) {
}
```
API notes from the published wrapper: `BarcodeReader.read(ImageProxy)` takes
the CameraX `YUV_420_888` frame directly (it reads the Y plane + cropRect +
rotation itself — the manual crop/copy machinery really can go); options are
one constructor-argument data class; `Format.QR_CODE` is nested inside
`BarcodeReader` (not a top-level `BarcodeFormat`); results carry `text`,
`contentType`, `position` — and `lastReadTime` gives the per-call decode time
in ms, useful to measure the claimed 5–10× while evaluating. Keep
`com.google.zxing:core` for the still-image path regardless (below).
Keep `com.google.zxing:core` for now regardless: the still-image path
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
`prewarmQrScanner` both use it, and neither is on the hot path.