From 9e264611e2e245e268f13d790adc9768012a9259 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 16 Jul 2026 21:59:54 -0400 Subject: [PATCH] docs: Nostr signer-login research + identity-import UX plan 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 --- docs/nostr-identity-import-plan.md | 82 +++++++++++++++++++++++++ docs/nostr-signer-login-research.md | 95 +++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 docs/nostr-identity-import-plan.md create mode 100644 docs/nostr-signer-login-research.md diff --git a/docs/nostr-identity-import-plan.md b/docs/nostr-identity-import-plan.md new file mode 100644 index 00000000..dc250f80 --- /dev/null +++ b/docs/nostr-identity-import-plan.md @@ -0,0 +1,82 @@ +# Add an existing Nostr identity to the node — UX & implementation plan + +**Status:** plan only (2026-07-16), no code. Companion research: `docs/nostr-signer-login-research.md`. + +## Where it lives + +The **Nostr Identities** screen (`Web5Identities.vue`, backed by `identity.list` / +`identity.create`). Today every identity is **seed-derived** (`identity_manager.rs` +derives ed25519 + nostr keys from the BIP-39 master seed at an index). "Add existing" +introduces a second class of identity: one whose key material comes from *outside* the +seed. + +## Two import kinds (both needed, different guarantees) + +1. **Full import (nsec)** — the node holds the secret key. The identity behaves exactly + like a seed-derived one (can sign in embedded apps, publish, encrypt). NOT covered by + seed backup — flag it visibly and include it in the encrypted node backup. +2. **Linked signer (npub only)** — the node stores just the public key; signing is + delegated to the user's own signer (browser extension NIP-07, or a NIP-46 remote + signer later). Zero key custody; some features (background publishing) unavailable — + the UI should badge what works. + +## The UX (matching the house style) + +**Entry point:** next to "Create identity" on Nostr Identities, an **"Add existing"** +glass-button. Opens a modal with three tabs (same tab pattern as the send/receive +modals): + +1. **Browser extension** (default when `window.nostr` exists) + - One button: "Connect with extension". Flow: `getPublicKey()` → show the npub + + resolved profile (kind-0 fetched via the node's relays: avatar, name — instant + recognition) → "Add this identity". + - Creates a **linked signer** identity. A challenge signature + (`signEvent` on a throwaway event) proves key possession before adding — never add + an unverified npub as "yours". +2. **Secret key (nsec)** + - Paste field (masked, `nsec1…` or hex), inline validation + derived npub preview + with the same kind-0 profile card before confirming. + - Scary-clear copy: "Your key will be stored on this node, encrypted at rest. It is + NOT part of your seed backup — back it up separately." Confirm step requires the + profile card to load or an explicit "add anyway". + - Creates a **full** identity. +3. **Public key (npub)** — watch-only + - Paste an npub for a linked identity without any signer attached yet (useful to + reserve the profile, upgrade to extension/NIP-46 signing later). + +**After adding:** the identity appears in the same grid with a small origin badge — +`seed` / `imported` / `linked` — and the imported profile picture/name pulled from +relays. Everything else (picker in apps, rename, avatar) behaves uniformly. + +**Removal:** existing delete flow; for `imported` identities the confirm dialog warns +the key is destroyed unless exported first (offer "Export nsec" in the identity's detail +sheet, gated behind password re-entry). + +## Backend work + +- `identity_manager.rs`: identity records gain `origin: Seed { index } | Imported | + Linked`, optional `nostr_secret_hex` absent for Linked. Storage: reuse the existing + encrypted identity file; imported secrets included in node backup. +- New RPCs: + - `identity.import-nostr` `{ nsec | npub, name?, verify_sig? }` → validates, derives + npub, rejects duplicates (same pubkey as any existing identity), returns the new + identity. + - `identity.fetch-profile` `{ pubkey }` → kind-0 lookup via `nostr_relays.rs` for the + preview card (frontend could also do this, but the node already has relay plumbing + and avoids CORS). +- `identity.nostr-sign` (used by the iframe NIP-07 bridge): for `Linked` identities + return a typed error the bridge translates into "ask the user's extension instead" — + phase 2; phase 1 simply hides linked identities from the in-app signer picker. + +## Demo mode + +Mock `identity.import-nostr` + `identity.fetch-profile` in mock-backend.js (canned +profile: picture + name for any pasted npub) so the whole add-existing flow is +demoable without real relays. + +## Phasing + +1. **Phase 1 (small):** nsec + npub tabs, origin badges, backup inclusion, mock. +2. **Phase 2:** extension tab with possession-proof + kind-0 preview cards everywhere. +3. **Phase 3:** NIP-46 remote-signer identities + login integration (shares the QR + plumbing from the signer-login work). diff --git a/docs/nostr-signer-login-research.md b/docs/nostr-signer-login-research.md new file mode 100644 index 00000000..a8049878 --- /dev/null +++ b/docs/nostr-signer-login-research.md @@ -0,0 +1,95 @@ +# 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.44` is already a core dependency** (`nostr_handshake.rs` runs 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 parallel + `auth.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) +1. Backend generates an ephemeral client keypair and renders a + `nostrconnect://?relay=&secret=&perms=sign_event:22242&name=Archipelago` QR. +2. User scans with **Amber** (Android reference signer; Aegis/Nowser also scan; + nsec.app is paste-based; Alby is *not* a NIP-46 signer). +3. Phone connects to the relay, acks the secret; backend requests one + `sign_event:22242` over 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-connect` for NIP-46. Avoid the 2.4 MB `nostr-login` JS 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_npubs` list next to the TOTP data in `auth.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 `secret` in 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 + +1. Amber + `ws://` LAN relay — needs a 10-minute on-device test before committing. +2. Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host. +3. NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles + both; verify against current Amber. +4. 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.