Files
sovereign_browser/plans/nostr-login-integration.md

14 KiB

Nostr Login Integration — Plan

Goal

When the browser opens, the first thing the user sees is a Nostr login screen. After authenticating, the browser has the user's Nostr identity (pubkey + signing capability) and injects window.nostr into every page it loads.

We want all the functionality of nostr_login_lite — local key, seed phrase, read-only, NIP-46 remote signer, n_signer hardware — as login methods.

Reference implementations

  • nostr_core_lib/examples/note_poster.c — shows local key + all n_signer transport modes (unix, serial, tcp, fds) via nostr_signer_t.
  • n_signer/client/demo_c99.c — comprehensive C99 demo: qrexec transport, nostr_signer_nsigner_set_nostr_index(), getPublicKey + signEvent + nip44 encrypt/decrypt round-trip. This is the pattern for n_signer login.
  • nostr_core_lib/examples/nip46_remote_signer.c — NIP-46 session pattern.
  • nostr_core_lib/examples/mnemonic_generation.c / mnemonic_derivation.c — seed phrase key generation/derivation pattern.

Key insight: nostr_core_lib already has everything

nostr_core_lib (at ~/lt/nostr_core_lib) is a C library that already implements all the crypto and protocol functionality that nostr_login_lite does in JavaScript:

Login method nostr_core_lib C API
Local key (generate / paste nsec) nostr_generate_keypair(), nostr_decode_nsec(), nostr_key_to_bech32()
Seed phrase (BIP-39) nostr_generate_mnemonic_and_keys(), nostr_derive_keys_from_mnemonic()
Read-only (npub only) nostr_decode_npub()
NIP-46 remote signer nostr_nip46_parse_bunker_url(), nostr_nip46_client_session_t, full request/response event handling
n_signer hardware nostr_signer_nsigner_unix(), nostr_signer_nsigner_serial(), nostr_signer_nsigner_tcp(), nostr_signer_nsigner_qrexec() — direct transport (USB serial, UNIX socket, TCP, Qubes qrexec), better than WebUSB. Key selection via nostr_signer_nsigner_set_nostr_index(). See n_signer/client/demo_c99.c for the full pattern.
Schnorr signing nostr_create_and_sign_event(), nostr_signer_sign_event()
NIP-04 encrypt/decrypt nostr_nip04_encrypt(), nostr_nip04_decrypt()
NIP-44 encrypt/decrypt nostr_nip44_encrypt(), nostr_nip44_decrypt()
Unified signer nostr_signer_t — local + n_signer backends, all NIP-07 verbs (getPublicKey, signEvent, nip04/nip44)

The nostr_signer_t abstraction is the key: it provides a unified interface across local keys and n_signer hardware. The browser creates a signer at login time and routes all window.nostr calls through it.

What nostr_login_lite adds that is NOT in nostr_core_lib:

  1. UI — modal dialog, floating tab, themes (we build a GTK dialog instead)
  2. Orchestration — method selection, persistence, session restore (straightforward C)
  3. window.nostr facade — JS shim injected into pages (we inject via WebKitGTK)

Decision: Native C99 login using nostr_core_lib

Since nostr_core_lib provides all the functionality, we build the login as a native GTK dialog that calls nostr_core_lib directly. No JavaScript login page needed. This is the purest C99 path and avoids the WebKitGTK WebUSB/WebSerial limitation entirely — n_signer works via direct serial/socket transport in C.

Architecture

