398 lines
10 KiB
JavaScript
398 lines
10 KiB
JavaScript
/**
|
|
* NOSTR NIP-46 Client Implementation
|
|
* Minimal RPC over NostrTools.SimplePool for NOSTR_LOGIN_LITE
|
|
*/
|
|
|
|
class NIP46Client {
|
|
constructor() {
|
|
this.pool = null;
|
|
this.localSk = null;
|
|
this.localPk = null;
|
|
this.remotePk = null;
|
|
this.relays = [];
|
|
this.sub = null;
|
|
this.pendingRequests = {};
|
|
this.useNip44 = false;
|
|
this.iframeOrigin = null;
|
|
this.iframePort = null;
|
|
}
|
|
|
|
init(localSk, remotePk, relays, iframeOrigin) {
|
|
// Create SimplePool
|
|
this.pool = new window.NostrTools.SimplePool();
|
|
|
|
// Setup keys
|
|
this.localSk = localSk;
|
|
if (this.localSk) {
|
|
this.localPk = window.NostrTools.getPublicKey(this.localSk);
|
|
}
|
|
|
|
this.remotePk = remotePk;
|
|
this.relays = [...relays];
|
|
|
|
// Store iframe origin for future use
|
|
this.iframeOrigin = iframeOrigin;
|
|
|
|
console.log('NIP46Client initialized for', this.remotePk ? 'remote signer' : 'listening mode');
|
|
}
|
|
|
|
setUseNip44(use) {
|
|
this.useNip44 = use;
|
|
}
|
|
|
|
subscribeReplies() {
|
|
if (!this.pool || !this.localPk) return;
|
|
|
|
// Subscribe to replies to our pubkey on kind 24133 (NIP-46 methods)
|
|
this.sub = this.pool.sub(this.relays, [{
|
|
kinds: [24133],
|
|
'#p': [this.localPk]
|
|
}]);
|
|
|
|
this.sub.on('event', (event) => this.onEvent(event));
|
|
this.sub.on('eose', () => {
|
|
console.log('NIP-46 subscription caught up');
|
|
});
|
|
|
|
console.log('Subscribed to NIP-46 replies on relays:', this.relays);
|
|
}
|
|
|
|
unsubscribe() {
|
|
if (this.sub) {
|
|
this.sub.unsub();
|
|
this.sub = null;
|
|
}
|
|
}
|
|
|
|
async onEvent(event) {
|
|
console.log('NIP-46 event received:', event);
|
|
|
|
try {
|
|
const parsed = await this.parseEvent(event);
|
|
if (parsed) {
|
|
if (parsed.id && this.pendingRequests[parsed.id]) {
|
|
// Handle response
|
|
const handler = this.pendingRequests[parsed.id];
|
|
delete this.pendingRequests[parsed.id];
|
|
|
|
if (parsed.result !== undefined) {
|
|
handler.resolve(parsed.result);
|
|
} else if (parsed.error) {
|
|
handler.reject(new Error(parsed.error));
|
|
} else {
|
|
handler.reject(new Error('Invalid response format'));
|
|
}
|
|
} else if (parsed.method === 'auth_url') {
|
|
// Handle auth_url emissions (deduplication required)
|
|
this.emitAuthUrlIfNeeded(parsed.params[0]);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing NIP-46 event:', error);
|
|
}
|
|
}
|
|
|
|
emitAuthUrlIfNeeded(url) {
|
|
// Deduplicate auth_url emissions - only emit if not recently shown
|
|
const lastUrl = sessionStorage.getItem('nl-last-auth-url');
|
|
if (lastUrl === url) {
|
|
console.log('Auth URL already shown, skipping duplicate:', url);
|
|
return;
|
|
}
|
|
|
|
sessionStorage.setItem('nl-last-auth-url', url);
|
|
console.log('New auth URL:', url);
|
|
|
|
// Emit event for UI
|
|
window.dispatchEvent(new CustomEvent('nlAuthUrl', { detail: { url } }));
|
|
}
|
|
|
|
async parseEvent(event) {
|
|
try {
|
|
let content = event.content;
|
|
|
|
// Determine encryption method based on content structure
|
|
if (content.length > 44) {
|
|
// Likely NIP-44 (encrypted)
|
|
if (this.localSk && event.pubkey) {
|
|
try {
|
|
content = window.NostrTools.nip44?.decrypt(this.localSk, event.pubkey, content);
|
|
} catch (e) {
|
|
console.warn('NIP-44 decryption failed, trying NIP-04...');
|
|
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
|
|
}
|
|
}
|
|
} else {
|
|
// Likely NIP-04
|
|
if (this.localSk && event.pubkey) {
|
|
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
|
|
}
|
|
}
|
|
|
|
const payload = JSON.parse(content);
|
|
console.log('Decrypted NIP-46 payload:', payload);
|
|
|
|
return {
|
|
id: payload.id,
|
|
method: payload.method,
|
|
params: payload.params,
|
|
result: payload.result,
|
|
error: payload.error,
|
|
event: event
|
|
};
|
|
|
|
} catch (e) {
|
|
console.error('Failed to parse event:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async listen(nostrConnectSecret) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.localPk) {
|
|
reject(new Error('No local pubkey available for listening'));
|
|
return;
|
|
}
|
|
|
|
// Subscribe to unsolicited events to our pubkey
|
|
let foundSecretOrAck = false;
|
|
|
|
const listenSub = this.pool.sub(this.relays, [{
|
|
kinds: [24133],
|
|
'#p': [this.localPk]
|
|
}]);
|
|
|
|
listenSub.on('event', async (event) => {
|
|
try {
|
|
const parsed = await this.parseEvent(event);
|
|
if (parsed && parsed.method === 'connect') {
|
|
// Accept if it's an ack or matches our secret
|
|
const [userPubkey, token] = parsed.params || [];
|
|
|
|
if (token === '' && parsed.result === 'ack') {
|
|
// Ack received
|
|
foundSecretOrAck = true;
|
|
listenSub.unsub();
|
|
resolve(event.pubkey);
|
|
} else if (token === nostrConnectSecret) {
|
|
// Secret match
|
|
foundSecretOrAck = true;
|
|
listenSub.unsub();
|
|
resolve(event.pubkey);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in listen mode:', error);
|
|
}
|
|
});
|
|
|
|
// Timeout after 5 minutes
|
|
setTimeout(() => {
|
|
if (!foundSecretOrAck) {
|
|
listenSub.unsub();
|
|
reject(new Error('Listen timeout - no signer connected'));
|
|
}
|
|
}, 300000);
|
|
});
|
|
}
|
|
|
|
async connect(token, perms) {
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const result = await this.sendRequest(
|
|
this.remotePk,
|
|
'connect',
|
|
[this.localPk, token || '', perms || ''],
|
|
24133,
|
|
(response) => {
|
|
if (response === 'ack') {
|
|
resolve(true);
|
|
} else {
|
|
reject(new Error('Connection not acknowledged'));
|
|
}
|
|
}
|
|
);
|
|
|
|
// Set 30 second timeout
|
|
setTimeout(() => reject(new Error('Connection timeout')), 30000);
|
|
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
async initUserPubkey(hint) {
|
|
if (hint) {
|
|
this.remotePk = hint;
|
|
return hint;
|
|
}
|
|
|
|
if (!this.remotePk) {
|
|
// Request get_public_key
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const pubkey = await this.sendRequest(
|
|
this.remotePk,
|
|
'get_public_key',
|
|
[],
|
|
24133
|
|
);
|
|
this.remotePk = pubkey;
|
|
resolve(pubkey);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
return this.remotePk;
|
|
}
|
|
|
|
async sendRequest(remotePubkey, method, params, kind = 24133, cb) {
|
|
if (!this.pool || !this.localSk || !this.localPk) {
|
|
throw new Error('NIP46Client not properly initialized');
|
|
}
|
|
|
|
if (!remotePubkey) {
|
|
throw new Error('No remote pubkey specified');
|
|
}
|
|
|
|
const id = this._generateId();
|
|
|
|
// Create request event
|
|
const event = await this.createRequestEvent(id, remotePubkey, method, params, kind);
|
|
|
|
console.log('Sending NIP-46 request:', { id, method, params });
|
|
|
|
// Publish to relays
|
|
const pubs = await this.pool.publish(this.relays, event);
|
|
console.log('Published to relays, waiting for response...');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Set timeout
|
|
const timeout = setTimeout(() => {
|
|
console.error('NIP-46 request timeout for id:', id);
|
|
delete this.pendingRequests[id];
|
|
reject(new Error(`Request timeout for ${method}`));
|
|
}, 60000); // 1 minute timeout
|
|
|
|
// Store handler
|
|
this.pendingRequests[id] = {
|
|
resolve: (result) => {
|
|
clearTimeout(timeout);
|
|
resolve(result);
|
|
},
|
|
reject: (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
},
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
// If callback provided, override resolve
|
|
if (cb) {
|
|
const originalResolve = this.pendingRequests[id].resolve;
|
|
this.pendingRequests[id].resolve = (result) => {
|
|
cb(result);
|
|
originalResolve(result);
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
async createRequestEvent(id, remotePubkey, method, params, kind = 24133) {
|
|
let content = JSON.stringify({
|
|
id,
|
|
method,
|
|
params
|
|
});
|
|
|
|
// Choose encryption method
|
|
let encrypted = content;
|
|
if (method !== 'create_account') {
|
|
// Use NIP-44 for non-account creation methods if available
|
|
if (this.useNip44 && window.NostrTools.nip44) {
|
|
encrypted = window.NostrTools.nip44.encrypt(this.localSk, remotePubkey, content);
|
|
} else {
|
|
// Fallback to NIP-04
|
|
encrypted = await window.NostrTools.nip04.encrypt(this.localSk, remotePubkey, content);
|
|
}
|
|
}
|
|
|
|
// Create event structure
|
|
const event = {
|
|
kind: kind,
|
|
content: encrypted,
|
|
tags: [
|
|
['p', remotePubkey]
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
pubkey: this.localPk,
|
|
id: '', // Will be set by finalizeEvent
|
|
sig: '' // Will be set by finalizeEvent
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = window.NostrTools.finalizeEvent(event, this.localSk);
|
|
|
|
return signedEvent;
|
|
}
|
|
|
|
_generateId() {
|
|
return 'nl-' + Date.now() + '-' + Math.random().toString(36).substring(2, 15);
|
|
}
|
|
|
|
setWorkerIframePort(port) {
|
|
this.iframePort = port;
|
|
|
|
// Set up postMessage routing if needed
|
|
if (this.iframePort && this.iframeOrigin) {
|
|
this.iframePort.onmessage = (event) => {
|
|
if (event.origin !== this.iframeOrigin) {
|
|
console.warn('Ignoring message from unknown origin:', event.origin);
|
|
return;
|
|
}
|
|
|
|
console.log('Received iframe message:', event.data);
|
|
// Handle iframe messages
|
|
};
|
|
|
|
// Send keepalive
|
|
setInterval(() => {
|
|
if (this.iframePort) {
|
|
try {
|
|
this.iframePort.postMessage({ type: 'ping' });
|
|
} catch (e) {
|
|
console.warn('Iframe port closed');
|
|
this.iframePort = null;
|
|
}
|
|
}
|
|
}, 30000); // 30 seconds
|
|
}
|
|
}
|
|
|
|
teardown() {
|
|
this.unsubscribe();
|
|
|
|
if (this.iframePort) {
|
|
try {
|
|
this.iframePort.close();
|
|
} catch (e) {
|
|
console.warn('Error closing iframe port:', e);
|
|
}
|
|
this.iframePort = null;
|
|
}
|
|
|
|
if (this.pool) {
|
|
this.pool.close(this.relays);
|
|
this.pool = null;
|
|
}
|
|
|
|
// Clear all pending requests
|
|
for (const id in this.pendingRequests) {
|
|
this.pendingRequests[id].reject(new Error('Client teardown'));
|
|
}
|
|
this.pendingRequests = {};
|
|
}
|
|
} |