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) vianostr_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:
- UI — modal dialog, floating tab, themes (we build a GTK dialog instead)
- Orchestration — method selection, persistence, session restore (straightforward C)
- 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:
sovereign://URI scheme (chosen for now) — JS doesfetch()to a custom scheme, C handles it. Simple, one callback, no separate library.WebKitWebExtension+ JSC (future) — a.soloaded 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 pagesfips:///*.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
Phase 1 — Link nostr_core_lib + key store + local key login
- Update Makefile to link against
libnostr_core_x64.aand its dependencies (-lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm). - 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.
- Saves/loads the user's identity to
- 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
- 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
- Test: launch browser, see login dialog, paste an nsec, verify the pubkey is derived correctly, browser opens.
Phase 2 — window.nostr injection + sovereign:// bridge
- Register
sovereign://URI scheme viawebkit_web_context_register_uri_scheme(). - Create
src/nostr_bridge.h/src/nostr_bridge.c— handlessovereign://nostr/<method>requests:getPublicKey→nostr_signer_get_public_key()signEvent→nostr_signer_sign_event()nip04_encrypt/nip04_decrypt→nostr_signer_nip04_*()nip44_encrypt/nip44_decrypt→nostr_signer_nip44_*()- Returns results as JSON in the scheme response stream.
- Create
src/nostr_inject.h/src/nostr_inject.c— builds the JS shim string and injects it viaWebKitUserContentManagerinto all frames before page scripts run. - 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
- Seed phrase screen — text area for mnemonic, calls
nostr_derive_keys_from_mnemonic(). Add "generate new mnemonic" button usingnostr_generate_mnemonic_and_keys(). - Read-only screen — npub entry, calls
nostr_decode_npub(). No signer created;window.nostr.signEvent()returns an error. - NIP-46 screen — bunker:// URL entry, calls
nostr_nip46_parse_bunker_url(), establishes a WebSocket connection to the relay, sends a connect request. Usesnostr_nip46_client_session_t. - Test: each login method end-to-end.
Phase 4 — n_signer hardware login
- 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
- Serial: enumerate via
- Create signer —
nostr_signer_nsigner_serial/unix/tcp/qrexec(...), thennostr_signer_nsigner_set_nostr_index(signer, index). - Get pubkey —
nostr_signer_get_public_key()to verify the device and show the npub to the user. - Persist — save the transport type + connection params + nostr_index (not a private key) in the key store. On restore, reopen the transport.
- 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
- Encrypted key file — encrypt the private key in the key store with a user-supplied password (AES-256-GCM via OpenSSL).
- Session lock — menu option to lock the session (clear signer, require re-auth).
- Auto-restore on startup — if a saved identity exists, skip the login dialog and restore the signer directly.
- Login dialog styling — match the browser's aesthetic (monospace, dark theme option).
- 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