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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user