21 KiB
n_signer WebUSB Integration
Add WebUSB support for the n_signer hardware signer (Feather S3 firmware) to nostr_login_lite as a new login method in the modal. Any host page that consumes window.nostr automatically gets hardware signing — without per-page edits.
1. Why WebUSB is the only thing to add
n_signer is a multi-transport signer (one core dispatcher, many wire formats), but from inside a browser tab the picture is simple:
| n_signer transport | Reachable from this lib today? | Action |
|---|---|---|
WebUSB (Feather S3, VID:PID 303a:4001, framed JSON-RPC) |
✅ Yes | This plan. New code. |
NIP-46 bunker (deferred upstream — plans/nip46_bunker_mode.md) |
✅ When upstream ships | Free — uses existing connect tile. No code. |
Browser extension (deferred upstream — plans/nsigner_browser_extension.md) |
✅ When upstream ships | Free — uses existing extension tile. No code. |
| AF_UNIX, qrexec, stdio, raw TCP, CDC serial | ❌ Browser physics | Not in scope. |
So the entire integration is: add a WebUSB tile. The other paths land for free as n_signer ships them, through the modal options the lib already has.
2. What's already in place on both sides
2.1 n_signer (Feather S3 firmware)
- Composite USB device, VID:PID
303a:4001, exposes a Vendor / WebUSB interface (firmware/README.md). - Wire format: 4-byte big-endian length prefix + JSON body, JSON-RPC 2.0 shape (
README.md). - Verbs we need:
get_public_key,sign_event,nip04_encrypt/nip04_decrypt,nip44_encrypt/nip44_decrypt(README.md). - Selector:
{ nostr_index: N }. - Every WebUSB request must carry an auth envelope: a kind-27235 Nostr event signed by the caller's keypair, with required tags
nsigner_rpc,nsigner_method,nsigner_body_hash(full spec:plans/caller_token_identity.md). Reference builder:feather_webusb_demo.html.
2.2 nostr_login_lite (this repo)
The lib already has the right shape for "yet another signing method":
- Modal tiles render in
src/ui/modal.jsand dispatch in_handleOptionClick()atsrc/ui/modal.js. - The
WindowNostrfacade atsrc/build.jsalready switches onthis.authState.methodfor all six NIP-07 methods. Switch locations:
| NIP-07 method | Switch line |
|---|---|
getPublicKey() |
src/build.js |
signEvent() |
src/build.js |
nip04.encrypt |
src/build.js |
nip04.decrypt |
src/build.js |
nip44.encrypt |
src/build.js |
nip44.decrypt |
src/build.js |
AuthManagerpersistence is keyed onauthData.method: persist switch atsrc/build.js, restore switch atsrc/build.js.
We add tile + matching case arms; no surface change.
3. Architecture
flowchart LR
subgraph Page[Host page using window.nostr]
UI[App calls window.nostr.signEvent]
end
subgraph Lite[nostr_login_lite]
Modal[Modal: pick n_signer USB]
Facade[WindowNostr facade]
Auth[AuthManager persistence]
end
subgraph Driver[NSignerWebUSB driver]
USB[WebUSB transport: 4-byte BE length + JSON]
RPC[JSON-RPC client]
AuthEnv[kind-27235 auth envelope signer]
end
Device[Feather S3 n_signer hardware]
UI --> Facade
Modal -- _setAuthMethod nsigner --> Facade
Facade -- getPublicKey/signEvent/nip04/nip44 --> Driver
Driver --> USB
USB --> Device
RPC --> USB
AuthEnv --> RPC
Modal -- saves caller key + index --> Auth
Auth -- restores on reload --> Facade
The driver is pure WebUSB + framing + JSON-RPC. The facade holds session state (authState.signer.driver, nostrIndex, caller keypair).
4. Driver module — src/signers/nsigner-webusb.js
New file. Distilled from feather_webusb_demo.html into an ES module / class.
4.1 Public surface
class NSignerWebUSB {
static FILTERS = [{ vendorId: 0x303a, productId: 0x4001 }];
static async requestAndConnect(opts) // navigator.usb.requestDevice + open + claim
static async getPairedDevice(opts) // navigator.usb.getDevices() match for auto-restore
constructor(usbDevice, { callerSecretKey, nostrIndex = 0 })
async open() // selectConfiguration, claim vendor iface, controlTransferOut(0x22, 1)
async close() // release + close, disconnect listener cleanup
isOpen
vendorId / productId / serial
async getPublicKey() // -> hex x-only
async signEvent(unsignedEvent) // -> signed event with id/pubkey/sig
async nip04Encrypt(peerHex, plaintext)
async nip04Decrypt(peerHex, ciphertext)
async nip44Encrypt(peerHex, plaintext)
async nip44Decrypt(peerHex, ciphertext)
onDisconnect(cb) // forwarded from navigator.usb.ondisconnect
}
4.2 Internals (proven in the demo, just refactored)
_sendRpc(req)— frame as4-byte BE length || JSON,transferOut(EP_OUT=1, …), thentransferIn(EP_IN=1, 512)until a full frame is reassembled. Same ring-buffer logic asfeather_webusb_demo.html._buildAuth(method, params)— kind 27235 with required tagsnsigner_rpc,nsigner_method,nsigner_body_hash, signed bycallerSecretKeyusingwindow.NostrTools.schnorr(already in the bundle vianostr-tools). Demo reference:feather_webusb_demo.html.- All RPC params append
{ nostr_index }. Configurable per call so future UI can switch identities without reconnect. - Single in-flight request mutex — the firmware dispatches one at a time and the wire is one bulk endpoint pair.
The driver does no UI and does no persistence. Both stay in nostr_login_lite.
5. nostr_login_lite changes
5.1 Modal — new tile
In src/ui/modal.js _renderLoginOptions(), gated by this.options?.methods?.nsigner !== false:
options.push({
type: 'nsigner',
title: 'USB Hardware signer',
description: 'Sign with USB-connected n_signer hardware',
icon: '🔐'
});
Add a case 'nsigner': this._showNSignerScreen() in the dispatch switch at src/ui/modal.js.
Feature-detect at modal render:
!('usb' in navigator)→ tile shows disabled with a "Chrome/Edge required" hint.!window.isSecureContext→ tile shows disabled with a "Requires HTTPS or localhost" hint (see §11 for why).
Mirrors how extension handles a missing window.nostr.
5.2 Modal — connect screen _showNSignerScreen()
UI elements:
- "Connect Device" button → calls
NSignerWebUSB.requestAndConnect(). - Numeric input for Nostr key index (
nostr_index, default0). Persisted with the auth state. - Status line that reads back the resolved pubkey once
getPublicKeyreturns, for human verification against the device's TFT. - A persistent "look at the device" hint while requests are in-flight (the on-device TFT may be prompting for approval per
plans/feather_signer_ui.md). - "Use this device" →
_setAuthMethod('nsigner', { pubkey, signer: { driver, nostrIndex, callerPubkey } }).
The screen also generates (or loads) the caller keypair for the auth envelope (per plans/caller_token_identity.md). Per-app, per-installation, generated once and persisted (§5.4). The first request triggers an on-device approval prompt — expected behavior; surface a "approve on device" hint while waiting.
5.3 WindowNostr facade — new branch
In src/build.js add a case 'nsigner' arm to each switch:
| Method | Switch location | Action |
|---|---|---|
getPublicKey() |
src/build.js |
Return cached authState.pubkey. Verified at login; no round-trip per call. |
signEvent(event) |
src/build.js |
await authState.signer.driver.signEvent(event). |
nip04.encrypt |
src/build.js |
driver.nip04Encrypt(peer, text). |
nip04.decrypt |
src/build.js |
driver.nip04Decrypt(peer, ct). |
nip44.encrypt |
src/build.js |
driver.nip44Encrypt(peer, text). |
nip44.decrypt |
src/build.js |
driver.nip44Decrypt(peer, ct). |
The facade keeps a single NSignerWebUSB instance for the session. If the device is unplugged mid-session, onDisconnect fires, the facade clears the live driver, any pending call rejects with a recognizable error, and the modal can offer a "Reconnect device" path using _attemptNSignerRestore() (§5.4).
5.4 Persistence — AuthManager
Hardware signing has no master secret to encrypt. Persist:
{
"method": "nsigner",
"pubkey": "<hex of the device-derived nostr pubkey>",
"nostrIndex": 0,
"callerSecretKey": "<hex>",
"callerPubkey": "<hex>",
"deviceVid": 12346,
"deviceProductId": 16385,
"deviceSerial": "<usb serial if available>",
"timestamp": 1730000000000
}
Storage location follows existing isolateSession rules in src/build.js. callerSecretKey is the only secret here and is intentionally not a Nostr identity — it is the per-app caller token (the identity the device approves once). Encrypting it with the same scheme used for local keys is a small non-blocking improvement.
Add a new branch in the persist switch at src/build.js (next to 'extension', 'local', 'nip46') and the matching restore branch at src/build.js.
On page reload, src/build.js gets a new _attemptNSignerRestore():
navigator.usb.getDevices()— already-permitted devices return without a prompt (per WebUSB spec).- Match
vid==0x303a && pid==0x4001. If multiple, matchserialNumberif stored. - If found,
open(), thengetPublicKey()and verify it matches storedpubkey. Mismatch → treat as logout (the user re-paired the device with a different mnemonic). - If no device is currently plugged in, dispatch
nlReconnectionRequired(analogous to the NIP-46 path) so the host page can show a "Plug in your signer" prompt instead of silently failing.
5.5 Logout
NOSTR_LOGIN_LITE.logout() already dispatches nlLogout. Add a listener in the facade to call driver.close() and zeroize the cached caller key.
6. Open questions
- Caller-key generation. The demo at
feather_webusb_demo.htmlhard-codes[1..32]as the caller key. We must generate a fresh random caller key per browser profile (matches §1.2 ofplans/caller_token_identity.md). Confirm. - Methods config gating. Add a
methods.nsigner: falseopt-out ininit(), mirroring existingmethods.{extension,local,seedphrase,connect,readonly,otp}flags. UpdateREADME.mdaccordingly. - Index switching at runtime. Do we want a UI to switch
nostr_indexafter login (effectively switching identities without re-pairing)? Easy to add viaNOSTR_LOGIN_LITE.setNSignerIndex(n). Defer to v1.1? - Optional encryption of
callerSecretKeyat rest. It's not a Nostr identity, but encrypting it with the same scheme aslocalkeys is cheap and reduces casual exfiltration.
7. Risks & mitigations
| Risk | Mitigation |
|---|---|
| WebUSB unsupported in Firefox/Safari | Feature-detect 'usb' in navigator at modal render; show the tile in a disabled state with a "Chrome/Edge required" tooltip. Mirrors how extension handles missing window.nostr. |
| Linux udev rule needed | Show the udev install snippet from firmware/README.md inline in the connect screen if requestDevice errors with SecurityError. |
| Concurrent calls collide on the device | Driver mutex serializes RPCs. Tests cover overlapping signEvent from multiple async callers in the same page. |
| Device unplug mid-flow | usb.ondisconnect → reject pending, dispatch nlReconnectionRequired, keep authState so reconnect resumes seamlessly. |
| Approval prompts on the TFT block requests | Surface a "Approve on device" hint and a soft 30 s timeout that doesn't reject — keeps spinning until the device responds. |
| Bundle size growth | Driver is small (~5–8 KB). Reuses nostr-tools schnorr already in the bundle. No new deps. |
| User pairs a different mnemonic on the same physical device | Auto-restore step §5.4.3 detects the pubkey mismatch and forces re-login instead of silently signing with the wrong identity. |
Caller-key leak from localStorage |
Document that callerSecretKey is per-app, not a Nostr identity. Optionally encrypt at rest with the same scheme as local. |
8. Test plan
8.1 Unit (driver, no device)
Mock USBDevice. Verify:
- 4-byte BE length framing (out and in).
- Ring-buffer reassembly across split chunks of arbitrary boundaries.
- Auth envelope produces a kind-27235 event whose
nsigner_body_hashmatchesSHA-256(JCS(params)). - Auth envelope verifies with
nostr-toolsverifyEvent()round-trip. - Mutex serializes overlapping
signEventcalls. onDisconnectrejects in-flight calls with a recognizable error.
8.2 Manual (with real Feather S3 device)
- Pair flow. Open
examples/sign.html, pick the n_signer tile, complete pairing, sign a kind-1 event. - Auto-restore. Reload the page; the device should re-attach via
navigator.usb.getDevices()without a permission prompt; pubkey verifies; signing works without re-pairing. - Unplug → replug. While paired, unplug the device;
nlReconnectionRequiredshould fire. Replug; "Reconnect device" button completes without re-entering the modal. - Wrong mnemonic. Re-pair the device with a different mnemonic, reload host page; auto-restore should detect the pubkey mismatch and force logout.
- NIP-04 / NIP-44 round-trip. Encrypt to self, decrypt, verify equality.
- Cross-method interaction. Logout from
nsigner; switch tolocal; back tonsigner. No leaked state inlocalStorage.
8.3 Negative
- Wrong index → device returns error; UI surfaces it.
- Device locked (firmware-side session locked) →
unauthorizederror surfaced. - USB error mid-frame → driver rejects pending; mutex releases; next call works after reconnect.
- WebUSB denied at OS level (no udev rule) → friendly Linux hint shown.
8.4 Cross-browser
- Chrome, Edge: full support.
- Firefox, Safari: tile shows disabled state with hint; existing methods (
extension,local,connect) remain unaffected.
9. Phased delivery
flowchart TD
P1[Phase 1: WebUSB driver module + harness] --> P2[Phase 2: Modal tile + facade branches]
P2 --> P3[Phase 3: Persistence + auto-restore]
P3 --> P4[Phase 4: Disconnect/reconnect UX + Linux udev hint]
P4 --> P5[Phase 5: Docs + example page]
- Phase 1 — Driver. Land
src/signers/nsigner-webusb.jsplus a standalone harness page mirroring the upstream demo. No changes to the lib's public surface yet. Unit tests (§8.1) green. - Phase 2 — Modal tile + facade. New tile,
_setAuthMethod('nsigner', …), six facadecasearms. Manual smoke test fromexamples/sign.html. - Phase 3 — Persistence + auto-restore.
AuthManagercase 'nsigner'(persist + restore),_attemptNSignerRestore()on init. - Phase 4 — Disconnect/reconnect.
nlReconnectionRequiredparity with NIP-46. Friendly Linux udev snippet on firstSecurityError. - Phase 5 — Docs. Update
README.mdmethods.nsigner, addexamples/nsigner.html, brief section inlogin_logic.mdand a one-line note thatn_signerusers running NIP-46 bunker mode or the future browser extension use the existingconnect/extensiontiles respectively.
10. What we explicitly do not change
- The host page's per-page code — they all reach the new path through
window.nostr. - The
WindowNostrfacade's public surface — only newcasearms, no removed/renamed methods. - Existing methods (
extension,local,seedphrase,connect,readonly,otp) — completely untouched. - The build pipeline — only
src/build.jsand the newsrc/signers/nsigner-webusb.jsfeed the bundler.
11. Development workflow & deployment
11.1 WebUSB requires a secure context
navigator.usb is gated to secure contexts only (WebUSB spec). The host page that loads nostr-lite.js must be served from one of:
- ✅
https://anything.com— production case. - ✅
http://localhostorhttp://127.0.0.1— browsers treat these as secure for WebUSB. - ✅
file:///path/to/page.html— Chrome treatsfile://as secure for WebUSB; viable for local examples / harness pages. - ✅
https://laantungir.net/...— confirmed HTTPS, so the deployed copy athttps://laantungir.net/nostr-login-lite/is WebUSB-eligible. - ❌ Plain
http://non-localhost origins —requestDevice()is undefined or throws. Not a concern in this project since the server is HTTPS.
Both localhost for dev and https://laantungir.net for staging/prod are eligible. No constraint blocks the integration.
11.2 Existing deploy pipeline
deploy.sh already pushes built artifacts to the server:
rsync -avz --chmod=644 --progress build/{nostr-lite.js,nostr.bundle.js} \
ubuntu@laantungir.net:html/nostr-login-lite/
increment_build_push.sh handles version bump → node src/build.js → git commit/tag/push. The two scripts are independent; deploy.sh is the rsync step.
11.3 Recommended dev workflow for this feature
flowchart LR
A[Local edit src/build.js + signers/nsigner-webusb.js] --> B[node src/build.js]
B --> C{Test target?}
C -->|Local| D[Open examples/nsigner.html via http://localhost or file://]
C -->|Server| E[deploy.sh rsync to laantungir.net]
E --> F[https://laantungir.net/... must be HTTPS for WebUSB]
D --> G[Plug in Feather S3, run pair flow]
F --> G
G --> H[increment_build_push.sh on merge]
Concretely:
-
Phase 1 / 2 dev iteration on
localhost. Run a tiny local static server in the workspace root:python3 -m http.server 8080 # then open http://localhost:8080/examples/nsigner.htmllocalhostqualifies as a secure context, sonavigator.usbworks. No HTTPS / certs needed during development. -
Add an example page. New file
examples/nsigner.html, modeled onexamples/sign.htmlbut withmethods: { nsigner: true }and prominent secure-context detection. This becomes both the local-dev harness and the published test page athttps://laantungir.net/nostr-login-lite/nsigner.html. -
Update
deploy.shto also push the new example page:rsync -avz --chmod=644 --progress \ build/{nostr-lite.js,nostr.bundle.js} \ examples/nsigner.html \ ubuntu@laantungir.net:html/nostr-login-lite/ -
Server-side validation loop. After local sign-off:
./deploy.sh # then open https://laantungir.net/nostr-login-lite/nsigner.html in Chrome # plug in Feather S3, run the pair flow end-to-end against the deployed bundleThis catches any same-origin / CDN / cache issue that wouldn't surface on
localhost.
Bottom line: add a thin NSignerWebUSB driver, a new nsigner method tile (titled "USB Hardware signer"), and six case 'nsigner' branches across the WindowNostr facade and AuthManager. Develop against http://localhost (a WebUSB-eligible secure context), deploy through the existing deploy.sh once the server origin is confirmed HTTPS. Every existing host page that consumes window.nostr becomes USB-hardware-sign-capable with no per-page edits. Other n_signer transports (NIP-46 bunker, browser extension) land for free through the lib's existing connect and extension tiles when upstream ships them.