Companion 0.5.28 (versionCode 48), the companion-agent queue items: #128 Backup & Restore — the phone side of losing your phone or wiping it to cross a border. Hub card → SAF export/import of an encrypted .json: everything the app holds (servers+passwords, FIPS identity/peers, signer key) sealed in the node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, native backup.rs — same blob layout as the node's, node-shaped envelopes decrypt too). Restore is merge-only: servers upsert npub-first, identity and signer key adopt only when absent, peers union by npub. No cloud, no telemetry — the file goes wherever the user saves it. #139 Remote Signer — the phone IS the NIP-46 bunker. Generate/import a nostr key, scan a nostrconnect:// QR (in-app scanner or deep link), and approve/deny each sign_event request from a legible card (kind label, content, tags, time) — nothing signs without a human. Wire-faithful to rust-nostr's reference bunker (connect-carrying-secret handshake, NIP-44 v2 transport with NIP-04 receive fallback, kind-24133 responses); get_public_key/describe/ping handled, everything else 'not authorized'. Session state in BunkerManager, UI in SignerScreen, hub card wired. Plus NativeCore (JNI object for the new native surface), FipsPreferences peers-merge for restore, nostrconnect:// intent filter, and the release docs (companion-backup-restore.md, companion-nip46-remote-signer.md). Also Android/tools/nip46-test-client.py: a pure-Python NIP-46 client that plays the node's login role (QR, handshake, get_public_key, sign_event) and verifies the phone's signature with an independent BIP-340 — the end-to-end test for the feature until node-side lands. Its crypto matches the official NIP-44 + BIP-340 vectors byte-for-byte, the same vectors the Rust core passes, so the two interop by construction. Built + smoke: assembleDebug v0.5.28/vc48, same signing cert as the served 0.5.27 (d622e07e…644d) so it updates in place.
5.3 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:
- 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. - 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). - The phone connects to the client's relay(s), subscribes to kind-24133
events p-tagged to its own key, and sends the
connectrequest 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. - Requests arrive NIP-44-encrypted. Handled methods:
connect→ "ack" (validates our pubkey + the pairing secret)get_public_key→ our pubkeydescribe→ method listping→ "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).
- 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. Hostcargo testruns 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:
ui/screens/SignerScreen.kt— key setup, npub/nsec display, pairing scan, session status, the approve/deny card. - Deep link:
nostrconnect://intent filter → SignerScreen.
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_eventapproval 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_keyreturns the phone's npub. sign_eventrequest 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.