Sign in with a bunker code (NIP-46) #4

Closed
laoc wants to merge 0 commits from feat/nip46-bunker into main
Owner

Asked for on Zapstore against v0.11.0 by "the axiom": "this is great but it would be even better if it had a way to login with a bunker code!" — and answered "will do".

Stash signed only over NIP-55 until now: an Android intent to a signer app on the same phone. Anyone without one reached No Nostr signer found on this device. and stopped. A bunker code is a remote signer over a relay — nothing installed locally, works with a desktop or hardware key.

Shape

NostrConnectSigner is a third NostrSigner implementation. No caller changes — the repository does not know which signer it was handed.

Tested against a real bunker, not a fake

scripts/e2e-direct.sh now runs nak bunker with a fixed key on its own relay (a fourth service beside the plain/auth/silent relays). The emulator pairs over a websocket and the event that comes back is signed by a key the app process has never seen — the entire point of NIP-46, and the one thing a fake signer could not demonstrate.

BunkerSignerE2ETest   5/5   connect · sign+publish+read-back · unauthorised secret refused
                            · dead bunker fails with a reason · garbage URI rejected
SecretBoxE2ETest      6/6   round trip · ciphertext opaque · fresh IV · tamper fails closed
                            · nonsense fails closed · a different alias cannot open it
full suite           54/54  0 failures

A defect the tests caught

The Duration handed to NostrConnect does not bound the attempt. Configured at 20s, a dead relay still took 30093ms to give up. The bound is now enforced at our own boundary with withTimeoutOrNull, and the test asserts 25s rather than 30 — a 30s assertion would have passed on the binding's own behaviour and never noticed ours had stopped being enforced.

The two questions from the plan

Built to my stated recommendations, since the go-ahead did not pick:

1. Where the client key lives. Sealed with AES-GCM under an AndroidKeyStore key (SecretBox), not plain SharedPreferences. Not androidx.security:security-crypto — a new dependency, deprecated, and what it buys here is sixty lines of platform API. The key is deliberately not user-authentication-bound: the app signs in the background for relay AUTH, and a key demanding a screen unlock would fail those with nothing to ask.

To be precise about what is stored: this is the client key, which identifies the app to the bunker. The user's identity key never enters the app, so the description's claim still holds.

2. One signer at a time. Amber or a bunker; switch by logging out.

Judgement calls worth your eye

  • Startup restore is silent on failure. The library is already on screen from cache and reading needs no signer, so "signer unavailable" on every cold start with a sleeping bunker would be noise. It surfaces when signing is actually attempted.
  • The login screen separates "waiting for your signer to approve" from "could not connect". Pairing blocks on a human on another device; a bare spinner cannot tell the user which is happening, and conflating them is how these features come to feel broken.
  • A failure is Failed, never Rejected. NIP-46 returns an error string, so a user declining and a relay dropping arrive identically — and Rejected drives UI that says the user declined.

Also here

UnsignedEvent.fromJson exists at SDK 0.44.6, so the stale comment in LocalKeysSigner claiming otherwise is wrong; that hand-rolled JSON parse can go in a follow-up.

Plan: PLANS/STASH_NIP46_BUNKER_LOGIN.md.

