Compare commits

...
27 Commits
Author SHA1 Message Date
Laan Tungir 5c45a4f3dc Fixed n_signer webserial/webusb sign-in failure: add nostr_ prefix to RPC method names (get_public_key, sign_event, nip04/nip44 encrypt/decrypt) required by upgraded n_signer firmware 2026-07-21 19:30:02 -04:00
Laan Tungir 2221e81c9b Migrated n_signer verb names to nostr_ prefix (sign_event→nostr_sign_event, get_public_key→nostr_get_public_key, nip04_*→nostr_nip04_*, nip44_*→nostr_nip44_*) 2026-07-20 18:04:15 -04:00
Laan Tungir 2ca4baad63 Bump version for unfiltered hardware signer USB/serial chooser and reconnect matching updates 2026-06-09 15:25:39 -04:00
Laan Tungir c4ecc8aca2 Remove USB/serial device filters and relax reconnect matching 2026-06-09 15:23:28 -04:00
Laan Tungir 43c8d0d3a3 Harden webserial session stability for hardware signer reconnect and probing 2026-05-28 09:23:09 -04:00
Laan Tungir b1218fa514 Improve nsigner multi-tab behavior with delegated restore state and clearer driver availability handling 2026-05-27 15:17:36 -04:00
Laan Tungir a20f17ea32 Unify hardware signer UX with serial-first auto-detect and WebUSB fallback; add transport-aware nsigner persistence/restore 2026-05-27 07:25:28 -04:00
Laan Tungir bdc9513e52 Add nsigner account probe diagnostics and fix modal visibility during chooser flow 2026-05-11 07:51:09 -04:00
Laan Tungir 1fd3862324 Add nsigner account chooser, modal sign-out action, and methods all/empty fallback 2026-05-10 18:25:58 -04:00
Laan Tungir 7f1dc00f49 USB hardware signer 2026-05-10 10:33:46 -04:00
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
Your Name 966d9d0456 documentation changes 2025-09-20 11:08:45 -04:00
Your Name ccff136edb Single Source of Truth Architecture - Complete authentication state management with storage-based getAuthState() as sole authoritative source 2025-09-20 10:39:43 -04:00
Your Name 8f34c2de73 Seem to have most everything working well. Got persistant state after page refresh, and implmented logout call 2025-09-19 16:09:05 -04:00
29 changed files with 16557 additions and 6551 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
# Dependencies
node_modules/
nostr-tools/
# IDE and OS files
.idea/
@@ -18,4 +18,5 @@ Thumbs.db
log.txt
Trash/
nostr-login/
nostr-login/
nostr-tools/
+93 -44
View File
@@ -1,67 +1,88 @@
Nostr_Login_Lite
===========
## Floating Tab API
## API
Configure persistent floating tab for login/logout:
Complete configuration showing all available options:
```javascript
await NOSTR_LOGIN_LITE.init({
// Set the initial theme (default: 'default')
theme: 'dark', // Choose from 'default' or 'dark'
// Standard configuration options
await window.NOSTR_LOGIN_LITE.init({
// Theme configuration
theme: 'default', // 'default' | 'dark' | custom theme name
// 🔐 Authentication persistence configuration
persistence: true, // Enable persistent authentication (default: true)
isolateSession: false, // Use sessionStorage for per-tab isolation (default: false = localStorage)
// Relay configuration
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
// Authentication methods
methods: {
extension: true,
local: true,
readonly: true,
connect: true,
otp: true
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)
},
// Floating tab configuration (now uses theme-aware text icons)
// Floating tab configuration
floatingTab: {
enabled: true,
hPosition: 0.95, // 0.0-1.0 or '95%' from left
vPosition: 0.5, // 0.0-1.0 or '50%' from top
getUserInfo: true, // Fetch user profile name from relays
getUserRelay: [ // Relays for profile queries
'wss://relay.damus.io',
'wss://nos.lol'
],
enabled: true, // Show/hide floating login tab
hPosition: 0.95, // 0.0 = left edge, 1.0 = right edge
vPosition: 0.1, // 0.0 = top edge, 1.0 = bottom edge
offset: { x: 0, y: 0 }, // Fine-tune positioning (pixels)
appearance: {
style: 'pill', // 'pill', 'square', 'circle', 'minimal'
theme: 'auto', // 'auto' follows main theme
icon: '[LOGIN]', // Now uses text-based icons like [LOGIN], [KEY], [NET]
text: 'Login'
style: 'pill', // 'pill' | 'square' | 'circle'
theme: 'auto', // 'auto' | 'light' | 'dark'
icon: '[LOGIN]', // Text-based icon
text: 'Sign In', // Button text
iconOnly: false // Show icon only (no text)
},
behavior: {
hideWhenAuthenticated: false,
showUserInfo: true,
autoSlide: true
},
animation: {
slideDirection: 'auto' // 'auto', 'left', 'right', 'up', 'down'
hideWhenAuthenticated: false, // Keep visible after login
showUserInfo: true, // Show user info when authenticated
autoSlide: true, // Slide animation on hover
persistent: false // Persist across page reloads
}
}
});
// After initialization, you can switch themes dynamically:
NOSTR_LOGIN_LITE.switchTheme('dark');
NOSTR_LOGIN_LITE.switchTheme('default');
// Or customize individual theme variables:
NOSTR_LOGIN_LITE.setThemeVariable('--nl-accent-color', '#00ff00');
// Control Methods
NOSTR_LOGIN_LITE.launch(); // Open login modal
NOSTR_LOGIN_LITE.logout(); // Clear authentication state
NOSTR_LOGIN_LITE.switchTheme('dark'); // Change theme
NOSTR_LOGIN_LITE.showFloatingTab(); // Show floating tab
NOSTR_LOGIN_LITE.hideFloatingTab(); // Hide floating tab
NOSTR_LOGIN_LITE.updateFloatingTab(options); // Update floating tab options
NOSTR_LOGIN_LITE.toggleFloatingTab(); // Toggle floating tab visibility
// Get Authentication State (Single Source of Truth)
const authState = NOSTR_LOGIN_LITE.getAuthState();
const isAuthenticated = !!authState;
const userInfo = authState; // Contains { method, pubkey, etc. }
```
Control methods:
```javascript
NOSTR_LOGIN_LITE.showFloatingTab();
NOSTR_LOGIN_LITE.hideFloatingTab();
NOSTR_LOGIN_LITE.updateFloatingTab(options);
NOSTR_LOGIN_LITE.destroyFloatingTab();
```
**Authentication Persistence:**
Two-tier configuration system:
1. **`persistence: boolean`** - Master switch for authentication persistence
- `true` (default): Save authentication state for automatic restore
- `false`: No persistence - user must login fresh every time
2. **`isolateSession: boolean`** - Storage location when persistence is enabled
- `false` (default): Use localStorage - shared across tabs/windows
- `true`: Use sessionStorage - isolated per tab/window
**Use Cases for Session Isolation (`isolateSession: true`):**
- Multi-tenant applications where different tabs need different users
- Testing environments requiring separate authentication per tab
- Privacy-focused applications that shouldn't share login state across tabs
## Embedded Modal API
@@ -86,3 +107,31 @@ const modal = NOSTR_LOGIN_LITE.embed('#login-container', {
```
Container can be CSS selector or DOM element. Modal renders inline without backdrop overlay.
## Logout API
To log out users and clear authentication state:
```javascript
// Unified logout method - works for all authentication methods
window.NOSTR_LOGIN_LITE.logout();
```
This will:
- Clear persistent authentication data from localStorage
- Dispatch `nlLogout` event for custom cleanup
- Reset the authentication state across all components
### Event Handling
Listen for logout events in your application:
```javascript
window.addEventListener('nlLogout', () => {
console.log('User logged out');
// Clear your application's UI state
// Redirect to login page, etc.
});
```
The logout system works consistently across all authentication methods (extension, local keys, NIP-46, etc.) and all UI components (floating tab, modal, embedded).
+6593
View File
File diff suppressed because it is too large Load Diff
@@ -11146,12 +11146,12 @@ zoo`.split("\n");
}
async getPublicKey() {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest("get_public_key", []);
this.cachedPubKey = await this.sendRequest("nostr_get_public_key", []);
}
return this.cachedPubKey;
}
async signEvent(event) {
let resp = await this.sendRequest("sign_event", [JSON.stringify(event)]);
let resp = await this.sendRequest("nostr_sign_event", [JSON.stringify(event)]);
let signed = JSON.parse(resp);
if (verifyEvent(signed)) {
return signed;
@@ -11160,16 +11160,16 @@ zoo`.split("\n");
}
}
async nip04Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest("nip04_encrypt", [thirdPartyPubkey, plaintext]);
return await this.sendRequest("nostr_nip04_encrypt", [thirdPartyPubkey, plaintext]);
}
async nip04Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest("nip04_decrypt", [thirdPartyPubkey, ciphertext]);
return await this.sendRequest("nostr_nip04_decrypt", [thirdPartyPubkey, ciphertext]);
}
async nip44Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest("nip44_encrypt", [thirdPartyPubkey, plaintext]);
return await this.sendRequest("nostr_nip44_encrypt", [thirdPartyPubkey, plaintext]);
}
async nip44Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest("nip44_decrypt", [thirdPartyPubkey, ciphertext]);
return await this.sendRequest("nostr_nip44_decrypt", [thirdPartyPubkey, ciphertext]);
}
};
async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) {
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,feather_webusb_demo.html,cyd_webserial_demo.html} \
ubuntu@laantungir.net:html/nostr-login-lite/
+63 -4
View File
@@ -35,7 +35,7 @@
}
#login-button:hover {
background: #0052a3;
opacity: 0.8;
}
</style>
</head>
@@ -49,6 +49,9 @@
<script src="../lite/nostr-lite.js"></script>
<script>
let isAuthenticated = false;
let currentUser = null;
document.addEventListener('DOMContentLoaded', async () => {
await window.NOSTR_LOGIN_LITE.init({
@@ -65,10 +68,66 @@
}
});
document.getElementById('login-button').addEventListener('click', () => {
window.NOSTR_LOGIN_LITE.launch('login');
});
// Listen for authentication events
window.addEventListener('nlMethodSelected', handleAuthEvent);
window.addEventListener('nlLogout', handleLogoutEvent);
// Check for existing authentication state
checkAuthState();
// Initialize button
updateButtonState();
});
function handleAuthEvent(event) {
const { pubkey, method } = event.detail;
console.log(`Authenticated with ${method}, pubkey: ${pubkey}`);
isAuthenticated = true;
currentUser = event.detail;
updateButtonState();
}
function handleLogoutEvent() {
console.log('Logout event received');
isAuthenticated = false;
currentUser = null;
updateButtonState();
}
function checkAuthState() {
// Check if user is already authenticated (from persistent storage)
try {
// Try to get public key - this will work if already authenticated
window.nostr.getPublicKey().then(pubkey => {
console.log('Found existing authentication, pubkey:', pubkey);
isAuthenticated = true;
currentUser = { pubkey, method: 'persistent' };
updateButtonState();
}).catch(error => {
console.log('No existing authentication found:', error.message);
// User is not authenticated, button stays in login state
});
} catch (error) {
console.log('No existing authentication found');
// User is not authenticated, button stays in login state
}
}
function updateButtonState() {
const button = document.getElementById('login-button');
if (isAuthenticated) {
button.textContent = 'Logout';
button.onclick = () => window.NOSTR_LOGIN_LITE.logout();
button.style.background = '#dc3545'; // Red for logout
} else {
button.textContent = 'Login';
button.onclick = () => window.NOSTR_LOGIN_LITE.launch('login');
button.style.background = '#0066cc'; // Blue for login
}
}
</script>
</body>
+735
View File
@@ -0,0 +1,735 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer CYD Web Serial Demo</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap {
max-width: 980px;
margin: 20px auto;
padding: 0 14px 24px;
}
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 12px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
.card h2 {
font-size: 1rem;
margin: 0 0 10px;
}
label {
font-size: 0.86rem;
color: var(--muted);
display: block;
margin: 6px 0 4px;
}
input, textarea, button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
}
input, textarea {
width: 100%;
background: #0c131d;
color: var(--text);
padding: 8px 10px;
}
textarea { min-height: 84px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button {
background: #1f6feb;
color: white;
padding: 8px 12px;
cursor: pointer;
border: 0;
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.secondary { background: #334155; }
.danger { background: #7f1d1d; }
.status {
padding: 6px 10px;
border-radius: 999px;
background: #2a3648;
color: var(--muted);
font-size: 0.85rem;
border: 1px solid var(--border);
}
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre {
margin: 8px 0 0;
background: #0a1018;
border: 1px solid #1b2636;
color: #d7e2ee;
padding: 10px;
border-radius: 8px;
overflow: auto;
max-height: 220px;
font-size: 12px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.info-box {
background: #0d1f35;
border: 1px solid #1e3a5f;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.5;
}
.info-box strong { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer CYD Web Serial Demo</h1>
<p class="note">Connect to the CYD (ESP32-2432S028) board over Web Serial (CH340/CP2102 USB-UART bridge), choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
<div class="info-box">
<strong>Requirements:</strong> Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.<br>
<strong>Linux users:</strong> Add yourself to the <code>dialout</code> group (<code>sudo usermod -aG dialout $USER</code>) and replug the device.<br>
<strong>Windows users:</strong> Install the WCH-IC CH340 driver if the port does not appear.<br>
<strong>Note:</strong> Close any serial monitor (Arduino IDE, idf.py monitor) before connecting — only one app can hold the port at a time.
</div>
<div class="card">
<div class="row">
<button id="connectBtn">Connect CYD (Serial)</button>
<button id="disconnectBtn" class="danger" disabled>Disconnect</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<section class="card">
<h2>Public Key</h2>
<label for="keyIndex">Key index (nostr_index)</label>
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
<div class="row" style="margin-top:10px">
<button id="pubkeyBtn" disabled>Get Public Key</button>
</div>
<pre id="pubkeyOut" class="mono"></pre>
</section>
<section class="card">
<h2>Sign Kind 1 Event</h2>
<label for="kind1Content">Content</label>
<textarea id="kind1Content">hello from cyd webserial demo</textarea>
<label for="kind1Tags">Tags JSON (array)</label>
<input id="kind1Tags" value="[]" />
<div class="row" style="margin-top:10px">
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Encrypt</h2>
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
<label for="nip04Msg">Plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Decrypt</h2>
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip04Cipher">Ciphertext</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<div class="row" style="margin-top:10px">
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
</div>
<pre id="nip04DecOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Encrypt</h2>
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
<label for="nip44Msg">Plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Decrypt</h2>
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip44Cipher">Ciphertext</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<div class="row" style="margin-top:10px">
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
</div>
<pre id="nip44DecOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
// ── DOM refs ──────────────────────────────────────────────────────────────
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const disconnectBtn = document.getElementById("disconnectBtn");
const pubkeyBtn = document.getElementById("pubkeyBtn");
const signKind1Btn = document.getElementById("signKind1Btn");
const nip04EncBtn = document.getElementById("nip04EncBtn");
const nip04DecBtn = document.getElementById("nip04DecBtn");
const nip44EncBtn = document.getElementById("nip44EncBtn");
const nip44DecBtn = document.getElementById("nip44DecBtn");
const keyIndexEl = document.getElementById("keyIndex");
const pubkeyOutEl = document.getElementById("pubkeyOut");
const kind1ContentEl = document.getElementById("kind1Content");
const kind1TagsEl = document.getElementById("kind1Tags");
const signOutEl = document.getElementById("signOut");
const nip04PeerEl = document.getElementById("nip04Peer");
const nip04MsgEl = document.getElementById("nip04Msg");
const nip04OutEl = document.getElementById("nip04Out");
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
const nip04CipherEl = document.getElementById("nip04Cipher");
const nip04DecOutEl = document.getElementById("nip04DecOut");
const nip44PeerEl = document.getElementById("nip44Peer");
const nip44MsgEl = document.getElementById("nip44Msg");
const nip44OutEl = document.getElementById("nip44Out");
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
const nip44CipherEl = document.getElementById("nip44Cipher");
const nip44DecOutEl = document.getElementById("nip44DecOut");
// ── State ─────────────────────────────────────────────────────────────────
// Keep chooser unfiltered so any serial-capable device can be selected.
const SERIAL_FILTERS = [];
let port = null; // SerialPort
let writer = null; // WritableStreamDefaultWriter
let reader = null; // ReadableStreamDefaultReader
let ownPubkey = "";
let rpcCounter = 0;
// Pending RPC promises keyed by request id
const pending = new Map(); // id -> { resolve, reject, timer }
// ── Helpers ───────────────────────────────────────────────────────────────
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function setConnected(connected) {
connectBtn.disabled = connected;
disconnectBtn.disabled = !connected;
pubkeyBtn.disabled = !connected;
signKind1Btn.disabled = !connected;
nip04EncBtn.disabled = !connected;
nip04DecBtn.disabled = !connected;
nip44EncBtn.disabled = !connected;
nip44DecBtn.disabled = !connected;
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function utf8(s) {
return new TextEncoder().encode(s);
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function getIndexOptions() {
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
return { nostr_index: index };
}
function pretty(value) {
try { return JSON.stringify(value, null, 2); }
catch { return String(value); }
}
function requirePeerHex(peer) {
const v = String(peer || "").trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(v)) {
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
}
return v;
}
function requireStringResult(resp, what) {
if (resp && typeof resp.result === "string") return resp.result;
throw new Error(`${what} failed: ${pretty(resp)}`);
}
// ── Auth envelope ─────────────────────────────────────────────────────────
async function buildAuth(method, params) {
// Demo caller key — fixed, non-secret (demo only).
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
const tags = [
["nsigner_rpc", String(++rpcCounter)],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "cyd-webserial-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
}
// ── Frame writer ──────────────────────────────────────────────────────────
async function writeFrame(reqObj) {
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
// Acquire writer, write, release immediately so other callers can use it.
const w = port.writable.getWriter();
try {
await w.write(frame);
} finally {
w.releaseLock();
}
}
// ── Background read loop ──────────────────────────────────────────────────
// Holds the reader for the lifetime of the connection.
// Parses length-prefixed frames and resolves pending RPC promises.
async function readLoop() {
let ring = new Uint8Array(0);
try {
reader = port.readable.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value || value.length === 0) continue;
// Append chunk to ring buffer.
const next = new Uint8Array(ring.length + value.length);
next.set(ring, 0);
next.set(value, ring.length);
ring = next;
// Parse as many complete frames as possible.
while (ring.length >= 4) {
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
// Invalid length header — slide one byte (boot-log recovery).
if (n <= 0 || n > 1_000_000) {
ring = ring.slice(1);
continue;
}
// Not enough bytes yet for the full payload.
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
let resp;
try {
resp = JSON.parse(new TextDecoder().decode(payload));
} catch (e) {
log("⚠ Frame parse error:", String(e));
continue;
}
// Resolve the matching pending RPC.
if (resp && resp.id && pending.has(resp.id)) {
const { resolve, timer } = pending.get(resp.id);
pending.delete(resp.id);
clearTimeout(timer);
resolve(resp);
} else {
log("← unsolicited frame:", JSON.stringify(resp));
}
}
}
} catch (err) {
// reader.cancel() throws a non-error on clean close; ignore it.
if (err && err.name !== "AbortError") {
log("Read loop error:", String(err));
}
} finally {
try { reader.releaseLock(); } catch (_) {}
reader = null;
handleDisconnect("Device disconnected");
}
}
// ── RPC call ──────────────────────────────────────────────────────────────
function sendRpc(reqObj) {
return new Promise((resolve, reject) => {
const id = reqObj.id;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("Timed out waiting for n_signer response"));
}, 30000);
pending.set(id, { resolve, reject, timer });
writeFrame(reqObj).catch(err => {
pending.delete(id);
clearTimeout(timer);
reject(err);
});
});
}
async function rpcCall(method, params) {
const id = `cyd-${Date.now()}-${++rpcCounter}`;
const auth = await buildAuth(method, params);
const req = { jsonrpc: "2.0", id, method, params, auth };
log("→", method, JSON.stringify(params));
const resp = await sendRpc(req);
log("←", method, JSON.stringify(resp));
return resp;
}
// ── Connect / disconnect ──────────────────────────────────────────────────
async function applySignalStrategies(serialPort) {
const strategies = [
{ dataTerminalReady: false, requestToSend: false, label: "dtr=0 rts=0" },
{ dataTerminalReady: true, requestToSend: true, label: "dtr=1 rts=1" }
];
if (!serialPort?.setSignals) return;
for (const s of strategies) {
try {
await serialPort.setSignals({
dataTerminalReady: !!s.dataTerminalReady,
requestToSend: !!s.requestToSend
});
await new Promise(r => setTimeout(r, 120));
} catch (e) {
log("⚠ setSignals strategy failed:", s.label, String(e));
}
}
}
async function findReenumeratedPort(matchInfo = {}) {
try {
const ports = await navigator.serial.getPorts();
if (!ports || !ports.length) return null;
const vid = matchInfo?.usbVendorId;
const pid = matchInfo?.usbProductId;
return ports.find((p) => {
const i = p.getInfo?.() || {};
if (vid != null && i.usbVendorId !== vid) return false;
if (pid != null && i.usbProductId !== pid) return false;
return true;
}) || ports[0] || null;
} catch {
return null;
}
}
async function connect() {
if (!("serial" in navigator)) {
throw new Error("Web Serial API not available — use Chrome 89+, Edge 89+, or Brave");
}
const initiallySelected = SERIAL_FILTERS.length
? await navigator.serial.requestPort({ filters: SERIAL_FILTERS })
: await navigator.serial.requestPort();
const selectedInfo = initiallySelected.getInfo?.() || {};
let lastError = null;
for (let attempt = 0; attempt < 2; attempt++) {
let droppedDuringOpen = false;
try {
port = attempt === 0 ? initiallySelected : (await findReenumeratedPort(selectedInfo));
if (!port) throw new Error("Serial port not available after re-enumeration");
const onPortDisconnect = () => {
droppedDuringOpen = true;
handleDisconnect("Port disconnect event");
};
port.addEventListener("disconnect", onPortDisconnect);
await port.open({
baudRate: 115200,
dataBits: 8,
parity: "none",
stopBits: 1,
flowControl: "none"
});
await applySignalStrategies(port);
// Stabilize after open/signals: some CYD revisions briefly reset and re-enumerate.
await new Promise(r => setTimeout(r, 1200));
if (droppedDuringOpen || !port?.readable || !port?.writable) {
throw new Error("Serial port dropped during open stabilization");
}
// Start background read loop (does not block).
readLoop();
const info = port.getInfo();
const vid = info?.usbVendorId != null ? `0x${info.usbVendorId.toString(16).padStart(4,"0")}` : "?";
const pid = info?.usbProductId != null ? `0x${info.usbProductId.toString(16).padStart(4,"0")}` : "?";
setConnected(true);
setStatus(`Connected (VID:${vid} PID:${pid})`, "ok");
log(`Connected. VID:${vid} PID:${pid}`);
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
return;
} catch (e) {
lastError = e;
try { if (port) await port.close(); } catch (_) {}
await new Promise(r => setTimeout(r, 400));
}
}
throw new Error(`Failed to open CYD serial port: ${String(lastError?.message || lastError)}`);
}
async function disconnect() {
// Cancel the reader to break out of readLoop.
if (reader) {
try { await reader.cancel(); } catch (_) {}
}
// Cancel all pending RPCs.
for (const [id, { reject, timer }] of pending) {
clearTimeout(timer);
reject(new Error("Connection closed"));
}
pending.clear();
try {
if (port) await port.close();
} catch (_) {}
port = null;
setConnected(false);
setStatus("Disconnected");
log("Disconnected.");
}
function handleDisconnect(reason) {
if (!port) return; // already cleaned up
log("⚠", reason);
// Reject all pending RPCs.
for (const [id, { reject, timer }] of pending) {
clearTimeout(timer);
reject(new Error("Device disconnected"));
}
pending.clear();
port = null;
setConnected(false);
setStatus("Disconnected — device reset or unplugged", "err");
}
// ── Pubkey helper ─────────────────────────────────────────────────────────
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("nostr_get_public_key", params);
if (resp && typeof resp.result === "string") {
ownPubkey = resp.result.trim().toLowerCase();
pubkeyOutEl.textContent = pretty(resp);
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
nip04PeerEl.value = ownPubkey;
nip04DecPeerEl.value = ownPubkey;
nip44PeerEl.value = ownPubkey;
nip44DecPeerEl.value = ownPubkey;
}
}
}
// ── Button handlers ───────────────────────────────────────────────────────
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", String(e));
}
});
disconnectBtn.addEventListener("click", async () => {
try {
await disconnect();
} catch (e) {
log("Disconnect error:", String(e));
}
});
pubkeyBtn.addEventListener("click", async () => {
try {
await fetchOwnPubkeyAndFillPeers();
} catch (e) {
pubkeyOutEl.textContent = String(e);
}
});
signKind1Btn.addEventListener("click", async () => {
try {
let tags = [];
try {
tags = JSON.parse(kind1TagsEl.value || "[]");
if (!Array.isArray(tags)) throw new Error("tags must be an array");
} catch (e) {
throw new Error(`Invalid tags JSON: ${String(e)}`);
}
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(kind1ContentEl.value || ""),
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("nostr_sign_event", params);
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
}
});
nip04EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_encrypt", params);
nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer;
nip04CipherEl.value = resp.result;
}
} catch (e) {
nip04OutEl.textContent = String(e);
}
});
nip04DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_decrypt", params);
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
}
});
nip44EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_encrypt", params);
nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer;
nip44CipherEl.value = resp.result;
}
} catch (e) {
nip44OutEl.textContent = String(e);
}
});
nip44DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_decrypt", params);
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+1
View File
@@ -41,6 +41,7 @@
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
+527
View File
@@ -0,0 +1,527 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>n_signer Feather WebUSB Demo</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121821;
--panel-2: #182231;
--text: #e6edf3;
--muted: #9fb0c3;
--accent: #58a6ff;
--good: #3fb950;
--bad: #f85149;
--border: #263448;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.35;
}
.wrap {
max-width: 980px;
margin: 20px auto;
padding: 0 14px 24px;
}
h1 { margin: 0 0 8px; font-size: 1.45rem; }
p.note { margin: 0 0 14px; color: var(--muted); }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
margin-top: 12px;
}
.card {
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
.card h2 {
font-size: 1rem;
margin: 0 0 10px;
}
label {
font-size: 0.86rem;
color: var(--muted);
display: block;
margin: 6px 0 4px;
}
input, textarea, button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--border);
}
input, textarea {
width: 100%;
background: #0c131d;
color: var(--text);
padding: 8px 10px;
}
textarea { min-height: 84px; resize: vertical; }
input[type="number"] { max-width: 130px; }
button {
background: #1f6feb;
color: white;
padding: 8px 12px;
cursor: pointer;
border: 0;
}
button[disabled] {
opacity: 0.55;
cursor: not-allowed;
}
.secondary { background: #334155; }
.status {
padding: 6px 10px;
border-radius: 999px;
background: #2a3648;
color: var(--muted);
font-size: 0.85rem;
border: 1px solid var(--border);
}
.status.ok { color: var(--good); border-color: #2f5a3a; }
.status.err { color: var(--bad); border-color: #6a3131; }
pre {
margin: 8px 0 0;
background: #0a1018;
border: 1px solid #1b2636;
color: #d7e2ee;
padding: 10px;
border-radius: 8px;
overflow: auto;
max-height: 220px;
font-size: 12px;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
</head>
<body>
<div class="wrap">
<h1>n_signer Feather WebUSB Demo</h1>
<p class="note">Connect to the board, choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.</p>
<div class="card">
<div class="row">
<button id="connectBtn">Connect WebUSB</button>
<span id="connStatus" class="status">Disconnected</span>
</div>
<pre id="log" class="mono"></pre>
</div>
<div class="grid">
<section class="card">
<h2>Public Key</h2>
<label for="keyIndex">Key index (nostr_index)</label>
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
<div class="row" style="margin-top:10px">
<button id="pubkeyBtn" disabled>Get Public Key</button>
</div>
<pre id="pubkeyOut" class="mono"></pre>
</section>
<section class="card">
<h2>Sign Kind 1 Event</h2>
<label for="kind1Content">Content</label>
<textarea id="kind1Content">hello from feather webusb demo</textarea>
<label for="kind1Tags">Tags JSON (array)</label>
<input id="kind1Tags" value="[]" />
<div class="row" style="margin-top:10px">
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
</div>
<pre id="signOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Encrypt</h2>
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
<label for="nip04Msg">Plaintext</label>
<textarea id="nip04Msg">hello via nip04</textarea>
<div class="row" style="margin-top:10px">
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
</div>
<pre id="nip04Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-04 Decrypt</h2>
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip04Cipher">Ciphertext</label>
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
<div class="row" style="margin-top:10px">
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
</div>
<pre id="nip04DecOut" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Encrypt</h2>
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
<label for="nip44Msg">Plaintext</label>
<textarea id="nip44Msg">hello via nip44</textarea>
<div class="row" style="margin-top:10px">
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
</div>
<pre id="nip44Out" class="mono"></pre>
</section>
<section class="card">
<h2>NIP-44 Decrypt</h2>
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
<label for="nip44Cipher">Ciphertext</label>
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
<div class="row" style="margin-top:10px">
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
</div>
<pre id="nip44DecOut" class="mono"></pre>
</section>
</div>
</div>
<script type="module">
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
const logEl = document.getElementById("log");
const connStatusEl = document.getElementById("connStatus");
const connectBtn = document.getElementById("connectBtn");
const pubkeyBtn = document.getElementById("pubkeyBtn");
const signKind1Btn = document.getElementById("signKind1Btn");
const nip04EncBtn = document.getElementById("nip04EncBtn");
const nip04DecBtn = document.getElementById("nip04DecBtn");
const nip44EncBtn = document.getElementById("nip44EncBtn");
const nip44DecBtn = document.getElementById("nip44DecBtn");
const keyIndexEl = document.getElementById("keyIndex");
const pubkeyOutEl = document.getElementById("pubkeyOut");
const kind1ContentEl = document.getElementById("kind1Content");
const kind1TagsEl = document.getElementById("kind1Tags");
const signOutEl = document.getElementById("signOut");
const nip04PeerEl = document.getElementById("nip04Peer");
const nip04MsgEl = document.getElementById("nip04Msg");
const nip04OutEl = document.getElementById("nip04Out");
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
const nip04CipherEl = document.getElementById("nip04Cipher");
const nip04DecOutEl = document.getElementById("nip04DecOut");
const nip44PeerEl = document.getElementById("nip44Peer");
const nip44MsgEl = document.getElementById("nip44Msg");
const nip44OutEl = document.getElementById("nip44Out");
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
const nip44CipherEl = document.getElementById("nip44Cipher");
const nip44DecOutEl = document.getElementById("nip44DecOut");
let dev = null;
let iface = null;
let ownPubkey = "";
const EP_OUT = 1;
const EP_IN = 1;
function log(...args) {
logEl.textContent += args.join(" ") + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function setStatus(text, mode = "") {
connStatusEl.textContent = text;
connStatusEl.className = `status ${mode}`.trim();
}
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
}
function utf8(s) {
return new TextEncoder().encode(s);
}
function be32(n) {
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
}
async function sha256Hex(dataBytes) {
const h = await crypto.subtle.digest("SHA-256", dataBytes);
return hex(new Uint8Array(h));
}
function getIndexOptions() {
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
return { nostr_index: index };
}
function pretty(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function buildAuth(method, params) {
// Demo caller key only (not secret in browser demo).
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params);
const bodyHash = await sha256Hex(utf8(paramsJson));
const tags = [
["nsigner_rpc", "1"],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "webusb-demo";
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await sha256Hex(utf8(ser));
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
return {
id,
pubkey: callerPubX,
created_at: createdAt,
kind: 27235,
tags,
content,
sig: sigHex,
};
}
async function sendRpc(reqObj) {
const body = utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(be32(body.length), 0);
frame.set(body, 4);
await dev.transferOut(EP_OUT, frame);
const deadline = Date.now() + 10000;
let ring = new Uint8Array(0);
while (Date.now() < deadline) {
const r = await dev.transferIn(EP_IN, 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 framed response");
}
async function rpcCall(method, params, id = "web-1") {
const auth = await buildAuth(method, params);
const req = { jsonrpc: "2.0", id, method, params, auth };
log("→", method, JSON.stringify(params));
const resp = await sendRpc(req);
log("←", method, JSON.stringify(resp));
return resp;
}
function requirePeerHex(peer) {
const v = String(peer || "").trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(v)) {
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
}
return v;
}
function requireStringResult(resp, what) {
if (resp && typeof resp.result === "string") {
return resp.result;
}
throw new Error(`${what} failed: ${pretty(resp)}`);
}
async function fetchOwnPubkeyAndFillPeers() {
const params = [getIndexOptions()];
const resp = await rpcCall("nostr_get_public_key", params, "web-own-pubkey");
if (resp && typeof resp.result === "string") {
ownPubkey = resp.result.trim().toLowerCase();
pubkeyOutEl.textContent = pretty(resp);
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
nip04PeerEl.value = ownPubkey;
nip04DecPeerEl.value = ownPubkey;
nip44PeerEl.value = ownPubkey;
nip44DecPeerEl.value = ownPubkey;
}
}
}
async function connect() {
dev = await navigator.usb.requestDevice({ filters: [{}] });
await dev.open();
if (dev.configuration === null) {
await dev.selectConfiguration(1);
}
const intf = dev.configuration.interfaces.find(i =>
i.alternates.some(a => a.interfaceClass === 0xff)
);
if (!intf) throw new Error("No vendor WebUSB interface found");
iface = intf.interfaceNumber;
await dev.claimInterface(iface);
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
await dev.selectAlternateInterface(iface, alt.alternateSetting);
await dev.controlTransferOut({
requestType: "class",
recipient: "interface",
request: 0x22,
value: 1,
index: iface,
});
pubkeyBtn.disabled = false;
signKind1Btn.disabled = false;
nip04EncBtn.disabled = false;
nip04DecBtn.disabled = false;
nip44EncBtn.disabled = false;
nip44DecBtn.disabled = false;
setStatus(`Connected (iface ${iface})`, "ok");
log("Connected. Interface", String(iface));
try {
await fetchOwnPubkeyAndFillPeers();
log("Default peer pubkeys set to selected signer pubkey");
} catch (e) {
log("Auto pubkey fetch failed:", String(e));
}
}
connectBtn.addEventListener("click", async () => {
try {
await connect();
} catch (e) {
setStatus("Connect failed", "err");
log("Connect failed:", String(e));
}
});
pubkeyBtn.addEventListener("click", async () => {
try {
await fetchOwnPubkeyAndFillPeers();
} catch (e) {
pubkeyOutEl.textContent = String(e);
}
});
signKind1Btn.addEventListener("click", async () => {
try {
let tags = [];
try {
tags = JSON.parse(kind1TagsEl.value || "[]");
if (!Array.isArray(tags)) throw new Error("tags must be an array");
} catch (e) {
throw new Error(`Invalid tags JSON: ${String(e)}`);
}
const unsignedEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: String(kind1ContentEl.value || ""),
};
const params = [unsignedEvent, getIndexOptions()];
const resp = await rpcCall("nostr_sign_event", params, "web-sign-kind1");
signOutEl.textContent = pretty(resp?.result ?? resp);
} catch (e) {
signOutEl.textContent = String(e);
}
});
nip04EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04PeerEl.value);
const msg = String(nip04MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_encrypt", params, "web-nip04-enc");
nip04OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip04DecPeerEl.value = peer;
nip04CipherEl.value = resp.result;
}
} catch (e) {
nip04OutEl.textContent = String(e);
}
});
nip04DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip04DecPeerEl.value);
const ciphertext = String(nip04CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip04_decrypt", params, "web-nip04-dec");
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
} catch (e) {
nip04DecOutEl.textContent = String(e);
}
});
nip44EncBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44PeerEl.value);
const msg = String(nip44MsgEl.value || "");
const params = [peer, msg, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_encrypt", params, "web-nip44-enc");
nip44OutEl.textContent = pretty(resp?.result ?? resp);
if (resp && typeof resp.result === "string") {
nip44DecPeerEl.value = peer;
nip44CipherEl.value = resp.result;
}
} catch (e) {
nip44OutEl.textContent = String(e);
}
});
nip44DecBtn.addEventListener("click", async () => {
try {
const peer = requirePeerHex(nip44DecPeerEl.value);
const ciphertext = String(nip44CipherEl.value || "");
const params = [peer, ciphertext, getIndexOptions()];
const resp = await rpcCall("nostr_nip44_decrypt", params, "web-nip44-dec");
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
} catch (e) {
nip44DecOutEl.textContent = String(e);
}
});
</script>
</body>
</html>
+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>
+32 -9
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,21 +58,24 @@
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,
relays: [relayUrl, 'wss://relay.damus.io'],
methods: {
extension: true,
local: true,
readonly: true,
seedphrase: true,
connect: true, // Enables "Nostr Connect" (NIP-46)
remote: true, // Also needed for "Nostr Connect" compatibility
otp: true // Enables "DM/OTP"
},
floatingTab: {
enabled: true,
hPosition: 0.80, // 95% from left
vPosition: 0.01, // 50% from top (center)
hPosition: .98, // 95% from left
vPosition: 0, // 50% from top (center)
getUserInfo: true, // Fetch user profiles
getUserRelay: ['wss://relay.laantungir.net'], // Custom relays for profiles
appearance: {
style: 'minimal',
theme: 'auto',
@@ -88,16 +100,17 @@
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
window.addEventListener('nlMethodSelected', handleAuthEvent);
window.addEventListener('nlLogout', handleLogoutEvent);
} catch (error) {
console.log('ERROR', `Initialization failed: ${error.message}`);
}
}
function handleAuthEvent(event) {
const {pubkey, method, error } = event.detail;
const { pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
if (method && pubkey) {
@@ -108,10 +121,20 @@
} else if (error) {
console.log('ERROR', `Authentication error: ${error}`);
}
}
function handleLogoutEvent() {
console.log('INFO', 'Logout event received');
// Clear local UI state
userPubkey = null;
document.getElementById('profile-name').textContent = '';
document.getElementById('profile-about').textContent = '';
document.getElementById('profile-pubkey').textContent = '';
document.getElementById('profile-picture').src = '';
}
// Load user profile using nostr-tools pool
async function loadUserProfile() {
if (!userPubkey) return;
@@ -124,7 +147,7 @@
// Create a SimplePool instance
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
// Get profile event (kind 0) for the user using querySync
const events = await pool.querySync(relays, {
kinds: [0],
@@ -171,7 +194,7 @@
async function logout() {
console.log('INFO', 'Logging out...');
try {
await nlLite.logout();
window.NOSTR_LOGIN_LITE.logout();
console.log('SUCCESS', 'Logged out successfully');
} catch (error) {
console.log('ERROR', `Logout failed: ${error.message}`);
+1
View File
@@ -41,6 +41,7 @@
methods: {
extension: true,
local: true,
seedphrase:true,
readonly: true,
connect: true,
remote: true,
+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>
+534
View File
@@ -0,0 +1,534 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Isolation Test - NOSTR LOGIN LITE</title>
<style>
body {
font-family: 'Courier New', monospace;
margin: 20px;
background: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.status-panel {
background: #f8f9fa;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
}
.info-box {
background: #e7f3ff;
border: 2px solid #0066cc;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
}
.isolated-notice {
background: #f8d7da;
border: 2px solid #dc3545;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
}
button {
background: white;
color: black;
border: 2px solid black;
border-radius: 8px;
padding: 10px 20px;
margin: 5px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-weight: bold;
}
button:hover {
border-color: red;
}
button:active {
background: red;
color: white;
}
.mode-indicator {
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #dc3545;
background: #f8d7da;
display: inline-block;
margin: 10px 0;
}
pre {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 10px;
overflow-x: auto;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔐 Session Isolation Test</h1>
<div class="info-box">
<h3>📋 Test Instructions</h3>
<ol>
<li><strong>Isolated Session:</strong> Each tab/window has independent authentication</li>
<li>Login in this tab/window - it will persist on refresh</li>
<li>Open new windows/tabs - they will start unauthenticated</li>
<li>Login with different users in different windows simultaneously</li>
<li>Refresh any window - authentication persists within that window only</li>
</ol>
</div>
<div class="mode-indicator">
🔒 ISOLATED MODE (sessionStorage)
</div>
<div class="isolated-notice">
<strong>🚨 Session Isolation Active:</strong>
<p>This tab uses sessionStorage - authentication is isolated to this window only. Refreshing will maintain your login state, but other tabs/windows are independent.</p>
</div>
<div class="status-panel">
<h3>Authentication Status</h3>
<div id="auth-status">Not authenticated</div>
<div id="auth-details"></div>
</div>
<div>
<h3>Actions</h3>
<button onclick="login()">Login</button>
<button onclick="logout()">Logout</button>
<button onclick="checkStatus()">Check Status</button>
<button onclick="testSigning()">Test Signing</button>
<button onclick="openNewWindow()">Open New Window</button>
<button onclick="debugAuthentication()" style="border-color: orange;">Debug Auth State</button>
</div>
<div>
<h3>Storage Inspector</h3>
<button onclick="inspectStorage()">Inspect SessionStorage</button>
<button onclick="clearStorage()">Clear Session Storage</button>
<div id="storage-content"></div>
</div>
<div>
<h3>Test Results</h3>
<div id="results"></div>
</div>
</div>
<script src="../lite/nostr.bundle.js"></script>
<script src="../lite/nostr-lite.js"></script>
<script>
let nostrLiteInstance = null;
// Initialize in isolated mode (always)
initializeIsolatedMode();
async function initializeIsolatedMode() {
try {
console.log('Initializing NOSTR_LOGIN_LITE in ISOLATED mode...');
nostrLiteInstance = await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
persistence: true,
isolateSession: true, // Always isolated - each tab/window independent
methods: {
extension: true,
local: true,
readonly: true,
connect: true,
otp: true
},
floatingTab: {
enabled: true,
hPosition: 0.95,
vPosition: 0.1,
appearance: {
style: 'pill',
icon: '🔒',
text: 'ISOLATED',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: false,
showUserInfo: true,
autoSlide: true
}
}
});
checkStatus();
console.log('NOSTR_LOGIN_LITE initialized successfully in ISOLATED mode');
console.log('Authentication will persist on refresh within this tab only');
} catch (error) {
console.error('Failed to initialize NOSTR_LOGIN_LITE:', error);
document.getElementById('results').innerHTML =
`<div style="color: red;">Initialization Error: ${error.message}</div>`;
}
}
function login() {
window.NOSTR_LOGIN_LITE.launch('login');
}
function logout() {
window.NOSTR_LOGIN_LITE.logout();
setTimeout(checkStatus, 100);
}
function debugAuthentication() {
console.log('=== AUTHENTICATION DEBUG ===');
// Check global storage-based authentication state (SINGLE SOURCE OF TRUTH)
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
console.log('🔍 GLOBAL getAuthState():', authState);
console.log('🔍 Derived isAuthenticated():', !!authState);
console.log('🔍 Derived getUserInfo():', authState);
// Check window.nostr (should sync with global state)
console.log('window.nostr exists:', !!window.nostr);
console.log('window.nostr constructor:', window.nostr?.constructor?.name);
console.log('window.nostr.authState (getter):', window.nostr?.authState);
// Check NOSTR_LOGIN_LITE instance
const instance = window.NOSTR_LOGIN_LITE?._instance;
console.log('NOSTR_LOGIN_LITE instance exists:', !!instance);
console.log('Instance hasExtension:', instance?.hasExtension);
console.log('Instance facadeInstalled:', instance?.facadeInstalled);
// Check floating tab state (now queries global getAuthState() only)
const floatingTab = instance?.floatingTab;
console.log('FloatingTab exists:', !!floatingTab);
if (floatingTab) {
const tabAuthState = floatingTab._getAuthState();
console.log('FloatingTab _getAuthState():', tabAuthState);
console.log('FloatingTab derived authenticated:', !!tabAuthState);
}
// Check session storage directly
const sessionKeys = [];
const storageKey = 'nostr_login_lite_auth';
const sessionAuthData = sessionStorage.getItem(storageKey);
const localAuthData = localStorage.getItem(storageKey);
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('nl_')) {
const value = sessionStorage.getItem(key);
sessionKeys.push({ key, valueLength: value?.length || 0, hasValue: !!value });
}
}
console.log('SessionStorage nl_ keys:', sessionKeys);
console.log('SessionStorage auth data:', !!sessionAuthData);
console.log('LocalStorage auth data:', !!localAuthData);
// Display debug results
let debugHTML = '<h4>🔍 Storage-Based Authentication Debug</h4>';
debugHTML += '<div style="font-family: monospace; font-size: 12px; background: #f8f9fa; padding: 10px; border-radius: 4px;">';
debugHTML += `<strong>🎯 GLOBAL getAuthState():</strong> ${!!authState} ${authState ? `(${authState.method})` : ''}<br>`;
debugHTML += `<strong>🎯 Derived isAuthenticated():</strong> ${!!authState}<br>`;
debugHTML += `<strong>🎯 Derived getUserInfo():</strong> ${!!authState}<br>`;
debugHTML += `<strong>window.nostr exists:</strong> ${!!window.nostr} (${window.nostr?.constructor?.name})<br>`;
debugHTML += `<strong>window.nostr.authState (getter):</strong> ${!!window.nostr?.authState}<br>`;
debugHTML += `<strong>FloatingTab queries getAuthState():</strong> ${!!floatingTab?._getAuthState()}<br>`;
debugHTML += `<strong>SessionStorage 'nostr_login_lite_auth':</strong> ${!!sessionAuthData}<br>`;
debugHTML += `<strong>LocalStorage 'nostr_login_lite_auth':</strong> ${!!localAuthData}<br>`;
debugHTML += `<strong>Session storage nl_ keys:</strong> ${sessionKeys.length}<br>`;
debugHTML += `<strong>Instance hasExtension:</strong> ${instance?.hasExtension}<br>`;
debugHTML += `<strong>Facade installed:</strong> ${instance?.facadeInstalled}<br>`;
debugHTML += '</div>';
debugHTML += '<p><strong>Check the browser console for detailed debug output.</strong></p>';
debugHTML += '<p><strong>NEW Architecture:</strong> Global functions query localStorage/sessionStorage directly as single source of truth</p>';
// Check for consistency issues
const derivedAuth = !!authState;
const floatingTabAuth = !!floatingTab?._getAuthState();
if (floatingTabAuth !== derivedAuth) {
debugHTML += '<p style="color: red;"><strong>🚨 MISMATCH DETECTED:</strong> FloatingTab and global getAuthState() disagree!</p>';
debugHTML += '<p>Both should query the same storage - check implementation.</p>';
} else if (sessionAuthData && !derivedAuth) {
debugHTML += '<p style="color: orange;"><strong>⚠️ PARSING ISSUE:</strong> Session data exists but getAuthState() returns null!</p>';
debugHTML += '<p>Check getAuthState() function - it may not be parsing the stored data correctly.</p>';
} else if (!sessionAuthData && !localAuthData && derivedAuth) {
debugHTML += '<p style="color: orange;"><strong>⚠️ STORAGE ISSUE:</strong> No storage data but getAuthState() returns data!</p>';
debugHTML += '<p>getAuthState() may be reading from unexpected sources.</p>';
}
document.getElementById('results').innerHTML = debugHTML;
}
async function checkStatus() {
try {
console.log('🔍 Checking authentication status using GLOBAL functions...');
// Use the single global storage-based authentication state function
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
console.log('🔍 GLOBAL getAuthState():', authState);
console.log('🔍 Derived isAuthenticated():', !!authState);
console.log('🔍 Derived getUserInfo():', authState);
console.log('🔍 window.nostr:', !!window.nostr);
console.log('🔍 window.nostr.constructor:', window.nostr?.constructor?.name);
// Check storage directly for debugging
const storageKey = 'nostr_login_lite_auth';
const sessionAuthData = sessionStorage.getItem(storageKey);
const localAuthData = localStorage.getItem(storageKey);
console.log('🔍 sessionStorage auth data:', !!sessionAuthData);
console.log('🔍 localStorage auth data:', !!localAuthData);
if (authState) {
let pubkey = null;
try {
if (window.nostr) {
pubkey = await window.nostr.getPublicKey();
} else if (authState.pubkey) {
pubkey = authState.pubkey;
}
} catch (err) {
console.warn('Could not get pubkey:', err.message);
pubkey = authState.pubkey;
}
const method = authState.method;
console.log('✅ Authentication detected via GLOBAL functions - method:', method, 'pubkey:', pubkey?.slice(0, 8) + '...');
document.getElementById('auth-status').innerHTML =
`<strong style="color: green;">✅ Authenticated (Session Isolated)</strong>`;
document.getElementById('auth-details').innerHTML =
`<strong>Method:</strong> ${method}<br>
<strong>Public Key:</strong> ${pubkey ? `${pubkey.slice(0, 16)}...${pubkey.slice(-8)}` : 'Available in authState'}<br>
<strong>Storage:</strong> ${sessionAuthData ? 'sessionStorage' : 'localStorage'} (${sessionAuthData ? 'isolated to this tab' : 'shared across tabs'})<br>
<strong>Persistence:</strong> Survives refresh${sessionAuthData ? ', isolated from other tabs' : ', shared with other tabs'}<br>
<strong>Debug:</strong> Global getAuthState() returns valid data`;
} else if (sessionAuthData || localAuthData) {
// We have storage data but getAuthState() returns null
console.log('⚠️ Storage data exists but getAuthState() returns null');
document.getElementById('auth-status').innerHTML =
`<strong style="color: orange;">⚠️ Authentication data found but not parsed</strong>`;
document.getElementById('auth-details').innerHTML =
`<strong>Storage:</strong> ${sessionAuthData ? 'sessionStorage' : 'localStorage'} has authentication data<br>
<strong>Issue:</strong> getAuthState() returns null<br>
<strong>Debug:</strong> Storage data: session=${!!sessionAuthData}, local=${!!localAuthData}<br>
<strong>Solution:</strong> Check getAuthState() function implementation`;
} else {
console.log('❌ No authentication detected via getAuthState()');
document.getElementById('auth-status').innerHTML =
`<strong style="color: red;">❌ Not authenticated</strong>`;
document.getElementById('auth-details').innerHTML =
`<strong>Storage:</strong> sessionStorage (isolated to this tab)<br>
<strong>Status:</strong> Ready for login - will persist on refresh<br>
<strong>Debug:</strong> getAuthState() returns no authentication data`;
}
} catch (error) {
console.error('❌ Error checking status:', error);
document.getElementById('auth-status').innerHTML =
`<strong style="color: orange;">⚠️ Error checking status</strong>`;
document.getElementById('auth-details').innerHTML =
`Error: ${error.message}<br>
<strong>Debug:</strong> Check browser console for details`;
}
}
async function testSigning() {
try {
// Use global authentication state to check if authenticated
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
if (!authState) {
throw new Error('Not authenticated (checked via global getAuthState())');
}
if (!window.nostr) {
throw new Error('window.nostr not available for signing');
}
const event = {
kind: 1,
content: `Test message from ISOLATED session - ${new Date().toISOString()}`,
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
const signedEvent = await window.nostr.signEvent(event);
document.getElementById('results').innerHTML =
`<h4>✅ Signing Test Successful (Session Isolated)</h4>
<p>This signature was created using the storage-based authentication system.</p>
<p><strong>Authentication Method:</strong> getAuthState() confirmed authentication before signing</p>
<pre>${JSON.stringify(signedEvent, null, 2)}</pre>`;
} catch (error) {
document.getElementById('results').innerHTML =
`<h4>❌ Signing Test Failed</h4>
<p style="color: red;">${error.message}</p>
<p><strong>Debug Info:</strong></p>
<ul>
<li>getAuthState(): ${!!window.NOSTR_LOGIN_LITE.getAuthState()}</li>
<li>window.nostr exists: ${!!window.nostr}</li>
<li>Auth method: ${JSON.stringify(window.NOSTR_LOGIN_LITE.getAuthState()?.method || null)}</li>
</ul>`;
}
}
function openNewWindow() {
const newWindow = window.open(
window.location.href,
'_blank',
'width=900,height=700,scrollbars=yes,resizable=yes'
);
if (newWindow) {
document.getElementById('results').innerHTML =
`<h4>🪟 New Window Opened - Independent Session</h4>
<p><strong>Session Isolation Test:</strong></p>
<ol>
<li>The new window starts unauthenticated (independent session)</li>
<li>Login in the new window with a different method or user</li>
<li>Both windows maintain separate authentication states</li>
<li>Refresh either window - authentication persists within that window only</li>
<li>Close a window - its authentication is lost (sessionStorage cleared)</li>
</ol>
<p><strong>Expected Behavior:</strong> Each window/tab has completely independent authentication that persists on refresh but doesn't leak to other windows.</p>`;
} else {
document.getElementById('results').innerHTML =
`<h4>❌ Failed to Open Window</h4>
<p>Please allow popups and try again</p>`;
}
}
function inspectStorage() {
const sessionStorage_keys = [];
// Inspect sessionStorage (our isolated storage)
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('nl_')) {
sessionStorage_keys.push({
key,
value: sessionStorage.getItem(key)
});
}
}
let content = '<h4>📊 Session Storage Inspection</h4>';
content += '<p><strong>Note:</strong> This tab uses sessionStorage for isolation - data here is independent of other tabs/windows.</p>';
content += '<h5>sessionStorage (This tab only):</h5>';
if (sessionStorage_keys.length === 0) {
content += '<p style="color: #666;">No authentication data found in this session</p>';
} else {
content += '<p style="color: green;">✅ Authentication data found (persists on refresh)</p>';
content += '<pre>' + JSON.stringify(sessionStorage_keys, null, 2) + '</pre>';
}
// Show what would be in localStorage if we weren't using isolation
const localStorage_keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('nl_')) {
localStorage_keys.push(key);
}
}
content += '<h5>localStorage (Not used in isolated mode):</h5>';
if (localStorage_keys.length === 0) {
content += '<p style="color: #666;">No NOSTR_LOGIN_LITE data (expected in isolated mode)</p>';
} else {
content += '<p style="color: orange;">⚠️ Found some data - might be from non-isolated sessions</p>';
}
document.getElementById('storage-content').innerHTML = content;
}
function clearStorage() {
// Clear only sessionStorage (our isolated storage)
const sessionKeys = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (key && key.startsWith('nl_')) {
sessionKeys.push(key);
}
}
sessionKeys.forEach(key => sessionStorage.removeItem(key));
document.getElementById('storage-content').innerHTML =
`<h4>🧹 Session Storage Cleared</h4>
<p>Removed ${sessionKeys.length} authentication items from this tab's sessionStorage</p>
<p><strong>Result:</strong> This tab is now logged out, but other tabs are unaffected</p>`;
// Update status
setTimeout(checkStatus, 100);
}
// Listen for authentication events
window.addEventListener('nlMethodSelected', (event) => {
console.log('Authentication successful in isolated session:', event.detail);
setTimeout(checkStatus, 100);
document.getElementById('results').innerHTML =
`<h4>✅ Authentication Successful (Session Isolated)</h4>
<p><strong>Method:</strong> ${event.detail.method}</p>
<p><strong>Storage:</strong> sessionStorage (isolated to this tab)</p>
<p><strong>Persistence:</strong> Will survive refresh, won't affect other tabs</p>
<p><strong>Test:</strong> Open a new tab - it should start unauthenticated</p>`;
});
window.addEventListener('nlLogout', (event) => {
console.log('Logout detected in isolated session:', event.detail);
setTimeout(checkStatus, 100);
document.getElementById('results').innerHTML =
`<h4>👋 Logged Out (Session Isolated)</h4>
<p>Authentication cleared from this tab's sessionStorage only</p>
<p><strong>Result:</strong> Other tabs remain unaffected by this logout</p>`;
});
// Check status on page load (should restore from sessionStorage if available)
window.addEventListener('load', () => {
setTimeout(checkStatus, 500);
// Show persistence message if we're restoring authentication
if (sessionStorage.getItem('nl_auth_state') || sessionStorage.getItem('nl_current')) {
setTimeout(() => {
document.getElementById('results').innerHTML =
`<h4>🔄 Session Restored</h4>
<p>Authentication state restored from sessionStorage on page load</p>
<p><strong>Isolation confirmed:</strong> This tab's login state is independent</p>`;
}, 1000);
}
});
</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}"
-1361
View File
File diff suppressed because it is too large Load Diff
-3293
View File
File diff suppressed because it is too large Load Diff
-1831
View File
File diff suppressed because it is too large Load Diff
+413
View File
@@ -0,0 +1,413 @@
# NOSTR_LOGIN_LITE - Login Logic Analysis
This document explains the complete login and authentication flow for the NOSTR_LOGIN_LITE library, including how state is maintained upon page refresh.
## System Architecture Overview
The library uses a **modular authentication architecture** with these key components:
1. **FloatingTab** - UI component for login trigger and status display
2. **Modal** - UI component for authentication method selection
3. **NostrLite** - Main library coordinator and facade manager
4. **WindowNostr** - NIP-07 compliant facade for non-extension methods
5. **AuthManager** - Persistent state management with encryption
6. **Extension Bridge** - Browser extension detection and management
## Authentication Flow Diagrams
### Initial Page Load Flow
```
┌─────────────────────┐
│ Page Loads │
└─────────┬───────────┘
┌─────────────────────┐
│ NOSTR_LOGIN_LITE │
│ .init() called │
└─────────┬───────────┘
┌─────────────────────┐ YES ┌─────────────────────┐
│ Real extension │──────────▶│ Extension-First │
│ detected? │ │ Mode: Don't install │
└─────────┬───────────┘ │ facade │
│ NO └─────────────────────┘
┌─────────────────────┐
│ Install WindowNostr │
│ facade for local/ │
│ NIP-46/readonly │
└─────────┬───────────┘
┌─────────────────────┐ YES ┌─────────────────────┐
│ Persistence │──────────▶│ _attemptAuthRestore │
│ enabled? │ │ called │
└─────────┬───────────┘ └─────────┬───────────┘
│ NO │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Initialization │ │ Check storage for │
│ complete │ │ saved auth state │
└─────────────────────┘ └─────────┬───────────┘
┌─────────────────────┐ YES
│ Valid auth state │────────┐
│ found? │ │
└─────────┬───────────┘ │
│ NO │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Show login UI │ │ Restore auth & │
│ (FloatingTab,etc) │ │ dispatch events │
└─────────────────────┘ └─────────────────────┘
```
### User-Initiated Login Flow
```
┌─────────────────────┐ ┌─────────────────────┐
│ User clicks │ │ User clicks │
│ FloatingTab │ │ Login Button │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
▼ ▼
┌─────────────────────┐ │
│ Extension │ │
│ available? │ │
└─────────┬───────────┘ │
│ YES │
▼ │
┌─────────────────────┐ │
│ Auto-try extension │ │
│ authentication │ │
└─────────┬───────────┘ │
│ SUCCESS │
▼ │
┌─────────────────────┐ │
│ Authentication │ │
│ complete │◀──────────────────┘
└─────────────────────┘ │ FAIL OR ALWAYS
┌─────────────────────┐
│ Open Modal with │
│ method selection: │
│ • Extension │
│ • Local Key │
│ • NIP-46 Connect │
│ • Read-only │
│ • OTP/DM │
└─────────┬───────────┘
┌─────────────────────┐
│ User selects method │
│ and completes auth │
└─────────┬───────────┘
┌─────────────────────┐
│ Authentication │
│ complete │
└─────────────────────┘
```
### Authentication Storage & Persistence Flow
```
┌─────────────────────┐
│ Authentication │
│ successful │
└─────────┬───────────┘
┌─────────────────────┐
│ nlMethodSelected │
│ event dispatched │
└─────────┬───────────┘
┌─────────────────────┐ Extension? ┌─────────────────────┐
│ AuthManager. │─────────────────▶│ Store verification │
│ saveAuthState() │ │ data only (no │
└─────────┬───────────┘ │ secrets) │
│ Local Key? └─────────────────────┘
┌─────────────────────┐
│ Encrypt secret key │
│ with session │
│ password + AES-GCM │
└─────────┬───────────┘
│ NIP-46?
┌─────────────────────┐
│ Store connection │
│ parameters (no │
│ secrets) │
└─────────┬───────────┘
│ Read-only?
┌─────────────────────┐
│ Store method only │
│ (no secrets) │
└─────────┬───────────┘
┌─────────────────────┐ isolateSession? ┌─────────────────────┐
│ Choose storage: │─────────YES─────────▶│ sessionStorage │
│ localStorage vs │ │ (per-window) │
│ sessionStorage │◀────────NO───────────┤ │
└─────────┬───────────┘ └─────────────────────┘
┌─────────────────────┐
│ localStorage │
│ (cross-window) │
└─────────────────────┘
```
## Key Decision Points and Logic
### 1. Extension Detection Logic (Line 994-1046)
**Function:** `NostrLite._isRealExtension(obj)`
```javascript
// Conservative extension detection
if (!obj || typeof obj !== 'object') return false;
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') return false;
// Exclude our own classes
const constructorName = obj.constructor?.name;
if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') return false;
if (obj === window.NostrTools) return false;
// Look for extension indicators
const extensionIndicators = [
'_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope',
'_requests', '_pubkey', 'name', 'version', 'description'
];
const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop));
const hasExtensionConstructor = constructorName &&
constructorName !== 'Object' &&
constructorName !== 'Function';
return hasIndicators || hasExtensionConstructor;
```
### 2. Facade Installation Decision (Line 942-972)
**Function:** `NostrLite._setupWindowNostrFacade()`
```
Extension detected? ──YES──▶ DON'T install facade
Store reference for persistence
NO
Install WindowNostr facade ──▶ Handle local/NIP-46/readonly methods
```
### 3. FloatingTab Click Behavior (Line 351-369)
**Current UX Inconsistency Issue:**
```javascript
async _handleClick() {
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
this._showUserMenu(); // Show user options
} else {
// INCONSISTENCY: Auto-tries extension instead of opening modal
if (window.nostr && this._isRealExtension(window.nostr)) {
await this._tryExtensionLogin(window.nostr); // Automatic extension attempt
} else {
if (this.modal) {
this.modal.open({ startScreen: 'login' }); // Fallback to modal
}
}
}
}
```
**Comparison with Login Button behavior:**
- Login Button: **Always** opens modal for user choice
- FloatingTab: **Auto-tries extension first**, only shows modal if denied
### 4. Authentication Restoration on Page Refresh
**Two-Path System:**
#### Path 1: Extension Mode (Line 1115-1173)
```javascript
async _attemptExtensionRestore() {
const authManager = new AuthManager({ isolateSession: this.options?.isolateSession });
const storedAuth = await authManager.restoreAuthState();
if (!storedAuth || storedAuth.method !== 'extension') return null;
// Verify extension still works with same pubkey
if (!window.nostr || !this._isRealExtension(window.nostr)) return null;
const currentPubkey = await window.nostr.getPublicKey();
if (currentPubkey !== storedAuth.pubkey) return null;
// Dispatch nlAuthRestored event for UI updates
window.dispatchEvent(new CustomEvent('nlAuthRestored', { detail: extensionAuth }));
}
```
#### Path 2: Non-Extension Mode (Line 1080-1098)
```javascript
// Uses facade's restoreAuthState method
if (this.facadeInstalled && window.nostr?.restoreAuthState) {
const restoredAuth = await window.nostr.restoreAuthState();
if (restoredAuth) {
// Handle NIP-46 reconnection if needed
if (restoredAuth.requiresReconnection) {
this._showReconnectionPrompt(restoredAuth);
}
}
}
```
### 5. Storage Strategy (Line 1408-1414)
**Storage Type Selection:**
```javascript
if (options.isolateSession) {
this.storage = sessionStorage; // Per-window isolation
} else {
this.storage = localStorage; // Cross-window persistence
}
```
### 6. Event-Driven State Synchronization
**Key Events:**
- `nlMethodSelected` - Dispatched when user completes authentication
- `nlAuthRestored` - Dispatched when authentication is restored from storage
- `nlLogout` - Dispatched when user logs out
- `nlReconnectionRequired` - Dispatched when NIP-46 needs reconnection
**Event Listeners:**
- FloatingTab listens to all auth events for UI updates (Line 271-295)
- WindowNostr listens to nlMethodSelected/nlLogout for state management (Line 823-869)
## State Persistence Security Model
### By Authentication Method:
**Extension:**
- ✅ Store: pubkey, verification metadata
- ❌ Never store: extension object, secrets
- 🔒 Security: Minimal data, 1-hour expiry
**Local Key:**
- ✅ Store: encrypted secret key, pubkey
- 🔒 Security: AES-GCM encryption with session-specific password
- 🔑 Session password stored in sessionStorage (cleared on tab close)
**NIP-46:**
- ✅ Store: connection parameters, pubkey
- ❌ Never store: session secrets
- 🔄 Requires: User reconnection on restore
**Read-only:**
- ✅ Store: method type, pubkey
- ❌ No secrets to store
## Current Issues Identified
### UX Inconsistency (THE MAIN ISSUE)
**Problem:** FloatingTab and Login Button have different click behaviors
- **FloatingTab:** Auto-tries extension → Falls back to modal if denied
- **Login Button:** Always opens modal for user choice
**Impact:**
- Confusing user experience
- Inconsistent interaction patterns
- Users don't get consistent choice of authentication method
**Root Cause:** Line 358-367 in FloatingTab._handleClick() method
### Proposed Solutions:
#### Option 1: Make FloatingTab Consistent (Recommended)
```javascript
async _handleClick() {
if (this.isAuthenticated && this.options.behavior.showUserInfo) {
this._showUserMenu();
} else {
// Always open modal - consistent with login button
if (this.modal) {
this.modal.open({ startScreen: 'login' });
}
}
}
```
#### Option 2: Add Configuration Option
```javascript
floatingTab: {
behavior: {
autoTryExtension: false, // Default to consistent behavior
// ... other options
}
}
```
## ⚠️ IMPLEMENTATION STATUS: READY FOR CODE CHANGES
**User Decision:** FloatingTab should behave exactly like login buttons - always open modal for authentication method selection.
**Required Changes:**
1. **File:** `lite/build.js`
2. **Method:** `FloatingTab._handleClick()` (lines 351-369)
3. **Action:** Remove extension auto-detection, always open modal
**Current Code to Replace (lines 358-367):**
```javascript
// Check if extension is available for direct login
if (window.nostr && this._isRealExtension(window.nostr)) {
console.log('FloatingTab: Extension available, attempting direct extension login');
await this._tryExtensionLogin(window.nostr);
} else {
// Open login modal
if (this.modal) {
this.modal.open({ startScreen: 'login' });
}
}
```
**Replacement Code:**
```javascript
// Always open login modal (consistent with login buttons)
if (this.modal) {
this.modal.open({ startScreen: 'login' });
}
```
**Critical Safety Notes:**
-**DO NOT** change `_checkExistingAuth()` method (lines 299-349) - this handles automatic restoration on page refresh
-**ONLY** change the click handler to remove manual extension detection
- ✅ Authentication restoration will continue to work properly via the separate restoration system
- ✅ Extension detection logic remains intact for other purposes (storage, verification, etc.)
**After Implementation:**
- Rebuild the library with `node lite/build.js`
- Test that both floating tab and login buttons behave identically
- Verify that automatic login restoration on page refresh still works properly
## Important Notes
1. **Extension-First Architecture:** The system never interferes with real browser extensions
2. **Dual Storage Support:** Supports both per-window (sessionStorage) and cross-window (localStorage) persistence
3. **Security-First:** Sensitive data is always encrypted or not stored
4. **Event-Driven:** All components communicate via custom events
5. **Automatic Restoration:** Authentication state is automatically restored on page refresh when possible
The login logic is complex due to supporting multiple authentication methods, security requirements, and different storage strategies, but it provides a flexible and secure authentication system for Nostr applications.
+10
View File
@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"liveServer.settings.port": 5501
}
}
+154
View File
@@ -0,0 +1,154 @@
# CYD Web Serial signer integration
Add support for the new n_signer hardware board (CYD: ESP32-2432S028, the resistive-touch ILI9341 board) to `nostr_login_lite`, alongside the existing Feather WebUSB signer.
## Background
The existing [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) talks to the Feather S3 board over **WebUSB** because the Feather has a native USB peripheral, enumerates with VID `0x303a`, and exposes a vendor-class interface (class 0xFF) plus a control transfer (`0x22, value=1`) that switches the device into WebUSB mode.
The new CYD board does **not** have native USB. The ESP32 talks to the host through a CH340 (or, on some revisions, a CP2102) USB-to-UART bridge chip, so the host enumerates a plain CDC serial device. WebUSB cannot reach it; the Web Serial API (`navigator.serial`) can.
On the firmware side the protocol is unchanged: both Feather and CYD speak the same 4-byte big-endian length-prefixed JSON-RPC framing — only the underlying byte stream is different (USB bulk endpoint vs. UART byte stream). All RPC semantics (auth envelope kind 27235, nsigner_rpc / nsigner_method / nsigner_body_hash tags, `get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) are identical.
## Goals
1. Add `NSignerWebSerial` to `src/signers/` with the same public API as [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) so the rest of `nostr_login_lite` (modal, login flow, build pipeline) is agnostic to which board is connected.
2. Add a `cyd_webserial_demo.html` example mirroring [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html), so a user can manually verify `get_public_key`, `sign_event`, NIP-04 and NIP-44 roundtrips against the CYD without booting the full SDK.
3. Update the login modal so the user can pick "Feather (WebUSB)" or "CYD (Serial)" at connect time.
4. Bundle the new signer into `nostr_login_lite.js` via `build.js`.
5. Document browser support, CH340 driver setup, and DTR/RTS behavior.
## Non-goals
- Touching the firmware: the CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already exposes the correct protocol on UART0 at 115200 8N1 with no flow control.
- Replacing the existing `NSignerWebUSB`. Both transports coexist; users with Feather hardware continue to use WebUSB.
- Supporting Firefox/Safari. Web Serial (and WebUSB) are Chromium-only. This is a known limitation, not a regression.
## Transport mapping
| Concern | WebUSB (Feather) | Web Serial (CYD) |
|---|---|---|
| Device picker | `navigator.usb.requestDevice({filters:[{vendorId:0x303a}]})` | `navigator.serial.requestPort({filters:[{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]})` |
| Open | `dev.open(); selectConfiguration(1); claimInterface; selectAlternateInterface; controlTransferOut(req=0x22, value=1)` | `port.open({baudRate:115200, dataBits:8, parity:"none", stopBits:1, flowControl:"none"})` |
| DTR/RTS | n/a | `port.setSignals({dataTerminalReady:false, requestToSend:false})` immediately after open to avoid pulsing EN/IO0 (which would reset the ESP32 or put it in download mode) |
| Already-paired discovery | `navigator.usb.getDevices()` | `navigator.serial.getPorts()` |
| Write | `dev.transferOut(EP_OUT, frame)` | `writer = port.writable.getWriter(); await writer.write(frame); writer.releaseLock()` |
| Read | `dev.transferIn(EP_IN, 512)` returning `DataView` | `reader = port.readable.getReader(); { value, done } = await reader.read(); reader.releaseLock()` (or hold the reader for the lifetime of the connection — see below) |
| Disconnect event | `navigator.usb.addEventListener('disconnect', ...)` | `port.addEventListener('disconnect', ...)` plus the reader stream throwing on unplug |
| Close | `releaseInterface(); device.close()` | `await reader.cancel(); await port.close()` |
Framing protocol is identical: `[4-byte big-endian length][JSON body]`. The CYD firmware in [`uart_transport.c`](../../../n_signer/firmware/cyd_esp32_2432s028/main/uart_transport.c) already tolerates leading non-frame bytes (early boot logs) by sliding the parser one byte at a time when the length header is invalid, so the client doesn't need to coordinate boot timing.
## Design
### `NSignerWebSerial` class
File: `src/signers/nsigner-webserial.js`.
Public API mirrors [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) exactly:
```text
static FILTERS // [{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]
static async requestAndConnect(options) // picker -> open -> return driver
static async getPairedDevice(options) // navigator.serial.getPorts() -> first matching
static randomSecretHex() // unchanged from WebUSB version
static toPubkeyHex(secretHex) // unchanged
constructor(port, { callerSecretKey, nostrIndex })
get isOpen, vendorId, productId, serial
onDisconnect(cb)
async open()
async close()
async getPublicKey()
async signEvent(unsignedEvent)
async nip04Encrypt(peerHex, plaintext)
async nip04Decrypt(peerHex, ciphertext)
async nip44Encrypt(peerHex, plaintext)
async nip44Decrypt(peerHex, ciphertext)
```
Internal differences from the WebUSB class:
1. **Constructor takes a `SerialPort`** (from `navigator.serial`) rather than a `USBDevice`.
2. **`vendorId` / `productId` / `serial`** come from `port.getInfo()` (`usbVendorId`, `usbProductId`). `serial` is generally unavailable on Web Serial — keep the field for API parity but expect `null`.
3. **Single long-lived reader.** Web Serial's reader, unlike WebUSB's `transferIn` calls, owns the readable stream for as long as it's locked. The cleanest pattern is:
- On `open()`, start a background `_readLoop()` task that acquires the reader once, accumulates bytes into a ring buffer, parses frames, and resolves the pending RPC promise keyed by `req.id`.
- `_sendRpc()` registers `{id, resolve, reject, timer}` in a `Map`, writes the frame via the writer, and awaits the registered promise.
- On `close()`, call `reader.cancel()` to break out of the loop, then `port.close()`.
4. **DTR/RTS handling.** Right after `port.open()`, call `port.setSignals({dataTerminalReady: false, requestToSend: false})`. The CH340's RTS/DTR are wired to the ESP32 EN/IO0 reset/boot pins through a small transistor network on the CYD; the auto-reset-into-bootloader sequence triggers when esptool toggles them in a specific pattern. We never want either asserted during normal operation. Test empirically — if it turns out the CYD revision in use ignores them, we leave the call as a defensive no-op.
5. **Reconnect detection.** The `disconnect` event on the port fires when the user unplugs the cable; our loop's `reader.read()` will resolve `{done:true}` shortly after. Both paths should call the registered `_disconnectHandlers`.
6. **Frame parser.** Identical to the WebUSB ring-buffer parser, including the "slide one byte and retry" recovery when the length header is invalid (e.g., during the CYD bootloader's early-boot stdout noise).
7. **Auth envelope construction.** Reuse the kind-27235 builder verbatim. Extract `_buildAuth`, `_be32`, `_hex`, `_hexToBytes`, `_sha256Hex`, `_utf8` into a shared helper module (e.g. `src/signers/_auth.js`) so both classes import the same code rather than duplicating it. Optional polish — not required for the first cut.
### Standalone demo: `examples/cyd_webserial_demo.html`
Copy [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html). Replace only:
- Title and intro text ("n_signer CYD Web Serial Demo").
- `connect()` function: swap WebUSB calls for Web Serial as per the transport mapping above.
- The `sendRpc()` loop: use the long-lived reader pattern.
- A small "Disconnect" button that calls `port.close()` cleanly.
Keep all the auth-envelope code, kind 1 signer, NIP-04/44 sections unchanged.
### Modal / login flow
In `src/ui/modal.js`, where the WebUSB option is rendered, add a sibling "CYD (Serial)" option. On click, instantiate `NSignerWebSerial` instead of `NSignerWebUSB`. The downstream code already uses the common interface so no further plumbing should be needed.
### Build pipeline
In `build.js`, add `src/signers/nsigner-webserial.js` (and the shared `_auth.js` if extracted) to the concatenation list, exposing `window.NSignerWebSerial` so non-bundled consumers can use it directly the same way [`window.NSignerWebUSB`](../src/signers/nsigner-webusb.js:355) is exposed today.
## DTR/RTS / CH340 caveats
- The CH340 datasheet specifies DTR and RTS are open-collector outputs. On the CYD, these tie through transistors to the ESP32 EN (reset) and IO0 (boot mode) pins respectively. esptool relies on this circuit for one-click flashing.
- Linux's `cdc-acm` driver (well, the `ch341` kernel driver) toggles DTR/RTS on `open(2)` by default, which is why naively running `cat /dev/ttyUSB0` resets the board. Web Serial does NOT do this automatically as far as Chromium's implementation goes — but to be safe we explicitly set both to `false` after open.
- If the board resets on connect anyway, mitigations:
- Add a 10 µF capacitor between EN and GND (a common hardware fix; out of scope for this plan).
- In software, after `port.open()`, immediately call `setSignals({dataTerminalReady:false, requestToSend:false})`, then wait ~200 ms before doing anything else, then drain and discard any pending bytes (since the firmware will spew boot logs).
- If the user does see a reset, the client should be resilient: a reset means `port.readable` will close, the disconnect event fires, but the OS-level serial port may still exist. The client should NOT try to reopen automatically; surface "device reset, please reconnect" to the user.
## Browser support and udev
- **Web Serial**: Chrome 89+, Edge 89+, Brave, Opera. Not in Firefox or Safari.
- **Linux**: the user's account must be in `dialout` (Debian/Ubuntu) or `uucp` (Arch) for unprivileged access. ChromeOS exposes serial ports via permission prompts. macOS and Windows: no extra setup beyond the CH340/CP2102 driver.
- **macOS CH340 driver**: WCH-IC ships a driver for older macOS; macOS 11+ has an in-tree driver but it sometimes conflicts with old kexts. Document this in the README.
- **Windows CH340 driver**: WCH-IC's official driver is required on Windows 10/11 (the in-box `usbser` driver does not auto-bind to all CH340 PID variants).
## Validation
1. Open `cyd_webserial_demo.html` in Chrome, click Connect, pick the CYD port.
2. Verify "Connected" status appears; auto-fetch returns the device's `pubkey` (64-hex).
3. Sign a kind 1 event — on the CYD's touchscreen the approval prompt should appear; tap "Approve" — the demo logs the signed event.
4. Tap "Always" once for nip04_encrypt, run an encrypt/decrypt roundtrip, then for nip44. Confirm second invocation of the same method skips the approval prompt due to `Always` caching on-device.
5. Tap "Deny" on one — confirm the client receives a deny error.
6. Unplug the cable — confirm the modal/SDK fires `onDisconnect`.
## Risks / open questions
- **CH340 vs CP2102 hardware revisions.** The two known VID/PID pairs cover most CYDs; if a third variant appears (e.g., FTDI FT232R, `0x0403:0x6001`), add a third filter entry. Web Serial allows multiple filters in one `requestPort` call so the picker shows any matching device.
- **`SerialPort.getInfo()`** on some Chrome versions returns `null` vendor/product IDs on Linux when the kernel driver doesn't expose them through `/sys/`. The fallback is to show all ports in the picker (no filter) — slightly worse UX but functional.
- **No serial number** is exposed by CH340/CP2102 chips on CYDs, so the "remember this device" UX from `NSignerWebUSB.getPairedDevice` will only be able to match on VID/PID, not serial. If multiple CYDs are connected, the user picks each time.
- **Concurrent access.** Only one tab can open a given serial port at a time. If the user has the Arduino IDE serial monitor or `idf.py monitor` running on the same port, Web Serial's `port.open()` will throw `NetworkError`. Document this.
## Phasing
| Phase | Deliverable |
|---|---|
| 1 | `cyd_webserial_demo.html` — standalone, no SDK. Lets us validate the transport works end-to-end against the CYD firmware. |
| 2 | `src/signers/nsigner-webserial.js` — class with full API parity. |
| 3 | Modal integration in `src/ui/modal.js`; build pipeline update in `build.js`. |
| 4 | Documentation: README updates (browser support, driver setup, udev rule note), plus a short user-facing note in the n_signer-side `firmware/README.md` linking to the new demo. |
Phase 1 is the recommended first step because the firmware is already done — landing a working demo verifies the transport and unblocks Phase 2 with a known-good wire format to imitate.
## Acceptance criteria
- `cyd_webserial_demo.html` performs a full get_public_key / sign_event / nip04 roundtrip / nip44 roundtrip against a CYD device.
- `NSignerWebSerial` passes the same smoke tests as `NSignerWebUSB`, with the same public surface (so any existing call site that takes either driver works without changes).
- Login modal renders both transport options and produces an equivalent session for either path.
- The Feather WebUSB path continues to work unchanged.
## Question raised by the user
> Is our new board ready to work with `nostr_login_lite` once Phase 13 land?
**Yes.** The CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already implements the full JSON-RPC surface (`get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) over UART0, with the same auth envelope and approval UI as the Feather. Once `NSignerWebSerial` exists and the modal offers it, `nostr_login_lite` will treat the CYD as a fully-supported signer. The only remaining gating items are the optional polish in this plan (touch calibration persistence and the CYD-side flash helper script — both are out of scope here and tracked on the n_signer firmware side).
+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.22
+2676
View File
File diff suppressed because it is too large Load Diff
+489
View File
@@ -0,0 +1,489 @@
class NSignerWebSerial {
// Keep Web Serial chooser unfiltered so users can select any serial port.
static FILTERS = [];
static async requestAndConnect(options = {}) {
if (!('serial' in navigator)) {
throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)');
}
const port = NSignerWebSerial.FILTERS.length
? await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS })
: await navigator.serial.requestPort();
const driver = new NSignerWebSerial(port, options);
await driver.open();
return driver;
}
static async getPairedDevice(options = {}) {
if (!('serial' in navigator)) return null;
const ports = await navigator.serial.getPorts();
if (!ports || ports.length === 0) return null;
const vendorId = options.vendorId ?? null;
const productId = options.productId ?? null;
const match = ports.find(p => {
const info = p.getInfo();
if (vendorId !== null && info?.usbVendorId !== vendorId) return false;
if (productId !== null && info?.usbProductId !== productId) return false;
return true;
});
return match || null;
}
// Identical helpers to NSignerWebUSB — kept here so the class is self-contained.
static randomSecretHex() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return NSignerWebSerial._hex(bytes);
}
static toPubkeyHex(secretHex) {
const secret = NSignerWebSerial._hexToBytes(secretHex);
const nt = window.NostrTools || {};
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
const pub = nt.schnorr.getPublicKey(secret);
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
}
if (typeof nt.getPublicKey === 'function') {
const pub = nt.getPublicKey(secret);
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub);
}
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
}
constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) {
if (!port) throw new Error('SerialPort is required');
if (!callerSecretKey) throw new Error('callerSecretKey is required');
this._port = port;
this.callerSecretKey = callerSecretKey;
this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0;
this._reader = null;
this._readLoopPromise = null;
this._ring = new Uint8Array(0);
this._pending = new Map(); // id -> { resolve, reject, timer }
this._disconnectHandlers = new Set();
this._rpcCounter = 0;
this._open = false;
this._opening = false;
this._sawDisconnectDuringOpen = false;
this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200);
this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length
? signalStrategies
: [
{ dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' },
{ dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' }
];
// Bound port-level disconnect listener.
this._boundPortDisconnect = () => {
if (this._opening) this._sawDisconnectDuringOpen = true;
this._handleDisconnect();
};
}
get isOpen() {
return this._open;
}
get vendorId() {
return this._port?.getInfo()?.usbVendorId ?? null;
}
get productId() {
return this._port?.getInfo()?.usbProductId ?? null;
}
// Web Serial / CH340 / CP2102 do not expose a serial number.
get serial() {
return null;
}
onDisconnect(cb) {
if (typeof cb === 'function') this._disconnectHandlers.add(cb);
return () => this._disconnectHandlers.delete(cb);
}
async open() {
const originalInfo = this._port?.getInfo?.() || {};
let lastError = null;
for (let attempt = 0; attempt < 2; attempt++) {
this._opening = true;
this._sawDisconnectDuringOpen = false;
try {
if (attempt > 0) {
const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo);
if (replacement) this._port = replacement;
}
await this._port.open({
baudRate: 115200,
dataBits: 8,
parity: 'none',
stopBits: 1,
flowControl: 'none'
});
this._port.addEventListener('disconnect', this._boundPortDisconnect);
await this._applySignalStrategies();
// Wait for potential auto-reset / re-enumeration after open+signals.
await new Promise(r => setTimeout(r, this._openStabilizeMs));
if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) {
throw new Error('Serial port dropped during open stabilization');
}
this._open = true;
this._readLoopPromise = this._readLoop();
return;
} catch (err) {
lastError = err;
try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {}
try { await this._port.close(); } catch (_) {}
// Short wait before retrying in case device just re-enumerated.
await new Promise(r => setTimeout(r, 400));
} finally {
this._opening = false;
}
}
throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`);
}
async close() {
this._open = false;
// Cancel the reader — this breaks out of the read loop.
if (this._reader) {
try { await this._reader.cancel(); } catch (_) {}
}
// Reject all in-flight RPCs.
for (const [, { reject, timer }] of this._pending) {
clearTimeout(timer);
reject(new Error('Connection closed'));
}
this._pending.clear();
this._port.removeEventListener('disconnect', this._boundPortDisconnect);
try { await this._port.close(); } catch (_) {}
}
// ── Public RPC methods (identical surface to NSignerWebUSB) ────────────────
async getPublicKey() {
const params = [{ nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid nostr_get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid nostr_sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_decrypt response');
return resp.result;
}
// ── Internal ───────────────────────────────────────────────────────────────
async _rpcCall(method, params) {
const id = `nl-${Date.now()}-${++this._rpcCounter}`;
const auth = await this._buildAuth(id, method, params);
const req = { jsonrpc: '2.0', id, method, params, auth };
console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req));
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;
}
_sendRpc(reqObj) {
return new Promise((resolve, reject) => {
const id = reqObj.id;
const timer = setTimeout(() => {
this._pending.delete(id);
reject(new Error('Timed out waiting for n_signer response'));
}, 30000);
this._pending.set(id, { resolve, reject, timer });
this._writeFrame(reqObj).catch(err => {
this._pending.delete(id);
clearTimeout(timer);
reject(err);
});
});
}
async _writeFrame(reqObj) {
const body = NSignerWebSerial._utf8(JSON.stringify(reqObj));
const frame = new Uint8Array(4 + body.length);
frame.set(NSignerWebSerial._be32(body.length), 0);
frame.set(body, 4);
console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length });
// Acquire writer, write, release immediately.
const w = this._port.writable.getWriter();
try {
await w.write(frame);
} finally {
w.releaseLock();
}
}
async _readLoop() {
let ring = new Uint8Array(0);
try {
this._reader = this._port.readable.getReader();
while (true) {
const { value, done } = await this._reader.read();
if (done) break;
if (!value || value.length === 0) continue;
// Append chunk to ring buffer.
const next = new Uint8Array(ring.length + value.length);
next.set(ring, 0);
next.set(value, ring.length);
ring = next;
// Parse as many complete frames as possible.
while (ring.length >= 4) {
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
// Invalid length header — slide one byte (boot-log recovery).
if (n <= 0 || n > 1_000_000) {
ring = ring.slice(1);
continue;
}
// Not enough bytes yet for the full payload.
if (ring.length < 4 + n) break;
const payload = ring.slice(4, 4 + n);
ring = ring.slice(4 + n);
let resp;
try {
resp = JSON.parse(new TextDecoder().decode(payload));
} catch (e) {
console.warn('NSignerWebSerial: frame parse error:', e);
continue;
}
console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp));
// Resolve the matching pending RPC.
if (resp && resp.id && this._pending.has(resp.id)) {
const { resolve, timer } = this._pending.get(resp.id);
this._pending.delete(resp.id);
clearTimeout(timer);
resolve(resp);
}
}
}
} catch (err) {
if (err && err.name !== 'AbortError') {
console.warn('NSignerWebSerial read loop error:', err);
}
} finally {
try { this._reader.releaseLock(); } catch (_) {}
this._reader = null;
this._handleDisconnect();
}
}
_handleDisconnect() {
if (!this._open && !this._opening) return; // already closed cleanly
this._open = false;
// Reject all in-flight RPCs.
for (const [, { reject, timer }] of this._pending) {
clearTimeout(timer);
reject(new Error('n_signer device disconnected'));
}
this._pending.clear();
for (const cb of this._disconnectHandlers) {
try { cb(); } catch (_) {}
}
}
async _buildAuth(rpcId, method, params) {
const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey);
const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey);
const createdAt = Math.floor(Date.now() / 1000);
const paramsJson = JSON.stringify(params ?? null);
const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson));
const tags = [
['nsigner_rpc', String(rpcId)],
['nsigner_method', String(method)],
['nsigner_body_hash', bodyHash]
];
const content = 'nostr_login_lite';
const nt = window.NostrTools || {};
// Prefer finalizeEvent() — stable across nostr-tools bundle shapes.
if (typeof nt.finalizeEvent === 'function') {
const finalized = nt.finalizeEvent({
kind: 27235,
created_at: createdAt,
tags,
content
}, callerPriv);
return {
id: String(finalized.id || '').toLowerCase(),
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
created_at: createdAt,
kind: 27235,
tags,
content,
sig: String(finalized.sig || '').toLowerCase()
};
}
// Fallback: schnorr.sign directly.
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser));
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
}
const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32));
const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes);
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
}
async _applySignalStrategies() {
if (!this._port?.setSignals) return;
for (const strategy of this._signalStrategies) {
try {
await this._port.setSignals({
dataTerminalReady: !!strategy.dataTerminalReady,
requestToSend: !!strategy.requestToSend
});
// Small settle gap between strategies.
await new Promise(r => setTimeout(r, 120));
} catch (_) {
// Ignore unsupported setSignals implementations.
}
if (this._sawDisconnectDuringOpen) return;
}
}
static async _findReenumeratedPort(matchInfo = {}) {
try {
if (!navigator.serial?.getPorts) return null;
const ports = await navigator.serial.getPorts();
if (!ports?.length) return null;
const vid = matchInfo?.usbVendorId;
const pid = matchInfo?.usbProductId;
// Prefer exact VID/PID match when available, but do not require it.
const exact = ports.find((p) => {
const i = p.getInfo?.() || {};
if (vid != null && i.usbVendorId !== vid) return false;
if (pid != null && i.usbProductId !== pid) return false;
return true;
});
return exact || ports[0] || null;
} catch (_) {
return null;
}
}
// ── Static utilities (mirrors NSignerWebUSB) ───────────────────────────────
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 NSignerWebSerial._hex(new Uint8Array(h));
}
}
if (typeof window !== 'undefined') {
window.NSignerWebSerial = NSignerWebSerial;
}
+358
View File
@@ -0,0 +1,358 @@
class NSignerWebUSB {
// Keep WebUSB chooser unfiltered so users can select any USB device.
// WebUSB requires a non-empty filters array; {} matches all devices.
static FILTERS = [{}];
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 ?? null;
const productId = options.productId ?? null;
const serial = options.serialNumber || null;
return devices.find((d) => {
if (vendorId !== null && vendorId !== undefined && d.vendorId !== vendorId) {
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 nt = window.NostrTools || {};
if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') {
const pub = nt.schnorr.getPublicKey(secret);
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
}
if (typeof nt.getPublicKey === 'function') {
const pub = nt.getPublicKey(secret);
return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebUSB._hex(pub);
}
throw new Error('NostrTools.getPublicKey is unavailable in this bundle');
}
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('nostr_get_public_key', params);
if (!resp || typeof resp.result !== 'string') {
throw new Error('Invalid nostr_get_public_key response');
}
return resp.result.trim().toLowerCase();
}
async signEvent(unsignedEvent) {
const params = [unsignedEvent, { nostr_index: this.nostrIndex }];
const resp = await this._rpcCall('nostr_sign_event', params);
if (!resp || typeof resp.result !== 'object') {
throw new Error('Invalid nostr_sign_event response');
}
return resp.result;
}
async nip04Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_encrypt response');
return resp.result;
}
async nip04Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip04_decrypt response');
return resp.result;
}
async nip44Encrypt(peerHex, plaintext) {
const resp = await this._rpcCall('nostr_nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_nip44_encrypt response');
return resp.result;
}
async nip44Decrypt(peerHex, ciphertext) {
const resp = await this._rpcCall('nostr_nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]);
if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nostr_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 nt = window.NostrTools || {};
// Prefer finalizeEvent() because it is stable across nostr-tools bundle shapes.
if (typeof nt.finalizeEvent === 'function') {
const finalized = nt.finalizeEvent({
kind: 27235,
created_at: createdAt,
tags,
content
}, callerPriv);
return {
id: String(finalized.id || '').toLowerCase(),
pubkey: String(finalized.pubkey || callerPubX).toLowerCase(),
created_at: createdAt,
kind: 27235,
tags,
content,
sig: String(finalized.sig || '').toLowerCase()
};
}
// Fallback path for bundles exposing schnorr directly.
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
const id = await NSignerWebUSB._sha256Hex(NSignerWebUSB._utf8(ser));
if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') {
throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)');
}
const sigBytes = await nt.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 reqJson = JSON.stringify(reqObj);
console.info('NSigner _sendRpc outbound JSON:', reqJson);
const body = NSignerWebUSB._utf8(reqJson);
const frame = new Uint8Array(4 + body.length);
frame.set(NSignerWebUSB._be32(body.length), 0);
frame.set(body, 4);
console.info('NSigner _sendRpc transferOut:', {
endpoint: this.epOut,
payloadBytes: body.length,
frameBytes: frame.length
});
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 || !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;
}
+3045
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
--nl-primary-color: #000000;
--nl-secondary-color: #ffffff;
--nl-accent-color: #ff0000;
--nl-muted-color: #666666;
--nl-muted-color: #CCCCCC;
--nl-font-family: "Courier New", Courier, monospace;
--nl-border-radius: 15px;
--nl-border-width: 3px;