Files
nostr_login_lite/plans/n_signer-integration.md
T
2026-05-10 10:30:35 -04:00

21 KiB
Raw Blame History

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.js and dispatch in _handleOptionClick() at src/ui/modal.js.
  • The WindowNostr facade at src/build.js already switches on this.authState.method for 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

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 as 4-byte BE length || JSON, transferOut(EP_OUT=1, …), then transferIn(EP_IN=1, 512) until a full frame is reassembled. Same ring-buffer logic as feather_webusb_demo.html.
  • _buildAuth(method, params) — kind 27235 with required tags nsigner_rpc, nsigner_method, nsigner_body_hash, signed by callerSecretKey using window.NostrTools.schnorr (already in the bundle via nostr-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, default 0). Persisted with the auth state.
  • Status line that reads back the resolved pubkey once getPublicKey returns, 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():

  1. navigator.usb.getDevices() — already-permitted devices return without a prompt (per WebUSB spec).
  2. Match vid==0x303a && pid==0x4001. If multiple, match serialNumber if stored.
  3. If found, open(), then getPublicKey() and verify it matches stored pubkey. Mismatch → treat as logout (the user re-paired the device with a different mnemonic).
  4. 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

  1. Caller-key generation. The demo at feather_webusb_demo.html hard-codes [1..32] as the caller key. We must generate a fresh random caller key per browser profile (matches §1.2 of plans/caller_token_identity.md). Confirm.
  2. Methods config gating. Add a methods.nsigner: false opt-out in init(), mirroring existing methods.{extension,local,seedphrase,connect,readonly,otp} flags. Update README.md accordingly.
  3. Index switching at runtime. Do we want a UI to switch nostr_index after login (effectively switching identities without re-pairing)? Easy to add via NOSTR_LOGIN_LITE.setNSignerIndex(n). Defer to v1.1?
  4. Optional encryption of callerSecretKey at rest. It's not a Nostr identity, but encrypting it with the same scheme as local keys 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 (~58 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_hash matches SHA-256(JCS(params)).
  • Auth envelope verifies with nostr-tools verifyEvent() round-trip.
  • Mutex serializes overlapping signEvent calls.
  • onDisconnect rejects 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; nlReconnectionRequired should 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 to local; back to nsigner. No leaked state in localStorage.

8.3 Negative

  • Wrong index → device returns error; UI surfaces it.
  • Device locked (firmware-side session locked) → unauthorized error 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.js plus 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 facade case arms. Manual smoke test from examples/sign.html.
  • Phase 3 — Persistence + auto-restore. AuthManager case 'nsigner' (persist + restore), _attemptNSignerRestore() on init.
  • Phase 4 — Disconnect/reconnect. nlReconnectionRequired parity with NIP-46. Friendly Linux udev snippet on first SecurityError.
  • Phase 5 — Docs. Update README.md methods.nsigner, add examples/nsigner.html, brief section in login_logic.md and a one-line note that n_signer users running NIP-46 bunker mode or the future browser extension use the existing connect / extension tiles 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 WindowNostr facade's public surface — only new case arms, no removed/renamed methods.
  • Existing methods (extension, local, seedphrase, connect, readonly, otp) — completely untouched.
  • The build pipeline — only src/build.js and the new src/signers/nsigner-webusb.js feed 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://localhost or http://127.0.0.1 — browsers treat these as secure for WebUSB.
  • file:///path/to/page.html — Chrome treats file:// as secure for WebUSB; viable for local examples / harness pages.
  • https://laantungir.net/... — confirmed HTTPS, so the deployed copy at https://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.

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:

  1. 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.html
    

    localhost qualifies as a secure context, so navigator.usb works. No HTTPS / certs needed during development.

  2. Add an example page. New file examples/nsigner.html, modeled on examples/sign.html but with methods: { nsigner: true } and prominent secure-context detection. This becomes both the local-dev harness and the published test page at https://laantungir.net/nostr-login-lite/nsigner.html.

  3. Update deploy.sh to 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/
    
  4. 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 bundle
    

    This 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.