flowchart TB
    subgraph C99Host[sovereign_browser C99 host]
        LoginDlg[GTK Login Dialog]
        KeyStore[C Key Store - file persistence]
        Signer[nostr_signer_t from nostr_core_lib]
        NostrInject[window.nostr JS injector]
        Bridge[C to JS bridge - sovereign:// scheme]
    end

    subgraph NostrCoreLib[nostr_core_lib - linked static lib]
        NIP01[NIP-01 Event signing]
        NIP06[NIP-06 Seed phrase derivation]
        NIP19[NIP-19 bech32 nsec/npub]
        NIP04[NIP-04 Encryption]
        NIP44[NIP-44 Encryption]
        NIP46[NIP-46 Remote signer]
        NSigner[n_signer transport - serial/socket/TCP]
    end

    subgraph WebKit[WebKitGTK Web View]
        Pages[Web pages with window.nostr]
    end

    LoginDlg -- user picks method --> Signer
    LoginDlg -- saves identity --> KeyStore
    Signer --> NIP01
    Signer --> NIP06
    Signer --> NIP19
    Signer --> NIP04
    Signer --> NIP44
    Signer --> NIP46
    Signer --> NSigner
    KeyStore -- restore on startup --> Signer
    Signer -- handles sign requests --> Bridge
    Bridge -- marshals calls --> NostrInject
    NostrInject -- injects into --> Pages

Login flow

flowchart TD
    Start[Browser starts] --> CheckKeyStore{Saved key in store?}
    CheckKeyStore -- yes --> RestoreSigner[Restore signer from saved key]
    CheckKeyStore -- no --> ShowLogin[Show GTK Login Dialog]
    ShowLogin --> MethodSelect{User selects method}
    MethodSelect -- Local key --> EnterNsec[Paste nsec or generate new]
    MethodSelect -- Seed phrase --> EnterSeed[Enter BIP-39 mnemonic]
    MethodSelect -- Read-only --> EnterNpub[Paste npub]
    MethodSelect -- NIP-46 --> EnterBunker[Paste bunker:// URL]
    MethodSelect -- n_signer USB --> PickSerial[Select /dev/ttyACM* device]
    EnterNsec --> CreateSigner[Create nostr_signer_local]
    EnterSeed --> DeriveKeys[nostr_derive_keys_from_mnemonic]
    DeriveKeys --> CreateSigner
    EnterNpub --> CreateReadonly[Store pubkey only, no signer]
    EnterBunker --> ConnectNIP46[Parse URL, connect to relay]
    ConnectNIP46 --> CreateSigner
    PickSerial --> CreateNSigner[Create nostr_signer_nsigner_serial]
    CreateNSigner --> CreateSigner
    CreateSigner --> SaveKeyStore[Save identity to key store]
    CreateReadonly --> SaveKeyStore
    SaveKeyStore --> LoadBrowser[Load start URL in web view]
    RestoreSigner --> LoadBrowser
    LoadBrowser --> InjectNostr[Inject window.nostr into all pages]

window.nostr injection — sovereign:// URI scheme bridge

After login, every page loaded in the web view gets a window.nostr object injected via WebKitUserContentManager + webkit_user_content_manager_add_script(). The injected JS shim exposes the NIP-07 API:

window.nostr = {
  getPublicKey: () => bridgeCall('getPublicKey'),
  signEvent: (event) => bridgeCall('signEvent', event),
  getRelays: () => bridgeCall('getRelays'),
  nip04: { encrypt: (pubkey, text) => bridgeCall('nip04_encrypt', {pubkey, text}),
           decrypt: (pubkey, ct) => bridgeCall('nip04_decrypt', {pubkey, ct}) },
  nip44: { encrypt: (pubkey, text) => bridgeCall('nip44_encrypt', {pubkey, text}),
           decrypt: (pubkey, ct) => bridgeCall('nip44_decrypt', {pubkey, ct}) }
};

bridgeCall() marshals to C via a custom sovereign:// URI scheme fetch: fetch('sovereign://nostr/getPublicKey') etc. The C scheme handler receives the request, calls the appropriate nostr_signer_t function, and returns the result as the HTTP response body.

This is the same nos2x surface, so existing Nostr web apps work without an extension.

Architecture decision: URI scheme now, WebExtension later

Two approaches were considered for the JS→C bridge:

  1. sovereign:// URI scheme (chosen for now) — JS does fetch() to a custom scheme, C handles it. Simple, one callback, no separate library.
  2. WebKitWebExtension + JSC (future) — a .so loaded into the web process that registers C functions as JS globals. The "proper" WebKitGTK way, supports synchronous calls, process isolation.

Why start with the URI scheme: it serves double duty. The same webkit_web_context_register_uri_scheme() infrastructure is needed for:

  • sovereign://nostr/* — window.nostr bridge (Phase 2)
  • sovereign://settings, sovereign://about — browser-internal pages
  • fips:// / *.fips — FIPS mesh routing (roadmap item 3)
  • nostr:// — Nostr content scheme (roadmap item 5)

Building the scheme handler infrastructure once covers all these use cases.

When to add WebExtension: when we need:

  • Synchronous NIP-07 calls (some older Nostr apps expect sync signEvent)
  • Security stripping (CORS/SOP removal in the web process, before scripts run)
  • Fine-grained request interception or content injection

The URI scheme is the network/content layer (controls what bytes load). The WebExtension is the script manipulation layer (controls what happens inside the web process). They serve different layers and will coexist.

Implementation plan

  1. Update Makefile to link against libnostr_core_x64.a and its dependencies (-lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm).
  2. Create src/key_store.h / src/key_store.c — a C module that:
    • Saves/loads the user's identity to ~/.sovereign_browser/identity.json
    • Stores: { method, pubkey_hex, private_key_hex?, nsigner_device?, nip46_session? }
    • On startup, checks if a saved identity exists and restores the signer.
  3. Create src/login_dialog.h / src/login_dialog.c — a GTK dialog with:
    • Method selection buttons: Local Key, Seed Phrase, Read-only, NIP-46, n_signer Hardware
    • Input fields per method (nsec entry, mnemonic entry, npub entry, bunker URL entry, serial device dropdown)
    • "Generate new key" button for local key method
    • "Generate new mnemonic" button for seed phrase method
    • Calls nostr_core_lib functions to create the signer
    • Returns a nostr_signer_t* (or NULL for read-only) + pubkey
  4. Modify src/main.c — on startup:
    • Try to restore identity from key store
    • If no saved identity, show login dialog before creating the web view
    • Store the signer globally for the window.nostr bridge
  5. Test: launch browser, see login dialog, paste an nsec, verify the pubkey is derived correctly, browser opens.

Phase 2 — window.nostr injection + sovereign:// bridge

  1. Register sovereign:// URI scheme via webkit_web_context_register_uri_scheme().
  2. Create src/nostr_bridge.h / src/nostr_bridge.c — handles sovereign://nostr/<method> requests:
    • getPublicKeynostr_signer_get_public_key()
    • signEventnostr_signer_sign_event()
    • nip04_encrypt / nip04_decryptnostr_signer_nip04_*()
    • nip44_encrypt / nip44_decryptnostr_signer_nip44_*()
    • Returns results as JSON in the scheme response stream.
  3. Create src/nostr_inject.h / src/nostr_inject.c — builds the JS shim string and injects it via WebKitUserContentManager into all frames before page scripts run.
  4. Test: load a Nostr web app (e.g. a test page that calls window.nostr.getPublicKey()), verify it gets the pubkey.

Phase 3 — Seed phrase + read-only + NIP-46 login

  1. Seed phrase screen — text area for mnemonic, calls nostr_derive_keys_from_mnemonic(). Add "generate new mnemonic" button using nostr_generate_mnemonic_and_keys().
  2. Read-only screen — npub entry, calls nostr_decode_npub(). No signer created; window.nostr.signEvent() returns an error.
  3. NIP-46 screen — bunker:// URL entry, calls nostr_nip46_parse_bunker_url(), establishes a WebSocket connection to the relay, sends a connect request. Uses nostr_nip46_client_session_t.
  4. Test: each login method end-to-end.

Phase 4 — n_signer hardware login

  1. n_signer screen — transport selection:
    • Serial: enumerate via nsigner_transport_list_serial(), dropdown
    • UNIX socket: enumerate via nsigner_transport_list_unix(), dropdown
    • TCP: host + port entry fields
    • Qubes qrexec: target qube + service name entry (for Qubes OS)
    • nostr_index selector (NIP-06 m/44'/1237'/N'/0/0) — numeric input, default 0
  2. Create signernostr_signer_nsigner_serial/unix/tcp/qrexec(...), then nostr_signer_nsigner_set_nostr_index(signer, index).
  3. Get pubkeynostr_signer_get_public_key() to verify the device and show the npub to the user.
  4. Persist — save the transport type + connection params + nostr_index (not a private key) in the key store. On restore, reopen the transport.
  5. Test: connect to n_signer via each transport, verify pubkey, sign an event from a web page. Follow the pattern in n_signer/client/demo_c99.c.

Phase 5 — Persistence + polish

  1. Encrypted key file — encrypt the private key in the key store with a user-supplied password (AES-256-GCM via OpenSSL).
  2. Session lock — menu option to lock the session (clear signer, require re-auth).
  3. Auto-restore on startup — if a saved identity exists, skip the login dialog and restore the signer directly.
  4. Login dialog styling — match the browser's aesthetic (monospace, dark theme option).
  5. Error handling — clear error messages for invalid keys, connection failures, device not found, etc.

File structure after implementation

src/
  main.c              — modified: login flow + signer lifecycle
  version.h           — existing
  key_store.h         — new: identity persistence
  key_store.c         — new
  login_dialog.h      — new: GTK login dialog
  login_dialog.c      — new
  nostr_bridge.h      — new: sovereign:// scheme handler for window.nostr
  nostr_bridge.c      — new
  nostr_inject.h      — new: JS shim injection
  nostr_inject.c      — new
Makefile              — modified: link nostr_core_lib + deps

Dependencies to add

  • libnostr_core_x64.a (or arm64) — static library from nostr_core_lib
  • -lsecp256k1 — Schnorr signing
  • -lssl -lcrypto — OpenSSL (AES, SHA, HMAC)
  • -lcurl — HTTP client (NIP-05, relay queries)
  • -lz — compression
  • All already required by nostr_core_lib; see its README for the full link line.

What we do NOT need

  • nostr_login_lite JS — not needed; nostr_core_lib covers all functionality
  • nostr-tools JS bundle — not needed
  • WebUSB / WebSerial — not needed; n_signer works via direct serial/socket
  • A web server — not needed; login is a native GTK dialog
  • JS-to-C bridge for login — not needed; login is pure C + GTK
  • JS-to-C bridge for signing — needed, but only for the window.nostr injection into web pages (Phase 2), not for the login itself