4 Commits

Author SHA1 Message Date
Your Name
ca75df8bb4 Fixed issue with bunker. Made the modal more beautiful. 2025-09-19 12:24:13 -04:00
Your Name
c747f1f315 . 2025-09-18 10:18:32 -04:00
Your Name
2a66b5eeec Fixed name display 2025-09-16 18:13:01 -04:00
Your Name
fa9688b17e Implement logging in via seed phrase 2025-09-16 15:51:08 -04:00
5 changed files with 5990 additions and 1445 deletions

View File

@@ -58,509 +58,10 @@ if (typeof window !== 'undefined') {
console.log('NOSTR_LOGIN_LITE: Dependencies verified ✓');
console.log('NOSTR_LOGIN_LITE: NostrTools available with keys:', Object.keys(window.NostrTools));
console.log('NOSTR_LOGIN_LITE: NIP-06 available:', !!window.NostrTools.nip06);
console.log('NOSTR_LOGIN_LITE: NIP-46 available:', !!window.NostrTools.nip46);
}
// ===== NIP-46 Extension Integration =====
// Add NIP-46 functionality to NostrTools if not already present
if (typeof window.NostrTools !== 'undefined' && !window.NostrTools.nip46) {
console.log('NOSTR_LOGIN_LITE: Adding NIP-46 extension to NostrTools');
const { nip44, generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, utils } = window.NostrTools;
// NIP-05 regex for parsing
const NIP05_REGEX = /^(?:([\\w.+-]+)@)?([\\w_-]+(\.[\\w_-]+)+)$/;
const BUNKER_REGEX = /^bunker:\\/\\/([0-9a-f]{64})\\??([?\\/\\w:.=&%-]*)$/;
const EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
// Event kinds
const NostrConnect = 24133;
const ClientAuth = 22242;
const Handlerinformation = 31990;
// Fetch implementation
let _fetch;
try {
_fetch = fetch;
} catch {
_fetch = null;
}
function useFetchImplementation(fetchImplementation) {
_fetch = fetchImplementation;
}
// Simple Pool implementation for NIP-46
class SimplePool {
constructor() {
this.relays = new Map();
this.subscriptions = new Map();
}
async ensureRelay(url) {
if (!this.relays.has(url)) {
console.log(\`NIP-46: Connecting to relay \${url}\`);
const ws = new WebSocket(url);
const relay = {
ws,
connected: false,
subscriptions: new Map()
};
this.relays.set(url, relay);
// Wait for connection with proper event handlers
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(\`NIP-46: Connection timeout for \${url}\`);
reject(new Error(\`Connection timeout to \${url}\`));
}, 10000); // 10 second timeout
ws.onopen = () => {
console.log(\`NIP-46: Successfully connected to relay \${url}, WebSocket state: \${ws.readyState}\`);
relay.connected = true;
clearTimeout(timeout);
resolve();
};
ws.onerror = (error) => {
console.error(\`NIP-46: Failed to connect to \${url}:\`, error);
clearTimeout(timeout);
reject(new Error(\`Failed to connect to \${url}: \${error.message || 'Connection failed'}\`));
};
ws.onclose = (event) => {
console.log(\`NIP-46: Disconnected from relay \${url}:\`, event.code, event.reason);
relay.connected = false;
if (this.relays.has(url)) {
this.relays.delete(url);
}
clearTimeout(timeout);
reject(new Error(\`Connection closed during setup: \${event.reason || 'Unknown reason'}\`));
};
});
} else {
const relay = this.relays.get(url);
// Verify the existing connection is still open
if (!relay.connected || relay.ws.readyState !== WebSocket.OPEN) {
console.log(\`NIP-46: Reconnecting to relay \${url}\`);
this.relays.delete(url);
return await this.ensureRelay(url); // Recursively reconnect
}
}
const relay = this.relays.get(url);
console.log(\`NIP-46: Relay \${url} ready, WebSocket state: \${relay.ws.readyState}\`);
return relay;
}
subscribe(relays, filters, params = {}) {
const subId = Math.random().toString(36).substring(7);
relays.forEach(async (url) => {
try {
const relay = await this.ensureRelay(url);
relay.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[1] === subId) {
params.onevent?.(data[2]);
} else if (data[0] === 'EOSE' && data[1] === subId) {
params.oneose?.();
}
} catch (err) {
console.warn('Failed to parse message:', err);
}
};
// Ensure filters is an array
const filtersArray = Array.isArray(filters) ? filters : [filters];
const reqMsg = JSON.stringify(['REQ', subId, ...filtersArray]);
relay.ws.send(reqMsg);
} catch (err) {
console.warn('Failed to connect to relay:', url, err);
}
});
return {
close: () => {
relays.forEach(async (url) => {
const relay = this.relays.get(url);
if (relay?.connected) {
relay.ws.send(JSON.stringify(['CLOSE', subId]));
}
});
}
};
}
async publish(relays, event) {
console.log(\`NIP-46: Publishing event to \${relays.length} relays:\`, event);
const promises = relays.map(async (url) => {
try {
console.log(\`NIP-46: Attempting to publish to \${url}\`);
const relay = await this.ensureRelay(url);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error(\`NIP-46: Publish timeout to \${url}\`);
reject(new Error(\`Publish timeout to \${url}\`));
}, 10000); // Increased timeout to 10 seconds
// Set up message handler for this specific event
const messageHandler = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === event.id) {
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
if (data[2]) {
console.log(\`NIP-46: Publish success to \${url}:\`, data[3]);
resolve(data[3]);
} else {
console.error(\`NIP-46: Publish rejected by \${url}:\`, data[3]);
reject(new Error(\`Publish rejected: \${data[3]}\`));
}
}
} catch (err) {
console.error(\`NIP-46: Error parsing message from \${url}:\`, err);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(err);
}
};
relay.ws.addEventListener('message', messageHandler);
// Double-check WebSocket state before sending
console.log(\`NIP-46: About to publish to \${url}, WebSocket state: \${relay.ws.readyState} (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)\`);
if (relay.ws.readyState === WebSocket.OPEN) {
console.log(\`NIP-46: Sending event to \${url}\`);
relay.ws.send(JSON.stringify(['EVENT', event]));
} else {
console.error(\`NIP-46: WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`);
clearTimeout(timeout);
relay.ws.removeEventListener('message', messageHandler);
reject(new Error(\`WebSocket not ready for \${url}, state: \${relay.ws.readyState}\`));
}
});
} catch (err) {
console.error(\`NIP-46: Failed to publish to \${url}:\`, err);
return Promise.reject(new Error(\`Failed to publish to \${url}: \${err.message}\`));
}
});
const results = await Promise.allSettled(promises);
console.log(\`NIP-46: Publish results:\`, results);
return results;
}
async querySync(relays, filter, params = {}) {
return new Promise((resolve) => {
const events = [];
this.subscribe(relays, [filter], {
...params,
onevent: (event) => events.push(event),
oneose: () => resolve(events)
});
});
}
}
// Bunker URL utilities
function toBunkerURL(bunkerPointer) {
let bunkerURL = new URL(\`bunker://\${bunkerPointer.pubkey}\`);
bunkerPointer.relays.forEach((relay) => {
bunkerURL.searchParams.append('relay', relay);
});
if (bunkerPointer.secret) {
bunkerURL.searchParams.set('secret', bunkerPointer.secret);
}
return bunkerURL.toString();
}
async function parseBunkerInput(input) {
let match = input.match(BUNKER_REGEX);
if (match) {
try {
const pubkey = match[1];
const qs = new URLSearchParams(match[2]);
return {
pubkey,
relays: qs.getAll('relay'),
secret: qs.get('secret')
};
} catch (_err) {
// Continue to NIP-05 parsing
}
}
return queryBunkerProfile(input);
}
async function queryBunkerProfile(nip05) {
if (!_fetch) {
throw new Error('Fetch implementation not available');
}
const match = nip05.match(NIP05_REGEX);
if (!match) return null;
const [_, name = '_', domain] = match;
try {
const url = \`https://\${domain}/.well-known/nostr.json?name=\${name}\`;
const res = await (await _fetch(url, { redirect: 'error' })).json();
let pubkey = res.names[name];
let relays = res.nip46[pubkey] || [];
return { pubkey, relays, secret: null };
} catch (_err) {
return null;
}
}
// BunkerSigner class
class BunkerSigner {
constructor(clientSecretKey, bp, params = {}) {
if (bp.relays.length === 0) {
throw new Error('no relays are specified for this bunker');
}
this.params = params;
this.pool = params.pool || new SimplePool();
this.secretKey = clientSecretKey;
this.conversationKey = nip44.getConversationKey(clientSecretKey, bp.pubkey);
this.bp = bp;
this.isOpen = false;
this.idPrefix = Math.random().toString(36).substring(7);
this.serial = 0;
this.listeners = {};
this.waitingForAuth = {};
this.ready = false;
this.readyPromise = this.setupSubscription(params);
}
async setupSubscription(params) {
console.log('NIP-46: Setting up subscription to relays:', this.bp.relays);
const listeners = this.listeners;
const waitingForAuth = this.waitingForAuth;
const convKey = this.conversationKey;
// Ensure all relays are connected first
await Promise.all(this.bp.relays.map(url => this.pool.ensureRelay(url)));
console.log('NIP-46: All relays connected, setting up subscription');
this.subCloser = this.pool.subscribe(
this.bp.relays,
[{ kinds: [NostrConnect], authors: [this.bp.pubkey], '#p': [getPublicKey(this.secretKey)] }],
{
onevent: async (event) => {
const o = JSON.parse(nip44.decrypt(event.content, convKey));
const { id, result, error } = o;
if (result === 'auth_url' && waitingForAuth[id]) {
delete waitingForAuth[id];
if (params.onauth) {
params.onauth(error);
} else {
console.warn(
\`NIP-46: remote signer \${this.bp.pubkey} tried to send an "auth_url"='\${error}' but there was no onauth() callback configured.\`
);
}
return;
}
let handler = listeners[id];
if (handler) {
if (error) handler.reject(error);
else if (result) handler.resolve(result);
delete listeners[id];
}
},
onclose: () => {
this.subCloser = undefined;
}
}
);
this.isOpen = true;
this.ready = true;
console.log('NIP-46: BunkerSigner setup complete and ready');
}
async ensureReady() {
if (!this.ready) {
console.log('NIP-46: Waiting for BunkerSigner to be ready...');
await this.readyPromise;
}
}
async close() {
this.isOpen = false;
this.subCloser?.close();
}
async sendRequest(method, params) {
return new Promise(async (resolve, reject) => {
try {
await this.ensureReady(); // Wait for BunkerSigner to be ready
if (!this.isOpen) {
throw new Error('this signer is not open anymore, create a new one');
}
if (!this.subCloser) {
await this.setupSubscription(this.params);
}
this.serial++;
const id = \`\${this.idPrefix}-\${this.serial}\`;
const encryptedContent = nip44.encrypt(JSON.stringify({ id, method, params }), this.conversationKey);
const verifiedEvent = finalizeEvent(
{
kind: NostrConnect,
tags: [['p', this.bp.pubkey]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
},
this.secretKey
);
this.listeners[id] = { resolve, reject };
this.waitingForAuth[id] = true;
console.log(\`NIP-46: Sending \${method} request with id \${id}\`);
const publishResults = await this.pool.publish(this.bp.relays, verifiedEvent);
// Check if at least one publish succeeded
const hasSuccess = publishResults.some(result => result.status === 'fulfilled');
if (!hasSuccess) {
throw new Error('Failed to publish to any relay');
}
console.log(\`NIP-46: \${method} request sent successfully\`);
} catch (err) {
console.error(\`NIP-46: sendRequest \${method} failed:\`, err);
reject(err);
}
});
}
async ping() {
let resp = await this.sendRequest('ping', []);
if (resp !== 'pong') {
throw new Error(\`result is not pong: \${resp}\`);
}
}
async connect() {
await this.sendRequest('connect', [this.bp.pubkey, this.bp.secret || '']);
}
async getPublicKey() {
if (!this.cachedPubKey) {
this.cachedPubKey = await this.sendRequest('get_public_key', []);
}
return this.cachedPubKey;
}
async signEvent(event) {
let resp = await this.sendRequest('sign_event', [JSON.stringify(event)]);
let signed = JSON.parse(resp);
if (verifyEvent(signed)) {
return signed;
} else {
throw new Error(\`event returned from bunker is improperly signed: \${JSON.stringify(signed)}\`);
}
}
async nip04Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip04_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip04Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip04_decrypt', [thirdPartyPubkey, ciphertext]);
}
async nip44Encrypt(thirdPartyPubkey, plaintext) {
return await this.sendRequest('nip44_encrypt', [thirdPartyPubkey, plaintext]);
}
async nip44Decrypt(thirdPartyPubkey, ciphertext) {
return await this.sendRequest('nip44_decrypt', [thirdPartyPubkey, ciphertext]);
}
}
async function createAccount(bunker, params, username, domain, email, localSecretKey = generateSecretKey()) {
if (email && !EMAIL_REGEX.test(email)) {
throw new Error('Invalid email');
}
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params);
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || '']);
rpc.bp.pubkey = pubkey;
await rpc.connect();
return rpc;
}
async function fetchBunkerProviders(pool, relays) {
const events = await pool.querySync(relays, {
kinds: [Handlerinformation],
'#k': [NostrConnect.toString()]
});
events.sort((a, b) => b.created_at - a.created_at);
const validatedBunkers = await Promise.all(
events.map(async (event, i) => {
try {
const content = JSON.parse(event.content);
try {
if (events.findIndex((ev) => JSON.parse(ev.content).nip05 === content.nip05) !== i) {
return undefined;
}
} catch (err) {
// Continue processing
}
const bp = await queryBunkerProfile(content.nip05);
if (bp && bp.pubkey === event.pubkey && bp.relays.length) {
return {
bunkerPointer: bp,
nip05: content.nip05,
domain: content.nip05.split('@')[1],
name: content.name || content.display_name,
picture: content.picture,
about: content.about,
website: content.website,
local: false
};
}
} catch (err) {
return undefined;
}
})
);
return validatedBunkers.filter((b) => b !== undefined);
}
// Extend NostrTools with NIP-46 functionality
window.NostrTools.nip46 = {
BunkerSigner,
parseBunkerInput,
toBunkerURL,
queryBunkerProfile,
createAccount,
fetchBunkerProviders,
useFetchImplementation,
BUNKER_REGEX,
SimplePool
};
console.log('NIP-46 extension loaded successfully');
console.log('Available: NostrTools.nip46');
}
// ======================================
// NOSTR_LOGIN_LITE Components
// ======================================
@@ -952,13 +453,13 @@ class FloatingTab {
// Determine which relays to use
const relays = this.options.getUserRelay.length > 0
? this.options.getUserRelay
: (this.modal?.options?.relays || ['wss://relay.damus.io', 'wss://nos.lol']);
: ['wss://relay.damus.io', 'wss://nos.lol'];
console.log('FloatingTab: Fetching profile from relays:', relays);
try {
// Create a SimplePool instance for querying
const pool = new window.NostrTools.nip46.SimplePool();
const pool = new window.NostrTools.SimplePool();
// Query for kind 0 (user metadata) events
const events = await pool.querySync(relays, {
@@ -981,9 +482,27 @@ class FloatingTab {
const profile = JSON.parse(latestEvent.content);
console.log('FloatingTab: Parsed profile:', profile);
// Return relevant profile fields
// Find the best name from any key containing "name" (case-insensitive)
let bestName = null;
const nameKeys = Object.keys(profile).filter(key =>
key.toLowerCase().includes('name') &&
typeof profile[key] === 'string' &&
profile[key].trim().length > 0
);
if (nameKeys.length > 0) {
// Find the shortest name value
bestName = nameKeys
.map(key => profile[key].trim())
.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest
);
console.log('FloatingTab: Found name keys:', nameKeys, 'selected:', bestName);
}
// Return relevant profile fields with the best name
return {
name: profile.name || null,
name: bestName,
display_name: profile.display_name || null,
about: profile.about || null,
picture: profile.picture || null,
@@ -1144,10 +663,10 @@ class NostrLite {
this.options = {
theme: 'default',
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
methods: {
extension: true,
local: true,
seedphrase: false,
readonly: true,
connect: false,
otp: false
@@ -1576,8 +1095,8 @@ class WindowNostr {
}
async getRelays() {
// Return configured relays from nostr-lite options
return this.nostrLite.options?.relays || ['wss://relay.damus.io'];
// Return default relays since we removed the relays configuration
return ['wss://relay.damus.io', 'wss://nos.lol'];
}
get nip04() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -58,7 +58,7 @@ class Modal {
overflow: hidden;
`;
} else {
// Modal content: centered with margin
// Modal content: centered with margin, no fixed height
modalContent.style.cssText = `
position: relative;
background: var(--nl-secondary-color);
@@ -68,7 +68,6 @@ class Modal {
margin: 50px auto;
border-radius: var(--nl-border-radius, 15px);
border: var(--nl-border-width) solid var(--nl-primary-color);
max-height: 600px;
overflow: hidden;
`;
}
@@ -105,7 +104,7 @@ class Modal {
closeButton.style.cssText = `
background: var(--nl-secondary-color);
border: var(--nl-border-width) solid var(--nl-primary-color);
border-radius: var(--nl-border-radius);
border-radius: 4px;
font-size: 28px;
color: var(--nl-primary-color);
cursor: pointer;
@@ -133,8 +132,6 @@ class Modal {
this.modalBody = document.createElement('div');
this.modalBody.style.cssText = `
padding: 24px;
overflow-y: auto;
max-height: 500px;
background: transparent;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
@@ -223,6 +220,16 @@ class Modal {
});
}
// Seed Phrase option - only show if explicitly enabled
if (this.options?.methods?.seedphrase === true) {
options.push({
type: 'seedphrase',
title: 'Seed Phrase',
description: 'Import from mnemonic seed phrase',
icon: '🌱'
});
}
// Nostr Connect option (check both 'connect' and 'remote' for compatibility)
if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) {
options.push({
@@ -280,6 +287,19 @@ class Modal {
button.style.background = 'var(--nl-secondary-color)';
};
const iconDiv = document.createElement('div');
// Remove the icon entirely - no emojis or text-based icons
iconDiv.textContent = '';
iconDiv.style.cssText = `
font-size: 16px;
font-weight: bold;
margin-right: 16px;
width: 0px;
text-align: center;
color: var(--nl-primary-color);
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
const contentDiv = document.createElement('div');
contentDiv.style.cssText = 'flex: 1; text-align: left;';
@@ -303,6 +323,7 @@ class Modal {
contentDiv.appendChild(titleDiv);
contentDiv.appendChild(descDiv);
button.appendChild(iconDiv);
button.appendChild(contentDiv);
this.modalBody.appendChild(button);
});
@@ -319,6 +340,9 @@ class Modal {
case 'local':
this._showLocalKeyScreen();
break;
case 'seedphrase':
this._showSeedPhraseScreen();
break;
case 'connect':
this._showConnectScreen();
break;
@@ -1165,10 +1189,6 @@ class Modal {
_showConnectScreen() {
this.modalBody.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Connect to NIP-46 Remote Signer';
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
const description = document.createElement('p');
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
@@ -1193,12 +1213,67 @@ class Modal {
box-sizing: border-box;
`;
// Users will enter the complete bunker connection string with relay info
// Add real-time bunker key validation
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
const connectButton = document.createElement('button');
connectButton.textContent = 'Connect to Bunker';
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value);
connectButton.style.cssText = this._getButtonStyle();
connectButton.disabled = true;
connectButton.onclick = () => {
if (!connectButton.disabled) {
this._handleNip46Connect(pubkeyInput.value);
}
};
// Set initial disabled state
connectButton.style.cssText = `
display: block;
width: 100%;
padding: 12px;
border: var(--nl-border-width) solid var(--nl-muted-color);
border-radius: var(--nl-border-radius);
font-size: 16px;
font-weight: 500;
cursor: not-allowed;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
background: var(--nl-secondary-color);
color: var(--nl-muted-color);
margin-bottom: 12px;
`;
pubkeyInput.oninput = () => {
const value = pubkeyInput.value.trim();
if (!value) {
formatHint.textContent = '';
// Disable button
connectButton.disabled = true;
connectButton.style.borderColor = 'var(--nl-muted-color)';
connectButton.style.color = 'var(--nl-muted-color)';
connectButton.style.cursor = 'not-allowed';
return;
}
const isValid = this._validateBunkerKey(value);
if (isValid) {
formatHint.textContent = '✅ Valid bunker connection format detected';
formatHint.style.color = '#059669';
// Enable button
connectButton.disabled = false;
connectButton.style.borderColor = 'var(--nl-primary-color)';
connectButton.style.color = 'var(--nl-primary-color)';
connectButton.style.cursor = 'pointer';
} else {
formatHint.textContent = '❌ Invalid format - must be bunker://, npub, or 64-char hex';
formatHint.style.color = '#dc2626';
// Disable button
connectButton.disabled = true;
connectButton.style.borderColor = 'var(--nl-muted-color)';
connectButton.style.color = 'var(--nl-muted-color)';
connectButton.style.cursor = 'not-allowed';
}
};
const backButton = document.createElement('button');
backButton.textContent = 'Back';
@@ -1207,14 +1282,49 @@ class Modal {
formGroup.appendChild(label);
formGroup.appendChild(pubkeyInput);
formGroup.appendChild(formatHint);
this.modalBody.appendChild(title);
this.modalBody.appendChild(description);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(backButton);
}
_validateBunkerKey(bunkerKey) {
try {
const trimmed = bunkerKey.trim();
// Check for bunker:// format
if (trimmed.startsWith('bunker://')) {
// Should have format: bunker://pubkey or bunker://pubkey?param=value
const match = trimmed.match(/^bunker:\/\/([0-9a-fA-F]{64})(\?.*)?$/);
return !!match;
}
// Check for npub format
if (trimmed.startsWith('npub1') && trimmed.length === 63) {
try {
if (window.NostrTools?.nip19) {
const decoded = window.NostrTools.nip19.decode(trimmed);
return decoded.type === 'npub';
}
} catch {
return false;
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return true;
}
return false;
} catch (error) {
console.log('Bunker key validation failed:', error.message);
return false;
}
}
_handleNip46Connect(bunkerPubkey) {
if (!bunkerPubkey || !bunkerPubkey.length) {
this._showError('Bunker pubkey is required');
@@ -1284,9 +1394,9 @@ class Modal {
const localSecretKey = window.NostrTools.generateSecretKey();
console.log('Generated local client keypair for NIP-46 session');
// Use nostr-tools BunkerSigner constructor
// Use nostr-tools BunkerSigner factory method (not constructor - it's private)
console.log('Creating nip46 BunkerSigner...');
const signer = new window.NostrTools.nip46.BunkerSigner(localSecretKey, bunkerPointer, {
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, {
onauth: (url) => {
console.log('Received auth URL from bunker:', url);
// Open auth URL in popup or redirect
@@ -1363,6 +1473,312 @@ class Modal {
this._setAuthMethod('readonly');
}
_showSeedPhraseScreen() {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.innerHTML = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts, or <span id="generate-new" style="text-decoration: underline; cursor: pointer; color: var(--nl-primary-color);">generate new</span>.';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
// Remove default placeholder text as requested
textarea.placeholder = '';
textarea.style.cssText = `
width: 100%;
height: 100px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
margin-bottom: 12px;
resize: none;
font-family: monospace;
font-size: 14px;
box-sizing: border-box;
`;
// Add real-time mnemonic validation
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
const importButton = document.createElement('button');
importButton.textContent = 'Import Accounts';
importButton.disabled = true;
importButton.onclick = () => {
if (!importButton.disabled) {
this._importFromSeedPhrase(textarea.value);
}
};
// Set initial disabled state
importButton.style.cssText = `
display: block;
width: 100%;
padding: 12px;
border: var(--nl-border-width) solid var(--nl-muted-color);
border-radius: var(--nl-border-radius);
font-size: 16px;
font-weight: 500;
cursor: not-allowed;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
background: var(--nl-secondary-color);
color: var(--nl-muted-color);
`;
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
// Disable button
importButton.disabled = true;
importButton.style.borderColor = 'var(--nl-muted-color)';
importButton.style.color = 'var(--nl-muted-color)';
importButton.style.cursor = 'not-allowed';
return;
}
const isValid = this._validateMnemonic(value);
if (isValid) {
const wordCount = value.split(/\s+/).length;
formatHint.textContent = `✅ Valid ${wordCount}-word mnemonic detected`;
formatHint.style.color = '#059669';
// Enable button
importButton.disabled = false;
importButton.style.borderColor = 'var(--nl-primary-color)';
importButton.style.color = 'var(--nl-primary-color)';
importButton.style.cursor = 'pointer';
} else {
formatHint.textContent = '❌ Invalid mnemonic - must be 12 or 24 valid BIP-39 words';
formatHint.style.color = '#dc2626';
// Disable button
importButton.disabled = true;
importButton.style.borderColor = 'var(--nl-muted-color)';
importButton.style.color = 'var(--nl-muted-color)';
importButton.style.cursor = 'not-allowed';
}
};
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
// Add click handler for the "generate new" link
const generateLink = document.getElementById('generate-new');
if (generateLink) {
generateLink.addEventListener('mouseenter', () => {
generateLink.style.color = 'var(--nl-accent-color)';
});
generateLink.addEventListener('mouseleave', () => {
generateLink.style.color = 'var(--nl-primary-color)';
});
generateLink.addEventListener('click', () => {
this._generateNewSeedPhrase(textarea, formatHint);
});
}
}
_generateNewSeedPhrase(textarea, formatHint) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
throw new Error('NIP-06 not available in bundle');
}
// Generate a random 12-word mnemonic using NostrTools
const mnemonic = window.NostrTools.nip06.generateSeedWords();
// Set the generated mnemonic in the textarea
textarea.value = mnemonic;
// Trigger the oninput event to properly validate and enable the button
if (textarea.oninput) {
textarea.oninput();
}
console.log('Generated new seed phrase:', mnemonic.split(/\s+/).length, 'words');
} catch (error) {
console.error('Failed to generate seed phrase:', error);
formatHint.textContent = '❌ Failed to generate seed phrase - NIP-06 not available';
formatHint.style.color = '#dc2626';
}
}
_validateMnemonic(mnemonic) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
console.error('NIP-06 not available in bundle');
return false;
}
const words = mnemonic.trim().split(/\s+/);
// Must be 12 or 24 words
if (words.length !== 12 && words.length !== 24) {
return false;
}
// Try to validate using NostrTools nip06 - this will throw if invalid
window.NostrTools.nip06.privateKeyFromSeedWords(mnemonic, '', 0);
return true;
} catch (error) {
console.log('Mnemonic validation failed:', error.message);
return false;
}
}
_importFromSeedPhrase(mnemonic) {
try {
const trimmed = mnemonic.trim();
if (!trimmed) {
throw new Error('Please enter a mnemonic seed phrase');
}
// Validate the mnemonic
if (!this._validateMnemonic(trimmed)) {
throw new Error('Invalid mnemonic. Please enter a valid 12 or 24-word BIP-39 seed phrase');
}
// Generate accounts 0-5 using NIP-06
const accounts = [];
for (let i = 0; i < 6; i++) {
try {
const privateKey = window.NostrTools.nip06.privateKeyFromSeedWords(trimmed, '', i);
const publicKey = window.NostrTools.getPublicKey(privateKey);
const nsec = window.NostrTools.nip19.nsecEncode(privateKey);
const npub = window.NostrTools.nip19.npubEncode(publicKey);
accounts.push({
index: i,
privateKey,
publicKey,
nsec,
npub
});
} catch (error) {
console.error(`Failed to derive account ${i}:`, error);
}
}
if (accounts.length === 0) {
throw new Error('Failed to derive any accounts from seed phrase');
}
console.log(`Successfully derived ${accounts.length} accounts from seed phrase`);
this._showAccountSelection(accounts);
} catch (error) {
console.error('Seed phrase import failed:', error);
this._showError('Seed phrase import failed: ' + error.message);
}
}
_showAccountSelection(accounts) {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.textContent = `Select which account to use (${accounts.length} accounts derived from seed phrase):`;
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
this.modalBody.appendChild(description);
// Create table for account selection
const table = document.createElement('table');
table.style.cssText = `
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-family: var(--nl-font-family, 'Courier New', monospace);
font-size: 12px;
`;
// Table header
const thead = document.createElement('thead');
thead.innerHTML = `
<tr style="background: #f3f4f6;">
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
</tr>
`;
table.appendChild(thead);
// Table body
const tbody = document.createElement('tbody');
accounts.forEach(account => {
const row = document.createElement('tr');
row.style.cssText = 'border: 1px solid #d1d5db;';
const indexCell = document.createElement('td');
indexCell.textContent = account.index;
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
const actionCell = document.createElement('td');
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
// Show truncated npub in the button
const truncatedNpub = `${account.npub.slice(0, 12)}...${account.npub.slice(-8)}`;
const selectButton = document.createElement('button');
selectButton.textContent = truncatedNpub;
selectButton.onclick = () => this._selectAccount(account);
selectButton.style.cssText = `
width: 100%;
padding: 8px 12px;
font-size: 11px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color);
border-radius: 4px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-align: center;
`;
selectButton.onmouseover = () => {
selectButton.style.borderColor = 'var(--nl-accent-color)';
};
selectButton.onmouseout = () => {
selectButton.style.borderColor = 'var(--nl-primary-color)';
};
actionCell.appendChild(selectButton);
row.appendChild(indexCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
this.modalBody.appendChild(table);
// Back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Seed Phrase';
backButton.onclick = () => this._showSeedPhraseScreen();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(backButton);
}
_selectAccount(account) {
console.log('Selected account:', account.index, account.npub);
// Use the same auth method as local keys, but with seedphrase identifier
this._setAuthMethod('local', {
secret: account.nsec,
pubkey: account.publicKey,
source: 'seedphrase',
accountIndex: account.index
});
}
_showOtpScreen() {
// Placeholder for OTP functionality
this._showError('OTP/DM not yet implemented - coming soon!');

1
nostr-tools Submodule

Submodule nostr-tools added at 23aebbd341