USB hardware signer
This commit is contained in:
@@ -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)
|
||||
},
|
||||
|
||||
|
||||
+672
-10
@@ -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);
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>n_signer WebUSB Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>n_signer WebUSB Harness</h2>
|
||||
<div id="status"></div>
|
||||
<div id="results"></div>
|
||||
<button id="launch">Launch Login Modal</button>
|
||||
<button id="sign" disabled>Sign kind-1</button>
|
||||
<button id="nip44" disabled>NIP-44 self encrypt/decrypt</button>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script>
|
||||
const status = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const signBtn = document.getElementById('sign');
|
||||
const nip44Btn = document.getElementById('nip44');
|
||||
|
||||
function setStatus(msg) {
|
||||
status.textContent = msg;
|
||||
}
|
||||
|
||||
function show(obj) {
|
||||
results.innerHTML = `<pre>${typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
setStatus('Initializing...');
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
nsigner: true,
|
||||
otp: false
|
||||
},
|
||||
floatingTab: { enabled: false }
|
||||
});
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
setStatus('Not a secure context. Use HTTPS or localhost for WebUSB.');
|
||||
} else if (!('usb' in navigator)) {
|
||||
setStatus('WebUSB not available in this browser. Use Chrome/Edge.');
|
||||
} else {
|
||||
setStatus('Ready. Use Launch Login Modal and select USB Hardware signer.');
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById('launch').addEventListener('click', () => {
|
||||
window.NOSTR_LOGIN_LITE.launch('login');
|
||||
});
|
||||
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
setStatus(`Authenticated with: ${event.detail.method}`);
|
||||
const enabled = event.detail.method !== 'readonly';
|
||||
signBtn.disabled = !enabled;
|
||||
nip44Btn.disabled = !enabled;
|
||||
});
|
||||
|
||||
window.addEventListener('nlAuthRestored', (event) => {
|
||||
setStatus(`Restored auth: ${event.detail.method}`);
|
||||
const enabled = event.detail.method !== 'readonly';
|
||||
signBtn.disabled = !enabled;
|
||||
nip44Btn.disabled = !enabled;
|
||||
});
|
||||
|
||||
window.addEventListener('nlReconnectionRequired', (event) => {
|
||||
setStatus(event.detail?.message || 'Reconnection required');
|
||||
});
|
||||
|
||||
signBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const event = {
|
||||
kind: 1,
|
||||
content: 'hello from nsigner harness',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
const signed = await window.nostr.signEvent(event);
|
||||
show(signed);
|
||||
} catch (err) {
|
||||
show(`signEvent failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
nip44Btn.addEventListener('click', async () => {
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const plaintext = 'self-test ' + Date.now();
|
||||
const cipher = await window.nostr.nip44.encrypt(pubkey, plaintext);
|
||||
const dec = await window.nostr.nip44.decrypt(pubkey, cipher);
|
||||
show({ pubkey, plaintext, cipher, decrypted: dec, ok: dec === plaintext });
|
||||
} catch (err) {
|
||||
show(`nip44 failed: ${err.message}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": "<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`](../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.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.13
|
||||
0.1.14
|
||||
|
||||
+161
-3
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+226
-5
@@ -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 = '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user