Research report on the most frictionless "sign in with your Nostr signer" flow for node login (NIP-07 extension + NIP-46 QR scan with Amber, password always kept as fallback), and a plan for adding existing Nostr identities (nsec import / npub watch-only / browser extension) to the Nostr Identities screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5.5 KiB
Sign in to the node with a Nostr signer — research & recommendation
Status: research only (2026-07-16), no code. Companion plan: docs/nostr-identity-import-plan.md.
What's already in the tree (and what it isn't)
The IndeeHub "sign in with signer" work is the inverse of this feature: the node acts
as a NIP-07 provider for embedded iframe apps, signing with node-held keys
(useNostrBridge.ts postMessage bridge → identity.nostr-sign etc., picker UI in
NostrIdentityPicker.vue). It never verifies an external signer — but the UI patterns
(picker modal, QR rendering) and the backend crypto are reusable:
nostr-sdk 0.44is already a core dependency (nostr_handshake.rsruns a real relay client) — schnorr event verification and NIP-46 client support are essentially free on the Rust side.- Auth today is single-password + optional TOTP, and TOTP already uses a two-step
login (
auth.login→auth.login.totp) — the exact slot where a parallelauth.login.nostr.*path fits. - The node can host its own relay (strfry app), and the frontend already bundles
qrcode.
Candidate flows, ranked by friction
A. Browser extension (NIP-07) — lowest friction on desktop (2 clicks)
Login page shows "Sign in with extension" when window.nostr exists. Server issues a
random challenge → extension signs a kind 22242 auth event carrying the challenge →
server verifies signature + challenge + created_at freshness + that the pubkey is
enrolled → normal session cookie. ~50 lines of frontend, ~80 lines of Rust. No relay
involved at all.
B. QR scan with a mobile signer (NIP-46 nostrconnect://) — the headline UX (scan + 1 tap)
- Backend generates an ephemeral client keypair and renders a
nostrconnect://<pubkey>?relay=<url>&secret=<rand>&perms=sign_event:22242&name=ArchipelagoQR. - User scans with Amber (Android reference signer; Aegis/Nowser also scan; nsec.app is paste-based; Alby is not a NIP-46 signer).
- Phone connects to the relay, acks the secret; backend requests one
sign_event:22242over the encrypted NIP-46 channel, verifies, issues the session.
Key architectural choice: make the Rust backend the NIP-46 client (rust-nostr's
nostr-connect crate), talking to the relay over localhost — the browser only polls our
own RPC for "signer connected". No websocket/mixed-content issues in the Vue app.
Relay topology: no public relay is required by the spec — and public relays often
rate-limit ephemeral NIP-46 traffic. The node's own strfry is the ideal relay (private,
LAN-fast); the QR should carry a relay URL derived from the Host the browser used
(LAN IP / Tailscale IP — not .local, which Android often can't resolve).
One empirical blocker to test first: does Amber accept plain ws:// LAN relays?
(Self-signed wss:// will likely fail cert validation.) If not, route wss:// through
the existing nginx/HTTPS cert story.
C. Remembered NIP-46 session (persisted bunker pointer) — zero-tap repeat logins
Same as B but persists the pairing so future logins auto-approve. Adds state, revocation surface, and "bunker offline = silent hang" failure modes. Defer — B re-scans in ~5 seconds anyway.
Recommendation
Ship A + B behind one "Sign in with Nostr" button; skip C for now. Password (+TOTP) stays the permanent fallback — exactly as the user proposed, the signer is enrolled in a step after password creation, never instead of it. The verification core is one shared Rust function (sig + challenge + freshness + enrolled-pubkey → session).
- Onboarding: after the password (and seed) steps, an optional "Connect a signer" card: QR (nostrconnect) + "Use browser extension" + Skip. Success enrolls the npub as a login key.
- Settings (next to TOTP): list enrolled npubs (added date + method), "Add npub" (paste, becomes usable after a challenge-verify), "Connect another signer" (same QR/extension modal), "Remove" (requires password confirm; removing the last npub never locks the account — password always works).
- Libraries: hand-roll the 22242 event for NIP-07 (window.nostr is a browser global);
rust-nostr
nostr-connectfor NIP-46. Avoid the 2.4 MBnostr-loginJS bundle — wrong fit for a self-hosted box (defaults to public bunkers); it's UX prior art only.
Security notes
- Only pubkeys enrolled while authenticated (or during onboarding) may log in —
a simple
login_npubslist next to the TOTP data inauth.rs. - Challenge: 32-byte random, single-use, 2–5 min TTL,
created_at±60 s, deleted on first verify attempt; pin an origin/host tag. Rate-limit like password attempts. - The
secretin the nostrconnect URI is a bearer token — one QR per attempt, expires with the challenge. - Policy call: signer approval should count as the second factor for TOTP accounts (possession of phone/extension key), so nostr login doesn't silently bypass TOTP.
Open questions
- Amber +
ws://LAN relay — needs a 10-minute on-device test before committing. - Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host.
- NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles both; verify against current Amber.
- Track draft NIP-97 "Login with Nostr" (matches this UX exactly, unmerged) — align, don't depend.
Prior art: no mainstream self-hosted node OS (Umbrel, Start9, Alby Hub) ships Nostr QR login for its own UI — this would be genuinely differentiating, and every building block is already in the tree.