Asked for on Zapstore against v0.11.0 by "the axiom": *"this is great but it would be even better if it had a way to login with a bunker code!"* — and answered "will do". Stash signed only over **NIP-55** until now: an Android intent to a signer app on the same phone. Anyone without one reached `No Nostr signer found on this device.` and stopped. A bunker code is a **remote** signer over a relay — nothing installed locally, works with a desktop or hardware key. ### Shape `NostrConnectSigner` is a third `NostrSigner` implementation. **No caller changes** — the repository does not know which signer it was handed. ### Tested against a real bunker, not a fake `scripts/e2e-direct.sh` now runs `nak bunker` with a fixed key on its own relay (a fourth service beside the plain/auth/silent relays). The emulator pairs over a websocket and the event that comes back is signed by a key the app process has never seen — the entire point of NIP-46, and the one thing a fake signer could not demonstrate. ``` BunkerSignerE2ETest 5/5 connect · sign+publish+read-back · unauthorised secret refused · dead bunker fails with a reason · garbage URI rejected SecretBoxE2ETest 6/6 round trip · ciphertext opaque · fresh IV · tamper fails closed · nonsense fails closed · a different alias cannot open it full suite 54/54 0 failures ``` ### A defect the tests caught **The `Duration` handed to `NostrConnect` does not bound the attempt.** Configured at 20s, a dead relay still took **30093ms** to give up. The bound is now enforced at our own boundary with `withTimeoutOrNull`, and the test asserts **25s rather than 30** — a 30s assertion would have passed on the binding's own behaviour and never noticed ours had stopped being enforced. ### The two questions from the plan Built to my stated recommendations, since the go-ahead did not pick: **1. Where the client key lives.** Sealed with AES-GCM under an `AndroidKeyStore` key (`SecretBox`), not plain SharedPreferences. Not `androidx.security:security-crypto` — a new dependency, deprecated, and what it buys here is sixty lines of platform API. The key is deliberately **not** user-authentication-bound: the app signs in the background for relay AUTH, and a key demanding a screen unlock would fail those with nothing to ask. To be precise about what is stored: this is the **client** key, which identifies the app to the bunker. The user's identity key never enters the app, so the description's claim still holds. **2. One signer at a time.** Amber *or* a bunker; switch by logging out. ### Judgement calls worth your eye - **Startup restore is silent on failure.** The library is already on screen from cache and reading needs no signer, so "signer unavailable" on every cold start with a sleeping bunker would be noise. It surfaces when signing is actually attempted. - **The login screen separates "waiting for your signer to approve" from "could not connect".** Pairing blocks on a human on another device; a bare spinner cannot tell the user which is happening, and conflating them is how these features come to feel broken. - **A failure is `Failed`, never `Rejected`.** NIP-46 returns an error string, so a user declining and a relay dropping arrive identically — and `Rejected` drives UI that says the user declined. ### Also here `UnsignedEvent.fromJson` exists at SDK 0.44.6, so the stale comment in `LocalKeysSigner` claiming otherwise is wrong; that hand-rolled JSON parse can go in a follow-up. Plan: `PLANS/STASH_NIP46_BUNKER_LOGIN.md`.
Asked for on Zapstore against v0.11.0: "it would be even better if it had
a way to login with a bunker code". Stash signs only over NIP-55 today, so
anyone without a signer app installed reaches "No Nostr signer found on
this device" and stops there.

NostrConnectSigner is a third NostrSigner implementation. No caller
changes: the repository does not know which signer it was handed.

Tested against a REAL bunker, not a fake. scripts/e2e-direct.sh now runs
`nak bunker` with a fixed key on its own relay, so the emulator pairs over
a websocket and the event that comes back is signed by a key this process
has never seen — which is the whole point of NIP-46 and the one thing a
fake signer could not show.

One thing the tests found: the Duration handed to NostrConnect does NOT
bound the attempt. Configured at 20s, a dead relay still took 30093ms to
give up. The bound is now enforced at our own boundary with
withTimeoutOrNull, and the test asserts 25s rather than 30 — a 30s
assertion would have passed on the binding's own behaviour and never
noticed ours had stopped working.

A failure is reported as Failed, never Rejected: NIP-46 returns an error
string, so a user declining and a relay dropping arrive identically, and
Rejected drives UI that says the user declined.
Sign in with a bunker code
All checks were successful
build / build (push) Successful in 6m46s
build / build (pull_request) Successful in 6m51s
0c553d6ae0
Completes the NIP-46 path: encrypted session storage, restore at startup,
a login UI, and a settings label that is not a sentinel string.

The client key is sealed by SecretBox — AES-GCM with a key held in
AndroidKeyStore — not stored beside the relay list in the clear. Not
androidx.security:security-crypto: that is a new dependency, it is
deprecated, and what it gives us here is sixty lines of platform API. The
key is deliberately NOT user-authentication-bound, because the app signs
in the background for relay AUTH and a key demanding a screen unlock
would fail those with nothing to ask.

Restore is asynchronous and silent on failure. The library is already on
screen from cache and reading needs no signer, so "signer unavailable" on
every cold start with a sleeping bunker would be noise; it surfaces when
signing is actually attempted.

The login screen distinguishes waiting from broken. Pairing blocks on a
human approving on another device, and a bare spinner cannot tell the
user which of those is happening — that is how these features come to
feel broken.

Logout drops both halves: the sealed session and the keystore key that
opens it.
Author
Owner

Closed because it has landed, not because it was dropped: feat/nip46-bunker is an ancestor of main (ff39aee) via #6. Verified with git merge-base --is-ancestor, not by eye.

The branch itself is untouched, so nothing here is lost.

Closed because it has landed, not because it was dropped: `feat/nip46-bunker` is an ancestor of `main` (`ff39aee`) via #6. Verified with `git merge-base --is-ancestor`, not by eye. The branch itself is untouched, so nothing here is lost.
laoc closed this pull request 2026-08-06 10:33:25 +00:00
All checks were successful
build / build (push) Successful in 6m46s
build / build (pull_request) Successful in 6m51s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
laoc/stash-bookmark!4
No description provided.