From 973025602c03aa7f781f2fcab62a86fa5a9be66e Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sun, 10 May 2026 10:30:35 -0400 Subject: [PATCH] USB hardware signer --- README.md | 3 +- build/nostr-lite.js | 682 +++++++++++++++++++++++++++++++++- deploy.sh | 5 +- examples/nsigner.html | 106 ++++++ plans/n_signer-integration.md | 351 +++++++++++++++++ src/VERSION | 2 +- src/build.js | 164 +++++++- src/signers/nsigner-webusb.js | 308 +++++++++++++++ src/ui/modal.js | 231 +++++++++++- 9 files changed, 1831 insertions(+), 21 deletions(-) create mode 100644 examples/nsigner.html create mode 100644 plans/n_signer-integration.md create mode 100644 src/signers/nsigner-webusb.js diff --git a/README.md b/README.md index 579b52c..03f73b7 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ await window.NOSTR_LOGIN_LITE.init({ extension: true, // Browser extensions (Alby, nos2x, etc.) local: true, // Manual key entry & generation readonly: true, // Read-only mode (no signing) - seedphrase: true, + seedphrase: true, connect: true, // NIP-46 remote signers + nsigner: true, // USB Hardware signer (n_signer via WebUSB) otp: false // OTP/DM authentication (not implemented yet) }, diff --git a/build/nostr-lite.js b/build/nostr-lite.js index 07c5e07..709b9cb 100644 --- a/build/nostr-lite.js +++ b/build/nostr-lite.js @@ -8,7 +8,7 @@ * Two-file architecture: * 1. Load nostr.bundle.js (official nostr-tools bundle) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) - * Generated on: 2026-04-04T13:40:23.265Z + * Generated on: 2026-05-10T14:30:35.759Z */ // Verify dependencies are loaded @@ -436,7 +436,7 @@ class Modal { modalContent.appendChild(modalHeader); // Add version element in bottom-right corner aligned with modal body const versionElement = document.createElement('div'); - versionElement.textContent = 'v0.1.13'; + versionElement.textContent = 'v0.1.14'; versionElement.style.cssText = ` position: absolute; bottom: 8px; @@ -555,6 +555,31 @@ class Modal { }); } + // n_signer USB hardware option + if (this.options?.methods?.nsigner !== false) { + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + + let description = 'Sign with USB-connected n_signer hardware'; + let disabled = false; + + if (!secure) { + description = 'Requires HTTPS or localhost'; + disabled = true; + } else if (!hasUsb) { + description = 'Requires Chrome/Edge WebUSB support'; + disabled = true; + } + + options.push({ + type: 'nsigner', + title: 'USB Hardware signer', + description, + icon: '๐Ÿ”', + disabled + }); + } + // Read-only option if (this.options?.methods?.readonly !== false) { options.push({ @@ -578,7 +603,10 @@ class Modal { // Render each option options.forEach(option => { const button = document.createElement('button'); - button.onclick = () => this._handleOptionClick(option.type); + button.disabled = !!option.disabled; + button.onclick = () => { + if (!option.disabled) this._handleOptionClick(option.type); + }; button.style.cssText = ` display: flex; align-items: center; @@ -586,19 +614,21 @@ class Modal { padding: 16px; margin-bottom: 12px; background: var(--nl-secondary-color); - color: var(--nl-primary-color); - border: var(--nl-border-width) solid var(--nl-primary-color); + color: ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'}; + border: var(--nl-border-width) solid ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'}; border-radius: var(--nl-border-radius); - cursor: pointer; + cursor: ${option.disabled ? 'not-allowed' : 'pointer'}; transition: all 0.2s; font-family: var(--nl-font-family, 'Courier New', monospace); + opacity: ${option.disabled ? '0.7' : '1'}; `; button.onmouseover = () => { + if (option.disabled) return; button.style.borderColor = 'var(--nl-accent-color)'; button.style.background = 'var(--nl-secondary-color)'; }; button.onmouseout = () => { - button.style.borderColor = 'var(--nl-primary-color)'; + button.style.borderColor = option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'; button.style.background = 'var(--nl-secondary-color)'; }; @@ -661,6 +691,9 @@ class Modal { case 'connect': this._showConnectScreen(); break; + case 'nsigner': + this._showNSignerScreen(); + break; case 'readonly': this._handleReadonly(); break; @@ -1571,6 +1604,194 @@ class Modal { this.modalBody.appendChild(backButton); } + _getOrCreateNSignerCallerIdentity() { + const storageKey = 'nostr_login_lite_nsigner_caller'; + const existing = localStorage.getItem(storageKey); + + if (existing) { + try { + const parsed = JSON.parse(existing); + if (parsed?.secret && parsed?.pubkey) { + return parsed; + } + } catch (_) {} + } + + if (!window.NSignerWebUSB) { + throw new Error('NSignerWebUSB driver not loaded'); + } + + const secret = window.NSignerWebUSB.randomSecretHex(); + const pubkey = window.NSignerWebUSB.toPubkeyHex(secret); + const generated = { secret, pubkey }; + localStorage.setItem(storageKey, JSON.stringify(generated)); + return generated; + } + + _showNSignerScreen() { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'USB Hardware signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Connect your n_signer Feather S3 device over USB, then confirm the public key shown on the device.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + + if (!secure || !hasUsb) { + status.textContent = !secure + ? 'Requires HTTPS or localhost for WebUSB.' + : 'WebUSB unavailable. Use Chrome or Edge.'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const indexLabel = document.createElement('label'); + indexLabel.textContent = 'Nostr key index'; + indexLabel.style.cssText = 'display:block; margin-bottom: 6px; font-weight: 500;'; + + const indexInput = document.createElement('input'); + indexInput.type = 'number'; + indexInput.min = '0'; + indexInput.step = '1'; + indexInput.value = '0'; + indexInput.style.cssText = 'width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; margin-bottom: 12px; box-sizing: border-box;'; + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Connect Device'; + connectButton.style.cssText = this._getButtonStyle(); + + const useButton = document.createElement('button'); + useButton.textContent = 'Use this device'; + useButton.disabled = true; + useButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px; opacity: 0.6; cursor: not-allowed;'; + + let connected = null; + + connectButton.onclick = async () => { + try { + const nostrIndex = Math.max(0, Number.parseInt(indexInput.value || '0', 10) || 0); + status.textContent = 'Connecting... If prompted, approve on your hardware device.'; + + const caller = this._getOrCreateNSignerCallerIdentity(); + + if (!window.NSignerWebUSB || typeof window.NSignerWebUSB.requestAndConnect !== 'function') { + throw new Error('NSignerWebUSB driver missing requestAndConnect (stale bundle likely loaded)'); + } + + let driver = await window.NSignerWebUSB.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex + }); + + // Defensive fallback for stale/cached bundles that may return a raw USBDevice + // instead of an initialized NSignerWebUSB instance. + if (driver && typeof driver.getPublicKey !== 'function' && typeof window.NSignerWebUSB === 'function') { + const maybeUsbDevice = driver; + if (typeof maybeUsbDevice.open === 'function') { + driver = new window.NSignerWebUSB(maybeUsbDevice, { + callerSecretKey: caller.secret, + nostrIndex + }); + await driver.open(); + } + } + + if (!driver || typeof driver.getPublicKey !== 'function') { + throw new Error('Driver initialization failed. Hard refresh the page to clear cached JS and retry.'); + } + + const pubkey = await driver.getPublicKey(); + connected = { + pubkey, + nostrIndex, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: driver.vendorId, + deviceProductId: driver.productId, + deviceSerial: driver.serial + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey, + connectionData: { + nostrIndex, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: driver.vendorId, + deviceProductId: driver.productId, + deviceSerial: driver.serial + }, + message: 'Your n_signer device was disconnected. Reconnect to continue.' + } + })); + }); + + status.textContent = `Connected. Device pubkey: ${pubkey}`; + useButton.disabled = false; + useButton.style.opacity = '1'; + useButton.style.cursor = 'pointer'; + } catch (error) { + const msg = String(error?.message || error); + if (msg.includes('SecurityError')) { + status.textContent = 'Access denied. On Linux, install the udev rule for VID:PID 303a:4001 and replug.'; + } else { + status.textContent = `Connect failed: ${msg}`; + } + } + }; + + useButton.onclick = () => { + if (!connected) return; + this._setAuthMethod('nsigner', { + pubkey: connected.pubkey, + signer: { + driver: connected.driver, + nostrIndex: connected.nostrIndex, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + } + }); + }; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(indexLabel); + this.modalBody.appendChild(indexInput); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(useButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + } + _showConnectScreen() { this.modalBody.innerHTML = ''; @@ -2280,6 +2501,320 @@ window.addEventListener('load', () => { modalInstance = new Modal(); }); +// ====================================== +// NSigner WebUSB Driver +// ====================================== + +class NSignerWebUSB { + // Keep picker broad (vendor-level) so Chromium can show all matching Feather states. + // Product-level checks still happen during open()/RPC. + static FILTERS = [{ vendorId: 0x303a }]; + + static async requestAndConnect(options = {}) { + if (!('usb' in navigator)) { + throw new Error('WebUSB not available in this browser'); + } + + const device = await navigator.usb.requestDevice({ filters: NSignerWebUSB.FILTERS }); + const driver = new NSignerWebUSB(device, options); + await driver.open(); + return driver; + } + + static async getPairedDevice(options = {}) { + if (!('usb' in navigator)) return null; + + const devices = await navigator.usb.getDevices(); + if (!devices || devices.length === 0) return null; + + const vendorId = options.vendorId ?? 0x303a; + const productId = options.productId ?? null; + const serial = options.serialNumber || null; + + return devices.find((d) => { + const vendorMatch = d.vendorId === vendorId; + if (!vendorMatch) return false; + + if (productId !== null && productId !== undefined && d.productId !== productId) { + return false; + } + + if (serial && d.serialNumber) return d.serialNumber === serial; + return true; + }) || null; + } + + static randomSecretHex() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return NSignerWebUSB._hex(bytes); + } + + static toPubkeyHex(secretHex) { + const secret = NSignerWebUSB._hexToBytes(secretHex); + const pub = window.NostrTools.schnorr.getPublicKey(secret); + return typeof pub === 'string' ? pub : NSignerWebUSB._hex(pub); + } + + constructor(usbDevice, { callerSecretKey, nostrIndex = 0 } = {}) { + if (!usbDevice) throw new Error('USB device is required'); + if (!callerSecretKey) throw new Error('callerSecretKey is required'); + + this.device = usbDevice; + this.callerSecretKey = callerSecretKey; + this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0; + + this.iface = null; + this.epIn = 1; + this.epOut = 1; + this._busy = false; + this._disconnectHandlers = new Set(); + this._rpcCounter = 0; + this._boundDisconnect = (event) => { + if (event.device === this.device) { + for (const cb of this._disconnectHandlers) { + try { cb(event); } catch (_) {} + } + } + }; + + if (navigator.usb?.addEventListener) { + navigator.usb.addEventListener('disconnect', this._boundDisconnect); + } + } + + get isOpen() { + return !!this.device?.opened; + } + + get vendorId() { + return this.device?.vendorId; + } + + get productId() { + return this.device?.productId; + } + + get serial() { + return this.device?.serialNumber || null; + } + + onDisconnect(cb) { + if (typeof cb === 'function') this._disconnectHandlers.add(cb); + return () => this._disconnectHandlers.delete(cb); + } + + async open() { + if (!this.device.opened) await this.device.open(); + if (this.device.configuration === null) await this.device.selectConfiguration(1); + + const intf = this.device.configuration.interfaces.find(i => + i.alternates.some(a => a.interfaceClass === 0xff) + ); + + if (!intf) throw new Error('No vendor WebUSB interface found'); + + this.iface = intf.interfaceNumber; + await this.device.claimInterface(this.iface); + + const alt = intf.alternates.find(a => a.interfaceClass === 0xff); + if (alt) await this.device.selectAlternateInterface(this.iface, alt.alternateSetting); + + await this.device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 1, + index: this.iface + }); + } + + async close() { + try { + if (this.device?.opened && this.iface !== null) { + await this.device.releaseInterface(this.iface); + } + } catch (_) {} + + try { + if (this.device?.opened) await this.device.close(); + } catch (_) {} + + this.iface = null; + + if (navigator.usb?.removeEventListener && this._boundDisconnect) { + navigator.usb.removeEventListener('disconnect', this._boundDisconnect); + } + } + + async getPublicKey() { + const params = [{ nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('get_public_key', params); + if (!resp || typeof resp.result !== 'string') { + throw new Error('Invalid get_public_key response'); + } + return resp.result.trim().toLowerCase(); + } + + async signEvent(unsignedEvent) { + const params = [unsignedEvent, { nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('sign_event', params); + if (!resp || typeof resp.result !== 'object') { + throw new Error('Invalid sign_event response'); + } + return resp.result; + } + + async nip04Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response'); + return resp.result; + } + + async nip04Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response'); + return resp.result; + } + + async nip44Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response'); + return resp.result; + } + + async nip44Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_decrypt response'); + return resp.result; + } + + async _rpcCall(method, params) { + if (this._busy) { + throw new Error('n_signer device is busy; wait for previous request'); + } + + this._busy = true; + try { + const id = `nl-${Date.now()}-${++this._rpcCounter}`; + const auth = await this._buildAuth(id, method, params); + const req = { jsonrpc: '2.0', id, method, params, auth }; + const resp = await this._sendRpc(req); + + if (resp?.error) { + const msg = resp.error?.message || JSON.stringify(resp.error); + throw new Error(`n_signer ${method} failed: ${msg}`); + } + + return resp; + } finally { + this._busy = false; + } + } + + async _buildAuth(rpcId, method, params) { + const callerPriv = NSignerWebUSB._hexToBytes(this.callerSecretKey); + const callerPubX = NSignerWebUSB.toPubkeyHex(this.callerSecretKey); + + const createdAt = Math.floor(Date.now() / 1000); + const paramsJson = JSON.stringify(params ?? null); + const bodyHash = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(paramsJson)); + + const tags = [ + ['nsigner_rpc', String(rpcId)], + ['nsigner_method', String(method)], + ['nsigner_body_hash', bodyHash] + ]; + + const content = 'nostr_login_lite'; + const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]); + const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(ser)); + + const sigBytes = await window.NostrTools.schnorr.sign(id, callerPriv, new Uint8Array(32)); + const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebUSB._hex(sigBytes); + + return { + id, + pubkey: callerPubX, + created_at: createdAt, + kind: 27235, + tags, + content, + sig: sigHex + }; + } + + async _sendRpc(reqObj) { + const body = NSignerWebUSB._utf8(JSON.stringify(reqObj)); + const frame = new Uint8Array(4 + body.length); + frame.set(NSignerWebUSB._be32(body.length), 0); + frame.set(body, 4); + await this.device.transferOut(this.epOut, frame); + + const deadline = Date.now() + 30000; + let ring = new Uint8Array(0); + + while (Date.now() < deadline) { + const r = await this.device.transferIn(this.epIn, 512); + if (!r.data || r.data.byteLength === 0) continue; + + const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength); + const next = new Uint8Array(ring.length + chunk.length); + next.set(ring, 0); + next.set(chunk, ring.length); + ring = next; + + while (ring.length >= 4) { + const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3]; + if (n <= 0 || n > 1_000_000) { + ring = ring.slice(1); + continue; + } + if (ring.length < 4 + n) break; + + const payload = ring.slice(4, 4 + n); + ring = ring.slice(4 + n); + const txt = new TextDecoder().decode(payload); + return JSON.parse(txt); + } + } + + throw new Error('Timed out waiting for n_signer response'); + } + + static _utf8(s) { + return new TextEncoder().encode(s); + } + + static _be32(n) { + return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + } + + static _hex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + static _hexToBytes(hex) { + const v = String(hex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(v)) { + throw new Error('Secret key must be 64 hex chars'); + } + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16); + return out; + } + + static async _sha256Hex(dataBytes) { + const h = await crypto.subtle.digest('SHA-256', dataBytes); + return NSignerWebUSB._hex(new Uint8Array(h)); + } +} + +if (typeof window !== 'undefined') { + window.NSignerWebUSB = NSignerWebUSB; +} + + // ====================================== // FloatingTab Component (Recovered from git history) @@ -2978,6 +3513,7 @@ class NostrLite { seedphrase: false, readonly: true, connect: false, + nsigner: true, otp: false }, floatingTab: { @@ -3201,7 +3737,7 @@ class NostrLite { // CRITICAL FIX: Activate facade resilience system for non-extension methods // Extensions like nos2x can override our facade after page refresh - if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46') { + if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46' || restoredAuth.method === 'nsigner') { this._activateResilienceProtection(restoredAuth.method); } @@ -3342,7 +3878,9 @@ class NostrLite { method: authData.method, pubkey: authData.pubkey, connectionData: authData.connectionData, - message: 'Your NIP-46 session has expired. Please reconnect to continue.' + message: authData.message || (authData.method === 'nsigner' + ? 'Your n_signer device is not connected. Plug it in and reconnect to continue.' + : 'Your NIP-46 session has expired. Please reconnect to continue.') } })); } @@ -3518,6 +4056,19 @@ class AuthManager { } break; + case 'nsigner': + if (authData.signer) { + authState.nsigner = { + nostrIndex: Number(authData.signer.nostrIndex ?? 0), + callerSecretKey: authData.signer.callerSecretKey, + callerPubkey: authData.signer.callerPubkey, + deviceVid: authData.signer.deviceVid, + deviceProductId: authData.signer.deviceProductId, + deviceSerial: authData.signer.deviceSerial || null + }; + } + break; + case 'readonly': // Read-only mode has no secrets to store // console.log('๐Ÿ” AuthManager: Read-only method - storing basic auth state'); @@ -3581,6 +4132,11 @@ class AuthManager { result = await this._restoreNip46Auth(authState); break; + case 'nsigner': + + result = await this._restoreNSignerAuth(authState); + break; + case 'readonly': result = await this._restoreReadonlyAuth(authState); @@ -3885,6 +4441,74 @@ class AuthManager { }; } + async _restoreNSignerAuth(authState) { + const nsigner = authState.nsigner; + if (!nsigner) return null; + + if (!window.isSecureContext || !('usb' in navigator) || !window.NSignerWebUSB) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'n_signer requires HTTPS/localhost and a WebUSB-capable browser (Chrome/Edge).' + }; + } + + const paired = await window.NSignerWebUSB.getPairedDevice({ + vendorId: nsigner.deviceVid ?? 0x303a, + productId: nsigner.deviceProductId ?? 0x4001, + serialNumber: nsigner.deviceSerial || undefined + }); + + if (!paired) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Your n_signer device is not connected. Plug it in and reconnect to continue.' + }; + } + + try { + const driver = new window.NSignerWebUSB(paired, { + callerSecretKey: nsigner.callerSecretKey, + nostrIndex: Number(nsigner.nostrIndex ?? 0) + }); + + await driver.open(); + const restoredPubkey = await driver.getPublicKey(); + if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) { + await driver.close(); + this.clearAuthState(); + return null; + } + + return { + method: 'nsigner', + pubkey: authState.pubkey, + signer: { + driver, + nostrIndex: Number(nsigner.nostrIndex ?? 0), + callerSecretKey: nsigner.callerSecretKey, + callerPubkey: nsigner.callerPubkey, + deviceVid: nsigner.deviceVid, + deviceProductId: nsigner.deviceProductId, + deviceSerial: nsigner.deviceSerial || null + } + }; + } catch (_) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Could not reopen your n_signer device. Reconnect from the modal.' + }; + } + } + async _restoreReadonlyAuth(authState) { return { @@ -4081,8 +4705,13 @@ class WindowNostr { } }); - window.addEventListener('nlLogout', () => { + window.addEventListener('nlLogout', async () => { console.log('๐Ÿ” WindowNostr: nlLogout event received'); + + if (this.authState?.method === 'nsigner' && this.authState?.signer?.driver?.close) { + try { await this.authState.signer.driver.close(); } catch (_) {} + } + this.authState = null; this.authenticatedExtension = null; @@ -4109,6 +4738,9 @@ class WindowNostr { case 'nip46': return this.authState.pubkey; + case 'nsigner': + return this.authState.pubkey; + case 'readonly': throw new Error('Read-only mode - cannot get public key'); @@ -4156,6 +4788,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.signEvent(event); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.signEvent(event); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -4205,6 +4843,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip04Encrypt(pubkey, plaintext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -4247,6 +4891,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip04Decrypt(pubkey, ciphertext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -4293,6 +4943,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip44Encrypt(pubkey, plaintext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -4335,6 +4991,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip44Decrypt(pubkey, ciphertext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); diff --git a/deploy.sh b/deploy.sh index 8e14db6..4e5ab71 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,3 +1,6 @@ #!/bin/bash -rsync -avz --chmod=644 --progress build/{nostr-lite.js,nostr.bundle.js} ubuntu@laantungir.net:html/nostr-login-lite/ +rsync -avz --chmod=644 --progress \ + build/{nostr-lite.js,nostr.bundle.js} \ + examples/nsigner.html \ + ubuntu@laantungir.net:html/nostr-login-lite/ diff --git a/examples/nsigner.html b/examples/nsigner.html new file mode 100644 index 0000000..30aa0cc --- /dev/null +++ b/examples/nsigner.html @@ -0,0 +1,106 @@ + + + + + + n_signer WebUSB Test + + +

n_signer WebUSB Harness

+
+
+ + + + + + + + + diff --git a/plans/n_signer-integration.md b/plans/n_signer-integration.md new file mode 100644 index 0000000..83b2dd6 --- /dev/null +++ b/plans/n_signer-integration.md @@ -0,0 +1,351 @@ +# n_signer WebUSB Integration + +Add WebUSB support for the [`n_signer`](../../n_signer/README.md:1) hardware signer (Feather S3 firmware) to [`nostr_login_lite`](../src/build.js:1) 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`](../../n_signer/plans/nip46_bunker_mode.md:1)) | โœ… When upstream ships | Free โ€” uses existing `connect` tile. No code. | +| **Browser extension** (deferred upstream โ€” [`plans/nsigner_browser_extension.md`](../../n_signer/plans/nsigner_browser_extension.md:1)) | โœ… 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`](../../n_signer/firmware/README.md:7)). +- Wire format: **4-byte big-endian length prefix + JSON body**, JSON-RPC 2.0 shape ([`README.md`](../../n_signer/README.md:172)). +- Verbs we need: `get_public_key`, `sign_event`, `nip04_encrypt` / `nip04_decrypt`, `nip44_encrypt` / `nip44_decrypt` ([`README.md`](../../n_signer/README.md:183)). +- 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`](../../n_signer/plans/caller_token_identity.md:55)). Reference builder: [`feather_webusb_demo.html`](../../n_signer/examples/feather_webusb_demo.html:279). + +### 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`](../src/ui/modal.js:201) and dispatch in `_handleOptionClick()` at [`src/ui/modal.js`](../src/ui/modal.js:335). +- The `WindowNostr` facade at [`src/build.js`](../src/build.js:1937) already switches on `this.authState.method` for all six NIP-07 methods. Switch locations: + +| NIP-07 method | Switch line | +|---|---| +| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) | +| `signEvent()` | [`src/build.js`](../src/build.js:2050) | +| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) | +| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) | +| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) | +| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) | + +- `AuthManager` persistence is keyed on `authData.method`: persist switch at [`src/build.js`](../src/build.js:1410), restore switch at [`src/build.js`](../src/build.js:1488). + +We add tile + matching `case` arms; no surface change. + +## 3. Architecture + +```mermaid +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`](../../n_signer/examples/feather_webusb_demo.html:197) into an ES module / class. + +### 4.1 Public surface + +```js +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`](../../n_signer/examples/feather_webusb_demo.html:310). +- `_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`](../../n_signer/examples/feather_webusb_demo.html:279). +- 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`](../src/ui/modal.js:201) `_renderLoginOptions()`, gated by `this.options?.methods?.nsigner !== false`: + +```js +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`](../src/ui/modal.js:339). + +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`](../../n_signer/plans/feather_signer_ui.md:1)). +- **"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`](../../n_signer/plans/caller_token_identity.md:13)). 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`](../src/build.js:1937) add a `case 'nsigner'` arm to each switch: + +| Method | Switch location | Action | +|---|---|---| +| `getPublicKey()` | [`src/build.js`](../src/build.js:2022) | Return cached `authState.pubkey`. Verified at login; no round-trip per call. | +| `signEvent(event)` | [`src/build.js`](../src/build.js:2050) | `await authState.signer.driver.signEvent(event)`. | +| `nip04.encrypt` | [`src/build.js`](../src/build.js:2102) | `driver.nip04Encrypt(peer, text)`. | +| `nip04.decrypt` | [`src/build.js`](../src/build.js:2144) | `driver.nip04Decrypt(peer, ct)`. | +| `nip44.encrypt` | [`src/build.js`](../src/build.js:2190) | `driver.nip44Encrypt(peer, text)`. | +| `nip44.decrypt` | [`src/build.js`](../src/build.js:2232) | `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: + +```jsonc +{ + "method": "nsigner", + "pubkey": "", + "nostrIndex": 0, + "callerSecretKey": "", + "callerPubkey": "", + "deviceVid": 12346, + "deviceProductId": 16385, + "deviceSerial": "", + "timestamp": 1730000000000 +} +``` + +Storage location follows existing `isolateSession` rules in [`src/build.js`](../src/build.js:1408). `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`](../src/build.js:1410) (next to `'extension'`, `'local'`, `'nip46'`) and the matching restore branch at [`src/build.js`](../src/build.js:1488). + +On page reload, [`src/build.js`](../src/build.js:1115) 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`](../../n_signer/examples/feather_webusb_demo.html:281) 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`](../../n_signer/plans/caller_token_identity.md:13)). 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`](../README.md:21) 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`](../../n_signer/firmware/README.md:56) 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_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`](../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 + +```mermaid +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`](../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`](../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`](../README.md:21) `methods.nsigner`, add [`examples/nsigner.html`](../examples/nsigner.html), brief section in [`login_logic.md`](../login_logic.md:1) 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`](../src/build.js:1) and the new [`src/signers/nsigner-webusb.js`](../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](https://wicg.github.io/webusb/)). 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`](../deploy.sh:1) already pushes built artifacts to the server: + +```bash +rsync -avz --chmod=644 --progress build/{nostr-lite.js,nostr.bundle.js} \ + ubuntu@laantungir.net:html/nostr-login-lite/ +``` + +[`increment_build_push.sh`](../increment_build_push.sh:1) 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 + +```mermaid +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: + ```bash + 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`](../examples/nsigner.html:1), modeled on [`examples/sign.html`](../examples/sign.html:1) 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`](../deploy.sh:1) to also push the new example page**: + ```bash + 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: + ```bash + ./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`](../deploy.sh:1) 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. diff --git a/src/VERSION b/src/VERSION index 7ac4e5e..71d6a66 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -0.1.13 +0.1.14 diff --git a/src/build.js b/src/build.js index 4f3cbd5..3519823 100644 --- a/src/build.js +++ b/src/build.js @@ -199,6 +199,37 @@ if (typeof window !== 'undefined') { console.warn('โš ๏ธ Modal UI not found: ui/modal.js'); } + // Add NSigner WebUSB driver + const nsignerDriverPath = path.join(__dirname, 'signers/nsigner-webusb.js'); + if (fs.existsSync(nsignerDriverPath)) { + let nsignerContent = fs.readFileSync(nsignerDriverPath, 'utf8'); + + let lines = nsignerContent.split('\n'); + let contentStartIndex = 0; + + for (let i = 0; i < Math.min(15, lines.length); i++) { + const line = lines[i].trim(); + if (line.startsWith('/**') || line.startsWith('*') || + line.startsWith('/*') || line.startsWith('//')) { + contentStartIndex = i + 1; + } else if (line && !line.startsWith('*') && !line.startsWith('//')) { + break; + } + } + + if (contentStartIndex > 0) { + lines = lines.slice(contentStartIndex); + } + + bundle += `// ======================================\n`; + bundle += `// NSigner WebUSB Driver\n`; + bundle += `// ======================================\n\n`; + bundle += lines.join('\n'); + bundle += '\n\n'; + } else { + console.warn('โš ๏ธ NSigner driver not found: signers/nsigner-webusb.js'); + } + // Add main library code // console.log('๐Ÿ“„ Adding Main Library...'); bundle += ` @@ -899,6 +930,7 @@ class NostrLite { seedphrase: false, readonly: true, connect: false, + nsigner: true, otp: false }, floatingTab: { @@ -1122,7 +1154,7 @@ class NostrLite { // CRITICAL FIX: Activate facade resilience system for non-extension methods // Extensions like nos2x can override our facade after page refresh - if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46') { + if (restoredAuth.method === 'local' || restoredAuth.method === 'nip46' || restoredAuth.method === 'nsigner') { this._activateResilienceProtection(restoredAuth.method); } @@ -1263,7 +1295,9 @@ class NostrLite { method: authData.method, pubkey: authData.pubkey, connectionData: authData.connectionData, - message: 'Your NIP-46 session has expired. Please reconnect to continue.' + message: authData.message || (authData.method === 'nsigner' + ? 'Your n_signer device is not connected. Plug it in and reconnect to continue.' + : 'Your NIP-46 session has expired. Please reconnect to continue.') } })); } @@ -1439,6 +1473,19 @@ class AuthManager { } break; + case 'nsigner': + if (authData.signer) { + authState.nsigner = { + nostrIndex: Number(authData.signer.nostrIndex ?? 0), + callerSecretKey: authData.signer.callerSecretKey, + callerPubkey: authData.signer.callerPubkey, + deviceVid: authData.signer.deviceVid, + deviceProductId: authData.signer.deviceProductId, + deviceSerial: authData.signer.deviceSerial || null + }; + } + break; + case 'readonly': // Read-only mode has no secrets to store // console.log('๐Ÿ” AuthManager: Read-only method - storing basic auth state'); @@ -1502,6 +1549,11 @@ class AuthManager { result = await this._restoreNip46Auth(authState); break; + case 'nsigner': + + result = await this._restoreNSignerAuth(authState); + break; + case 'readonly': result = await this._restoreReadonlyAuth(authState); @@ -1806,6 +1858,74 @@ class AuthManager { }; } + async _restoreNSignerAuth(authState) { + const nsigner = authState.nsigner; + if (!nsigner) return null; + + if (!window.isSecureContext || !('usb' in navigator) || !window.NSignerWebUSB) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'n_signer requires HTTPS/localhost and a WebUSB-capable browser (Chrome/Edge).' + }; + } + + const paired = await window.NSignerWebUSB.getPairedDevice({ + vendorId: nsigner.deviceVid ?? 0x303a, + productId: nsigner.deviceProductId ?? 0x4001, + serialNumber: nsigner.deviceSerial || undefined + }); + + if (!paired) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Your n_signer device is not connected. Plug it in and reconnect to continue.' + }; + } + + try { + const driver = new window.NSignerWebUSB(paired, { + callerSecretKey: nsigner.callerSecretKey, + nostrIndex: Number(nsigner.nostrIndex ?? 0) + }); + + await driver.open(); + const restoredPubkey = await driver.getPublicKey(); + if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) { + await driver.close(); + this.clearAuthState(); + return null; + } + + return { + method: 'nsigner', + pubkey: authState.pubkey, + signer: { + driver, + nostrIndex: Number(nsigner.nostrIndex ?? 0), + callerSecretKey: nsigner.callerSecretKey, + callerPubkey: nsigner.callerPubkey, + deviceVid: nsigner.deviceVid, + deviceProductId: nsigner.deviceProductId, + deviceSerial: nsigner.deviceSerial || null + } + }; + } catch (_) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Could not reopen your n_signer device. Reconnect from the modal.' + }; + } + } + async _restoreReadonlyAuth(authState) { return { @@ -2002,8 +2122,13 @@ class WindowNostr { } }); - window.addEventListener('nlLogout', () => { + window.addEventListener('nlLogout', async () => { console.log('๐Ÿ” WindowNostr: nlLogout event received'); + + if (this.authState?.method === 'nsigner' && this.authState?.signer?.driver?.close) { + try { await this.authState.signer.driver.close(); } catch (_) {} + } + this.authState = null; this.authenticatedExtension = null; @@ -2030,6 +2155,9 @@ class WindowNostr { case 'nip46': return this.authState.pubkey; + case 'nsigner': + return this.authState.pubkey; + case 'readonly': throw new Error('Read-only mode - cannot get public key'); @@ -2077,6 +2205,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.signEvent(event); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.signEvent(event); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -2126,6 +2260,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip04Encrypt(pubkey, plaintext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip04Encrypt(pubkey, plaintext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -2168,6 +2308,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip04Decrypt(pubkey, ciphertext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip04Decrypt(pubkey, ciphertext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -2214,6 +2360,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip44Encrypt(pubkey, plaintext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip44Encrypt(pubkey, plaintext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); @@ -2256,6 +2408,12 @@ class WindowNostr { } return await this.authState.signer.bunkerSigner.nip44Decrypt(pubkey, ciphertext); } + + case 'nsigner': { + const driver = this.authState.signer?.driver; + if (!driver) throw new Error('n_signer driver not available'); + return await driver.nip44Decrypt(pubkey, ciphertext); + } default: throw new Error('Unsupported auth method: ' + this.authState.method); diff --git a/src/signers/nsigner-webusb.js b/src/signers/nsigner-webusb.js new file mode 100644 index 0000000..2a122e0 --- /dev/null +++ b/src/signers/nsigner-webusb.js @@ -0,0 +1,308 @@ +class NSignerWebUSB { + // Keep picker broad (vendor-level) so Chromium can show all matching Feather states. + // Product-level checks still happen during open()/RPC. + static FILTERS = [{ vendorId: 0x303a }]; + + static async requestAndConnect(options = {}) { + if (!('usb' in navigator)) { + throw new Error('WebUSB not available in this browser'); + } + + const device = await navigator.usb.requestDevice({ filters: NSignerWebUSB.FILTERS }); + const driver = new NSignerWebUSB(device, options); + await driver.open(); + return driver; + } + + static async getPairedDevice(options = {}) { + if (!('usb' in navigator)) return null; + + const devices = await navigator.usb.getDevices(); + if (!devices || devices.length === 0) return null; + + const vendorId = options.vendorId ?? 0x303a; + const productId = options.productId ?? null; + const serial = options.serialNumber || null; + + return devices.find((d) => { + const vendorMatch = d.vendorId === vendorId; + if (!vendorMatch) return false; + + if (productId !== null && productId !== undefined && d.productId !== productId) { + return false; + } + + if (serial && d.serialNumber) return d.serialNumber === serial; + return true; + }) || null; + } + + static randomSecretHex() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return NSignerWebUSB._hex(bytes); + } + + static toPubkeyHex(secretHex) { + const secret = NSignerWebUSB._hexToBytes(secretHex); + const pub = window.NostrTools.schnorr.getPublicKey(secret); + return typeof pub === 'string' ? pub : NSignerWebUSB._hex(pub); + } + + constructor(usbDevice, { callerSecretKey, nostrIndex = 0 } = {}) { + if (!usbDevice) throw new Error('USB device is required'); + if (!callerSecretKey) throw new Error('callerSecretKey is required'); + + this.device = usbDevice; + this.callerSecretKey = callerSecretKey; + this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0; + + this.iface = null; + this.epIn = 1; + this.epOut = 1; + this._busy = false; + this._disconnectHandlers = new Set(); + this._rpcCounter = 0; + this._boundDisconnect = (event) => { + if (event.device === this.device) { + for (const cb of this._disconnectHandlers) { + try { cb(event); } catch (_) {} + } + } + }; + + if (navigator.usb?.addEventListener) { + navigator.usb.addEventListener('disconnect', this._boundDisconnect); + } + } + + get isOpen() { + return !!this.device?.opened; + } + + get vendorId() { + return this.device?.vendorId; + } + + get productId() { + return this.device?.productId; + } + + get serial() { + return this.device?.serialNumber || null; + } + + onDisconnect(cb) { + if (typeof cb === 'function') this._disconnectHandlers.add(cb); + return () => this._disconnectHandlers.delete(cb); + } + + async open() { + if (!this.device.opened) await this.device.open(); + if (this.device.configuration === null) await this.device.selectConfiguration(1); + + const intf = this.device.configuration.interfaces.find(i => + i.alternates.some(a => a.interfaceClass === 0xff) + ); + + if (!intf) throw new Error('No vendor WebUSB interface found'); + + this.iface = intf.interfaceNumber; + await this.device.claimInterface(this.iface); + + const alt = intf.alternates.find(a => a.interfaceClass === 0xff); + if (alt) await this.device.selectAlternateInterface(this.iface, alt.alternateSetting); + + await this.device.controlTransferOut({ + requestType: 'class', + recipient: 'interface', + request: 0x22, + value: 1, + index: this.iface + }); + } + + async close() { + try { + if (this.device?.opened && this.iface !== null) { + await this.device.releaseInterface(this.iface); + } + } catch (_) {} + + try { + if (this.device?.opened) await this.device.close(); + } catch (_) {} + + this.iface = null; + + if (navigator.usb?.removeEventListener && this._boundDisconnect) { + navigator.usb.removeEventListener('disconnect', this._boundDisconnect); + } + } + + async getPublicKey() { + const params = [{ nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('get_public_key', params); + if (!resp || typeof resp.result !== 'string') { + throw new Error('Invalid get_public_key response'); + } + return resp.result.trim().toLowerCase(); + } + + async signEvent(unsignedEvent) { + const params = [unsignedEvent, { nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('sign_event', params); + if (!resp || typeof resp.result !== 'object') { + throw new Error('Invalid sign_event response'); + } + return resp.result; + } + + async nip04Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response'); + return resp.result; + } + + async nip04Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response'); + return resp.result; + } + + async nip44Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response'); + return resp.result; + } + + async nip44Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_decrypt response'); + return resp.result; + } + + async _rpcCall(method, params) { + if (this._busy) { + throw new Error('n_signer device is busy; wait for previous request'); + } + + this._busy = true; + try { + const id = `nl-${Date.now()}-${++this._rpcCounter}`; + const auth = await this._buildAuth(id, method, params); + const req = { jsonrpc: '2.0', id, method, params, auth }; + const resp = await this._sendRpc(req); + + if (resp?.error) { + const msg = resp.error?.message || JSON.stringify(resp.error); + throw new Error(`n_signer ${method} failed: ${msg}`); + } + + return resp; + } finally { + this._busy = false; + } + } + + async _buildAuth(rpcId, method, params) { + const callerPriv = NSignerWebUSB._hexToBytes(this.callerSecretKey); + const callerPubX = NSignerWebUSB.toPubkeyHex(this.callerSecretKey); + + const createdAt = Math.floor(Date.now() / 1000); + const paramsJson = JSON.stringify(params ?? null); + const bodyHash = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(paramsJson)); + + const tags = [ + ['nsigner_rpc', String(rpcId)], + ['nsigner_method', String(method)], + ['nsigner_body_hash', bodyHash] + ]; + + const content = 'nostr_login_lite'; + const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]); + const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(ser)); + + const sigBytes = await window.NostrTools.schnorr.sign(id, callerPriv, new Uint8Array(32)); + const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebUSB._hex(sigBytes); + + return { + id, + pubkey: callerPubX, + created_at: createdAt, + kind: 27235, + tags, + content, + sig: sigHex + }; + } + + async _sendRpc(reqObj) { + const body = NSignerWebUSB._utf8(JSON.stringify(reqObj)); + const frame = new Uint8Array(4 + body.length); + frame.set(NSignerWebUSB._be32(body.length), 0); + frame.set(body, 4); + await this.device.transferOut(this.epOut, frame); + + const deadline = Date.now() + 30000; + let ring = new Uint8Array(0); + + while (Date.now() < deadline) { + const r = await this.device.transferIn(this.epIn, 512); + if (!r.data || r.data.byteLength === 0) continue; + + const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength); + const next = new Uint8Array(ring.length + chunk.length); + next.set(ring, 0); + next.set(chunk, ring.length); + ring = next; + + while (ring.length >= 4) { + const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3]; + if (n <= 0 || n > 1_000_000) { + ring = ring.slice(1); + continue; + } + if (ring.length < 4 + n) break; + + const payload = ring.slice(4, 4 + n); + ring = ring.slice(4 + n); + const txt = new TextDecoder().decode(payload); + return JSON.parse(txt); + } + } + + throw new Error('Timed out waiting for n_signer response'); + } + + static _utf8(s) { + return new TextEncoder().encode(s); + } + + static _be32(n) { + return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + } + + static _hex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + static _hexToBytes(hex) { + const v = String(hex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(v)) { + throw new Error('Secret key must be 64 hex chars'); + } + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16); + return out; + } + + static async _sha256Hex(dataBytes) { + const h = await crypto.subtle.digest('SHA-256', dataBytes); + return NSignerWebUSB._hex(new Uint8Array(h)); + } +} + +if (typeof window !== 'undefined') { + window.NSignerWebUSB = NSignerWebUSB; +} diff --git a/src/ui/modal.js b/src/ui/modal.js index 9e64ad8..8f0b644 100644 --- a/src/ui/modal.js +++ b/src/ui/modal.js @@ -243,6 +243,31 @@ class Modal { }); } + // n_signer USB hardware option + if (this.options?.methods?.nsigner !== false) { + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + + let description = 'Sign with USB-connected n_signer hardware'; + let disabled = false; + + if (!secure) { + description = 'Requires HTTPS or localhost'; + disabled = true; + } else if (!hasUsb) { + description = 'Requires Chrome/Edge WebUSB support'; + disabled = true; + } + + options.push({ + type: 'nsigner', + title: 'USB Hardware signer', + description, + icon: '๐Ÿ”', + disabled + }); + } + // Read-only option if (this.options?.methods?.readonly !== false) { options.push({ @@ -266,7 +291,10 @@ class Modal { // Render each option options.forEach(option => { const button = document.createElement('button'); - button.onclick = () => this._handleOptionClick(option.type); + button.disabled = !!option.disabled; + button.onclick = () => { + if (!option.disabled) this._handleOptionClick(option.type); + }; button.style.cssText = ` display: flex; align-items: center; @@ -274,19 +302,21 @@ class Modal { padding: 16px; margin-bottom: 12px; background: var(--nl-secondary-color); - color: var(--nl-primary-color); - border: var(--nl-border-width) solid var(--nl-primary-color); + color: ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'}; + border: var(--nl-border-width) solid ${option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'}; border-radius: var(--nl-border-radius); - cursor: pointer; + cursor: ${option.disabled ? 'not-allowed' : 'pointer'}; transition: all 0.2s; font-family: var(--nl-font-family, 'Courier New', monospace); + opacity: ${option.disabled ? '0.7' : '1'}; `; button.onmouseover = () => { + if (option.disabled) return; button.style.borderColor = 'var(--nl-accent-color)'; button.style.background = 'var(--nl-secondary-color)'; }; button.onmouseout = () => { - button.style.borderColor = 'var(--nl-primary-color)'; + button.style.borderColor = option.disabled ? 'var(--nl-muted-color)' : 'var(--nl-primary-color)'; button.style.background = 'var(--nl-secondary-color)'; }; @@ -349,6 +379,9 @@ class Modal { case 'connect': this._showConnectScreen(); break; + case 'nsigner': + this._showNSignerScreen(); + break; case 'readonly': this._handleReadonly(); break; @@ -1259,6 +1292,194 @@ class Modal { this.modalBody.appendChild(backButton); } + _getOrCreateNSignerCallerIdentity() { + const storageKey = 'nostr_login_lite_nsigner_caller'; + const existing = localStorage.getItem(storageKey); + + if (existing) { + try { + const parsed = JSON.parse(existing); + if (parsed?.secret && parsed?.pubkey) { + return parsed; + } + } catch (_) {} + } + + if (!window.NSignerWebUSB) { + throw new Error('NSignerWebUSB driver not loaded'); + } + + const secret = window.NSignerWebUSB.randomSecretHex(); + const pubkey = window.NSignerWebUSB.toPubkeyHex(secret); + const generated = { secret, pubkey }; + localStorage.setItem(storageKey, JSON.stringify(generated)); + return generated; + } + + _showNSignerScreen() { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'USB Hardware signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Connect your n_signer Feather S3 device over USB, then confirm the public key shown on the device.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + + if (!secure || !hasUsb) { + status.textContent = !secure + ? 'Requires HTTPS or localhost for WebUSB.' + : 'WebUSB unavailable. Use Chrome or Edge.'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const indexLabel = document.createElement('label'); + indexLabel.textContent = 'Nostr key index'; + indexLabel.style.cssText = 'display:block; margin-bottom: 6px; font-weight: 500;'; + + const indexInput = document.createElement('input'); + indexInput.type = 'number'; + indexInput.min = '0'; + indexInput.step = '1'; + indexInput.value = '0'; + indexInput.style.cssText = 'width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 6px; margin-bottom: 12px; box-sizing: border-box;'; + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Connect Device'; + connectButton.style.cssText = this._getButtonStyle(); + + const useButton = document.createElement('button'); + useButton.textContent = 'Use this device'; + useButton.disabled = true; + useButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px; opacity: 0.6; cursor: not-allowed;'; + + let connected = null; + + connectButton.onclick = async () => { + try { + const nostrIndex = Math.max(0, Number.parseInt(indexInput.value || '0', 10) || 0); + status.textContent = 'Connecting... If prompted, approve on your hardware device.'; + + const caller = this._getOrCreateNSignerCallerIdentity(); + + if (!window.NSignerWebUSB || typeof window.NSignerWebUSB.requestAndConnect !== 'function') { + throw new Error('NSignerWebUSB driver missing requestAndConnect (stale bundle likely loaded)'); + } + + let driver = await window.NSignerWebUSB.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex + }); + + // Defensive fallback for stale/cached bundles that may return a raw USBDevice + // instead of an initialized NSignerWebUSB instance. + if (driver && typeof driver.getPublicKey !== 'function' && typeof window.NSignerWebUSB === 'function') { + const maybeUsbDevice = driver; + if (typeof maybeUsbDevice.open === 'function') { + driver = new window.NSignerWebUSB(maybeUsbDevice, { + callerSecretKey: caller.secret, + nostrIndex + }); + await driver.open(); + } + } + + if (!driver || typeof driver.getPublicKey !== 'function') { + throw new Error('Driver initialization failed. Hard refresh the page to clear cached JS and retry.'); + } + + const pubkey = await driver.getPublicKey(); + connected = { + pubkey, + nostrIndex, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: driver.vendorId, + deviceProductId: driver.productId, + deviceSerial: driver.serial + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey, + connectionData: { + nostrIndex, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: driver.vendorId, + deviceProductId: driver.productId, + deviceSerial: driver.serial + }, + message: 'Your n_signer device was disconnected. Reconnect to continue.' + } + })); + }); + + status.textContent = `Connected. Device pubkey: ${pubkey}`; + useButton.disabled = false; + useButton.style.opacity = '1'; + useButton.style.cursor = 'pointer'; + } catch (error) { + const msg = String(error?.message || error); + if (msg.includes('SecurityError')) { + status.textContent = 'Access denied. On Linux, install the udev rule for VID:PID 303a:4001 and replug.'; + } else { + status.textContent = `Connect failed: ${msg}`; + } + } + }; + + useButton.onclick = () => { + if (!connected) return; + this._setAuthMethod('nsigner', { + pubkey: connected.pubkey, + signer: { + driver: connected.driver, + nostrIndex: connected.nostrIndex, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + } + }); + }; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(indexLabel); + this.modalBody.appendChild(indexInput); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(useButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + } + _showConnectScreen() { this.modalBody.innerHTML = '';