Compare commits

...
14 Commits
Author SHA1 Message Date
Laan Tungir 973025602c USB hardware signer 2026-05-10 10:30:35 -04:00
Laan Tungir 7af85a45a7 Fix NIP-46 signing across pages by auto-reconnecting BunkerSigner 2026-04-04 09:40:23 -04:00
Your Name 71347ea1bf small change 2026-01-28 13:16:03 -04:00
Your Name 58d9b4386e Remove more logs 2025-11-14 14:40:05 -04:00
Your Name cb4f4b2a3c Remove more logs 2025-11-14 14:31:40 -04:00
Your Name 05a5306f86 Reorganize directories 2025-11-14 13:59:08 -04:00
Your Name 98b87de736 Comment out debug prints 2025-11-14 13:45:29 -04:00
Your Name ae6f176f52 Comment out debug prints 2025-11-14 13:32:27 -04:00
Your Name a79277f3ed small stuff 2025-10-01 10:18:10 -04:00
Your Name 521693cfa1 . 2025-09-24 10:50:11 -04:00
Your Name 3109a93163 Fixed issue not recognizing browser extension 2025-09-22 15:37:53 -04:00
Your Name 4505167246 Change key entry 2025-09-21 11:51:33 -04:00
Your Name ea387c0c9f Add automated versioning and deployment system 2025-09-21 11:22:26 -04:00
Your Name a7dceb1156 Fixed persistance issues 2025-09-20 15:33:14 -04:00
16 changed files with 3879 additions and 1295 deletions
+2 -1
View File
@@ -18,4 +18,5 @@ Thumbs.db
log.txt
Trash/
nostr-login/
nostr-login/
nostr-tools/
+2
View File
@@ -22,7 +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,
connect: true, // NIP-46 remote signers
nsigner: true, // USB Hardware signer (n_signer via WebUSB)
otp: false // OTP/DM authentication (not implemented yet)
},
File diff suppressed because it is too large Load Diff
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
rsync -avz --chmod=644 --progress \
build/{nostr-lite.js,nostr.bundle.js} \
examples/nsigner.html \
ubuntu@laantungir.net:html/nostr-login-lite/
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Embedded NOSTR_LOGIN_LITE</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 40px;
background: white;
display: flex;
justify-content: center;
align-items: center;
min-height: 90vh;
}
.container {
max-width: 400px;
width: 100%;
}
#login-container {
/* No styling - let embedded modal blend seamlessly */
}
</style>
</head>
<body>
<div class="container">
<div id="login-container">
<!-- Login interface will appear here -->
</div>
<div id="test-section" style="display: none; margin-top: 30px;">
<h2>Nostr Testing Interface</h2>
<div id="status" style="margin-bottom: 20px; padding: 10px; background: #f0f0f0; border-radius: 5px;"></div>
<div style="display: grid; gap: 15px;">
<button id="sign-button" style="padding: 12px; font-size: 16px; background: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer;">
Sign Event
</button>
<button id="nip04-encrypt-button" style="padding: 12px; font-size: 16px; background: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer;">
NIP-04 Encrypt
</button>
<button id="nip04-decrypt-button" style="padding: 12px; font-size: 16px; background: #28a745; color: white; border: none; border-radius: 5px; cursor: pointer;">
NIP-04 Decrypt
</button>
<button id="nip44-encrypt-button" style="padding: 12px; font-size: 16px; background: #6f42c1; color: white; border: none; border-radius: 5px; cursor: pointer;">
NIP-44 Encrypt
</button>
<button id="nip44-decrypt-button" style="padding: 12px; font-size: 16px; background: #6f42c1; color: white; border: none; border-radius: 5px; cursor: pointer;">
NIP-44 Decrypt
</button>
<button id="get-pubkey-button" style="padding: 12px; font-size: 16px; background: #17a2b8; color: white; border: none; border-radius: 5px; cursor: pointer;">
Get Public Key
</button>
</div>
<div id="results" style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px; font-family: monospace; white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
</div>
</div>
<script src="../lite/nostr.bundle.js"></script>
<script src="../lite/nostr-lite.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
extension: true,
local: true,
seedphrase:true,
readonly: true,
connect: true,
remote: true,
otp: true
},
floatingTab: {
enabled: true,
hPosition: 1, // 0.0-1.0 or '95%' from left
vPosition: 0, // 0.0-1.0 or '50%' from top
appearance: {
style: 'square', // 'pill', 'square', 'circle', 'minimal'
// icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
text: 'Login'
},
behavior: {
hideWhenAuthenticated: false,
showUserInfo: true,
autoSlide: true
},
animation: {
slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down'
}
}});
// Check for existing authentication state on page load
const authState = getAuthState();
if (authState && authState.method) {
console.log('Found existing authentication:', authState.method);
document.getElementById('status').textContent = `Authenticated with: ${authState.method}`;
document.getElementById('test-section').style.display = 'block';
// Store some test data for encryption/decryption
window.testCiphertext = null;
window.testCiphertext44 = null;
}
// Listen for authentication events
window.addEventListener('nlMethodSelected', (event) => {
console.log('User authenticated:', event.detail);
document.getElementById('status').textContent = `Authenticated with: ${event.detail.method}`;
document.getElementById('test-section').style.display = 'block';
// Store some test data for encryption/decryption
window.testCiphertext = null;
window.testCiphertext44 = null;
});
window.addEventListener('nlLogout', () => {
console.log('User logged out');
document.getElementById('status').textContent = 'Logged out';
document.getElementById('test-section').style.display = 'none';
document.getElementById('results').innerHTML = '';
});
// Button event listeners
document.getElementById('get-pubkey-button').addEventListener('click', testGetPublicKey);
document.getElementById('sign-button').addEventListener('click', testSigning);
document.getElementById('nip04-encrypt-button').addEventListener('click', testNip04Encrypt);
document.getElementById('nip04-decrypt-button').addEventListener('click', testNip04Decrypt);
document.getElementById('nip44-encrypt-button').addEventListener('click', testNip44Encrypt);
document.getElementById('nip44-decrypt-button').addEventListener('click', testNip44Decrypt);
});
// Test functions
async function testGetPublicKey() {
try {
updateResults('🔑 Getting public key...');
const pubkey = await window.nostr.getPublicKey();
updateResults(`✅ Public Key: ${pubkey}`);
} catch (error) {
updateResults(`❌ Get Public Key Error: ${error.message}`);
}
}
async function testSigning() {
try {
updateResults('✍️ Signing event...');
const event = {
kind: 1,
content: 'Hello from NOSTR_LOGIN_LITE key test! ' + new Date().toISOString(),
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = await window.nostr.signEvent(event);
updateResults(`✅ Event Signed Successfully:\n${JSON.stringify(signedEvent, null, 2)}`);
} catch (error) {
updateResults(`❌ Sign Event Error: ${error.message}`);
}
}
async function testNip04Encrypt() {
try {
updateResults('🔐 Testing NIP-04 encryption...');
const pubkey = await window.nostr.getPublicKey();
const plaintext = 'Secret message for NIP-04 testing! ' + Date.now();
const ciphertext = await window.nostr.nip04.encrypt(pubkey, plaintext);
window.testCiphertext = ciphertext; // Store for decryption test
updateResults(`✅ NIP-04 Encrypted:\nPlaintext: ${plaintext}\nCiphertext: ${ciphertext}`);
} catch (error) {
updateResults(`❌ NIP-04 Encrypt Error: ${error.message}`);
}
}
async function testNip04Decrypt() {
try {
if (!window.testCiphertext) {
updateResults('❌ No ciphertext available. Run NIP-04 encrypt first.');
return;
}
updateResults('🔓 Testing NIP-04 decryption...');
const pubkey = await window.nostr.getPublicKey();
const decrypted = await window.nostr.nip04.decrypt(pubkey, window.testCiphertext);
updateResults(`✅ NIP-04 Decrypted:\nCiphertext: ${window.testCiphertext}\nDecrypted: ${decrypted}`);
} catch (error) {
updateResults(`❌ NIP-04 Decrypt Error: ${error.message}`);
}
}
async function testNip44Encrypt() {
try {
updateResults('🔐 Testing NIP-44 encryption...');
const pubkey = await window.nostr.getPublicKey();
const plaintext = 'Secret message for NIP-44 testing! ' + Date.now();
const ciphertext = await window.nostr.nip44.encrypt(pubkey, plaintext);
window.testCiphertext44 = ciphertext; // Store for decryption test
updateResults(`✅ NIP-44 Encrypted:\nPlaintext: ${plaintext}\nCiphertext: ${ciphertext}`);
} catch (error) {
updateResults(`❌ NIP-44 Encrypt Error: ${error.message}`);
}
}
async function testNip44Decrypt() {
try {
if (!window.testCiphertext44) {
updateResults('❌ No ciphertext available. Run NIP-44 encrypt first.');
return;
}
updateResults('🔓 Testing NIP-44 decryption...');
const pubkey = await window.nostr.getPublicKey();
const decrypted = await window.nostr.nip44.decrypt(pubkey, window.testCiphertext44);
updateResults(`✅ NIP-44 Decrypted:\nCiphertext: ${window.testCiphertext44}\nDecrypted: ${decrypted}`);
} catch (error) {
updateResults(`❌ NIP-44 Decrypt Error: ${error.message}`);
}
}
function updateResults(message) {
const results = document.getElementById('results');
const timestamp = new Date().toLocaleTimeString();
results.textContent += `[${timestamp}] ${message}\n\n`;
results.scrollTop = results.scrollHeight;
}
</script>
</body>
</html>
+11
View File
@@ -35,6 +35,15 @@
<!-- Load NOSTR_LOGIN_LITE main library (now includes NIP-46 extension) -->
<script src="../lite/nostr-lite.js"></script>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="./nostr.bundle.js"></script> -->
<script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script>
<!-- <script src="./nostr-lite.js"></script> -->
<script>
@@ -49,6 +58,8 @@
try {
await window.NOSTR_LOGIN_LITE.init({
persistence: true, // Enable persistent authentication (default: true)
isolateSession: true, // Use sessionStorage for per-tab isolation (default: false = localStorage)
theme: 'default',
darkMode: false,
methods: {
+106
View File
@@ -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>
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# increment_build_push.sh
# Automates version increment, build, and git operations
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}🔄 Starting increment, build, and push process...${NC}"
# Function to get the latest git tag
get_latest_tag() {
# Get the latest tag that matches the pattern v*.*.*
git tag -l "v*.*.*" | sort -V | tail -n1
}
# Function to increment version
increment_version() {
local version=$1
# Remove 'v' prefix if present
version=${version#v}
# Split version into parts
IFS='.' read -ra VERSION_PARTS <<< "$version"
# Increment the patch version (last digit)
local major=${VERSION_PARTS[0]}
local minor=${VERSION_PARTS[1]}
local patch=${VERSION_PARTS[2]}
patch=$((patch + 1))
echo "$major.$minor.$patch"
}
# Step 1: Get current version
echo -e "${YELLOW}📋 Getting current version...${NC}"
current_tag=$(get_latest_tag)
if [ -z "$current_tag" ]; then
echo -e "${YELLOW}⚠️ No existing version tags found, starting with v0.1.0${NC}"
current_version="0.1.0"
else
echo -e "Current tag: ${current_tag}"
current_version=${current_tag#v}
fi
# Step 2: Increment version
new_version=$(increment_version "$current_version")
new_tag="v$new_version"
echo -e "${GREEN}📈 Incrementing version: $current_version$new_version${NC}"
# Step 2.5: Save version to src/VERSION file
echo -e "${YELLOW}💾 Saving version to src/VERSION...${NC}"
echo "$new_version" > src/VERSION
echo -e "Version saved: ${GREEN}$new_version${NC}"
# Step 2.5: Run build.js
echo -e "${YELLOW}🔧 Running build process...${NC}"
cd src
node build.js
cd ..
echo -e "${GREEN}✅ Build completed${NC}"
# Step 3: Git add
echo -e "${YELLOW}📦 Adding files to git...${NC}"
git add .
# Step 4: Handle commit message and commit
commit_message=""
if [ $# -eq 0 ]; then
# No arguments provided, ask for commit message
echo -e "${YELLOW}💬 Please enter a commit message:${NC}"
read -p "> " commit_message
if [ -z "$commit_message" ]; then
echo -e "${RED}❌ Commit message cannot be empty${NC}"
exit 1
fi
else
# Use provided arguments as commit message
commit_message="$*"
fi
echo -e "${YELLOW}💬 Committing changes...${NC}"
git commit -m "$commit_message"
echo -e "${YELLOW}🏷️ Creating git tag: $new_tag${NC}"
git tag "$new_tag"
# Step 5: Git push
echo -e "${YELLOW}🚀 Pushing to remote...${NC}"
git push
git push --tags
echo -e "${GREEN}🎉 Successfully completed:${NC}"
echo -e " • Version incremented to: ${GREEN}$new_version${NC}"
echo -e " • VERSION file updated: ${GREEN}src/VERSION${NC}"
echo -e " • Build completed: ${GREEN}build/nostr-lite.js${NC}"
echo -e " • Git tag created: ${GREEN}$new_tag${NC}"
echo -e " • Changes pushed to remote${NC}"
echo -e "\n${GREEN}✨ Process complete!${NC}"
+10
View File
@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"liveServer.settings.port": 5501
}
}
+351
View File
@@ -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 (~58 KB). Reuses `nostr-tools` schnorr already in the bundle. No new deps. |
| User pairs a different mnemonic on the same physical device | Auto-restore step §5.4.3 detects the pubkey mismatch and forces re-login instead of silently signing with the wrong identity. |
| Caller-key leak from `localStorage` | Document that `callerSecretKey` is per-app, not a Nostr identity. Optionally encrypt at rest with the same scheme as `local`. |
## 8. Test plan
### 8.1 Unit (driver, no device)
Mock `USBDevice`. Verify:
- 4-byte BE length framing (out and in).
- Ring-buffer reassembly across split chunks of arbitrary boundaries.
- Auth envelope produces a kind-27235 event whose `nsigner_body_hash` matches `SHA-256(JCS(params))`.
- Auth envelope verifies with `nostr-tools` `verifyEvent()` round-trip.
- Mutex serializes overlapping `signEvent` calls.
- `onDisconnect` rejects in-flight calls with a recognizable error.
### 8.2 Manual (with real Feather S3 device)
- **Pair flow.** Open [`examples/sign.html`](../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.
View File
+1
View File
@@ -0,0 +1 @@
0.1.14
File diff suppressed because it is too large Load Diff
+308
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff