Files
archy/docs/companion-nip46-remote-signer.md
Dorian 981296e8b0 fix(companion): backup + signer live inside the hub modal, not standalone screens
Field feedback on 0.5.28: the standalone Backup/Signer screens were hard
to read over the synthwave background, back left the app instead of the
menu, and they broke the hub's one-container interaction model. Both are
now hub sub-pages exactly like Nodes/FIPS:

- BackupSection / SignerSection (ui/components) render inside the NESMenu
  panel with the menu's own dark glass surface, scrim, and palette — the
  readability and theming problem disappears with the standalone surface.
- The header back arrow returns to the hub card page (same as Nodes).
- The panel height cap drops from 92% to 70% of the screen — ~15%
  breathing margin top and bottom; content scrolls inside.
- The pairing QR scanner is hosted by NESMenu OUTSIDE the panel
  (QrGlassModal is a full-screen Box, not a Dialog — inside the panel's
  scroll it would clip), and decoded nostrconnect:// URIs funnel into the
  signer section through the same latch as the deep link.
- The nostrconnect:// deep link now routes to the session and pops the
  hub open on the signer sub-page (SignerLaunch singleton) instead of a
  dedicated route; standalone screens and routes removed.
- BunkerManager.refreshState is now a proper suspend fun (was
  runBlocking on the caller's dispatcher).

Docs updated to the new locations. Rebuilt for on-device testing
(v0.5.28-debug/vc48, same signing cert).
2026-08-31 14:23:04 +01:00

5.5 KiB

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 — 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).
  • 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).

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.