Files
archy/docs/companion-nip46-remote-signer.md
T
Dorian 22f8129b52 feat(companion): backup & restore + NIP-46 remote signer — 0.5.28 (#128, #139)
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.
2026-08-31 13:53:52 +01:00

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:

  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: 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_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.