bunker-worker.js raw
1 /**
2 * BunkerWorker - Web Worker for persistent NIP-46 bunker service
3 *
4 * Runs in a separate thread to maintain WebSocket connection
5 * regardless of UI component lifecycle.
6 */
7
8 import { nip04 } from 'nostr-tools';
9 import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
10 import { secp256k1 } from '@noble/curves/secp256k1';
11
12 // State
13 let ws = null;
14 let connected = false;
15 let userPubkey = null;
16 let userPrivkey = null;
17 let relayUrl = null;
18 let subscriptionId = null;
19 let heartbeatInterval = null;
20 let allowedSecrets = new Set();
21 let connectedClients = new Map();
22
23 // NIP-46 methods
24 const NIP46_METHOD = {
25 CONNECT: 'connect',
26 GET_PUBLIC_KEY: 'get_public_key',
27 SIGN_EVENT: 'sign_event',
28 NIP04_ENCRYPT: 'nip04_encrypt',
29 NIP04_DECRYPT: 'nip04_decrypt',
30 PING: 'ping'
31 };
32
33 function generateRandomHex(bytes = 16) {
34 const arr = new Uint8Array(bytes);
35 crypto.getRandomValues(arr);
36 return bytesToHex(arr);
37 }
38
39 function postStatus(status, data = {}) {
40 self.postMessage({ type: 'status', status, ...data });
41 }
42
43 function postError(error) {
44 self.postMessage({ type: 'error', error });
45 }
46
47 function postClientsUpdate() {
48 const clients = Array.from(connectedClients.entries()).map(([pubkey, info]) => ({
49 pubkey,
50 ...info
51 }));
52 self.postMessage({ type: 'clients', clients });
53 }
54
55 async function connect() {
56 if (connected || !relayUrl || !userPubkey || !userPrivkey) {
57 postError('Missing configuration or already connected');
58 return;
59 }
60
61 return new Promise((resolve, reject) => {
62 let wsUrl = relayUrl;
63 if (wsUrl.startsWith('http://')) {
64 wsUrl = 'ws://' + wsUrl.slice(7);
65 } else if (wsUrl.startsWith('https://')) {
66 wsUrl = 'wss://' + wsUrl.slice(8);
67 } else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
68 wsUrl = 'wss://' + wsUrl;
69 }
70
71 console.log('[BunkerWorker] Connecting to:', wsUrl);
72
73 ws = new WebSocket(wsUrl);
74
75 const timeout = setTimeout(() => {
76 ws.close();
77 postError('Connection timeout');
78 reject(new Error('Connection timeout'));
79 }, 10000);
80
81 ws.onopen = () => {
82 clearTimeout(timeout);
83 connected = true;
84 console.log('[BunkerWorker] Connected to relay');
85
86 // Subscribe to NIP-46 events
87 subscriptionId = generateRandomHex(8);
88 const sub = JSON.stringify([
89 'REQ',
90 subscriptionId,
91 {
92 kinds: [24133],
93 '#p': [userPubkey],
94 since: Math.floor(Date.now() / 1000) - 60
95 }
96 ]);
97 ws.send(sub);
98
99 startHeartbeat();
100 postStatus('connected');
101 resolve();
102 };
103
104 ws.onerror = (error) => {
105 clearTimeout(timeout);
106 console.error('[BunkerWorker] WebSocket error:', error);
107 postError('WebSocket error');
108 reject(new Error('WebSocket error'));
109 };
110
111 ws.onclose = () => {
112 connected = false;
113 ws = null;
114 stopHeartbeat();
115 console.log('[BunkerWorker] Disconnected from relay');
116 postStatus('disconnected');
117 };
118
119 ws.onmessage = (event) => {
120 handleMessage(event.data);
121 };
122 });
123 }
124
125 function disconnect() {
126 stopHeartbeat();
127 if (ws) {
128 if (subscriptionId) {
129 ws.send(JSON.stringify(['CLOSE', subscriptionId]));
130 }
131 ws.close();
132 ws = null;
133 }
134 connected = false;
135 connectedClients.clear();
136 postStatus('disconnected');
137 postClientsUpdate();
138 }
139
140 function startHeartbeat(intervalMs = 30000) {
141 stopHeartbeat();
142 heartbeatInterval = setInterval(() => {
143 if (ws && ws.readyState === WebSocket.OPEN) {
144 const sub = JSON.stringify([
145 'REQ',
146 subscriptionId,
147 {
148 kinds: [24133],
149 '#p': [userPubkey],
150 since: Math.floor(Date.now() / 1000) - 60
151 }
152 ]);
153 ws.send(sub);
154 }
155 }, intervalMs);
156 }
157
158 function stopHeartbeat() {
159 if (heartbeatInterval) {
160 clearInterval(heartbeatInterval);
161 heartbeatInterval = null;
162 }
163 }
164
165 async function handleMessage(data) {
166 try {
167 const msg = JSON.parse(data);
168 if (!Array.isArray(msg)) return;
169
170 const [type, ...rest] = msg;
171
172 if (type === 'EVENT') {
173 const [, event] = rest;
174 if (event.kind === 24133) {
175 await handleNIP46Request(event);
176 }
177 } else if (type === 'OK') {
178 console.log('[BunkerWorker] Event published:', rest[0]?.substring(0, 8));
179 } else if (type === 'NOTICE') {
180 console.warn('[BunkerWorker] Relay notice:', rest[0]);
181 }
182 } catch (err) {
183 console.error('[BunkerWorker] Failed to parse message:', err);
184 }
185 }
186
187 async function handleNIP46Request(event) {
188 try {
189 const privkeyHex = bytesToHex(userPrivkey);
190 const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
191 const request = JSON.parse(decrypted);
192
193 console.log('[BunkerWorker] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
194
195 // Log to main thread
196 self.postMessage({
197 type: 'request',
198 method: request.method,
199 from: event.pubkey,
200 timestamp: Date.now()
201 });
202
203 let result = null;
204 let error = null;
205
206 try {
207 switch (request.method) {
208 case NIP46_METHOD.CONNECT:
209 result = await handleConnect(request, event.pubkey);
210 break;
211 case NIP46_METHOD.GET_PUBLIC_KEY:
212 result = handleGetPublicKey(event.pubkey);
213 break;
214 case NIP46_METHOD.SIGN_EVENT:
215 result = await handleSignEvent(request, event.pubkey);
216 break;
217 case NIP46_METHOD.NIP04_ENCRYPT:
218 result = await handleNip04Encrypt(request, event.pubkey);
219 break;
220 case NIP46_METHOD.NIP04_DECRYPT:
221 result = await handleNip04Decrypt(request, event.pubkey);
222 break;
223 case NIP46_METHOD.PING:
224 result = 'pong';
225 break;
226 default:
227 error = `Unknown method: ${request.method}`;
228 }
229 } catch (err) {
230 console.error('[BunkerWorker] Error handling request:', err);
231 error = err.message;
232 }
233
234 await sendResponse(request.id, result, error, event.pubkey);
235
236 } catch (err) {
237 console.error('[BunkerWorker] Failed to handle NIP-46 request:', err);
238 }
239 }
240
241 async function handleConnect(request, senderPubkey) {
242 const [clientPubkey, secret] = request.params;
243
244 if (allowedSecrets.size > 0) {
245 if (!secret || !allowedSecrets.has(secret)) {
246 throw new Error('Invalid or missing connection secret');
247 }
248 }
249
250 connectedClients.set(senderPubkey, {
251 clientPubkey: clientPubkey || senderPubkey,
252 connectedAt: Date.now(),
253 lastActivity: Date.now()
254 });
255
256 console.log('[BunkerWorker] Client connected:', senderPubkey.substring(0, 8));
257 postClientsUpdate();
258
259 return 'ack';
260 }
261
262 function handleGetPublicKey(senderPubkey) {
263 if (connectedClients.has(senderPubkey)) {
264 connectedClients.get(senderPubkey).lastActivity = Date.now();
265 }
266 return userPubkey;
267 }
268
269 async function handleSignEvent(request, senderPubkey) {
270 if (!connectedClients.has(senderPubkey)) {
271 throw new Error('Not connected');
272 }
273
274 connectedClients.get(senderPubkey).lastActivity = Date.now();
275
276 const [eventJson] = request.params;
277 const event = JSON.parse(eventJson);
278
279 if (event.pubkey && event.pubkey !== userPubkey) {
280 throw new Error('Event pubkey does not match signer pubkey');
281 }
282
283 event.pubkey = userPubkey;
284
285 const serialized = JSON.stringify([
286 0,
287 event.pubkey,
288 event.created_at,
289 event.kind,
290 event.tags,
291 event.content
292 ]);
293 const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
294 event.id = bytesToHex(new Uint8Array(hash));
295
296 const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
297 event.sig = sig.toCompactHex();
298
299 console.log('[BunkerWorker] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
300
301 return JSON.stringify(event);
302 }
303
304 async function handleNip04Encrypt(request, senderPubkey) {
305 if (!connectedClients.has(senderPubkey)) {
306 throw new Error('Not connected');
307 }
308
309 connectedClients.get(senderPubkey).lastActivity = Date.now();
310
311 const [pubkey, plaintext] = request.params;
312 const privkeyHex = bytesToHex(userPrivkey);
313 return await nip04.encrypt(privkeyHex, pubkey, plaintext);
314 }
315
316 async function handleNip04Decrypt(request, senderPubkey) {
317 if (!connectedClients.has(senderPubkey)) {
318 throw new Error('Not connected');
319 }
320
321 connectedClients.get(senderPubkey).lastActivity = Date.now();
322
323 const [pubkey, ciphertext] = request.params;
324 const privkeyHex = bytesToHex(userPrivkey);
325 return await nip04.decrypt(privkeyHex, pubkey, ciphertext);
326 }
327
328 async function sendResponse(requestId, result, error, recipientPubkey) {
329 if (!ws || !connected) {
330 console.error('[BunkerWorker] Cannot send response: not connected');
331 return;
332 }
333
334 const response = {
335 id: requestId,
336 result: result !== null ? result : undefined,
337 error: error !== null ? error : undefined
338 };
339
340 const privkeyHex = bytesToHex(userPrivkey);
341 const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
342
343 const event = {
344 kind: 24133,
345 pubkey: userPubkey,
346 created_at: Math.floor(Date.now() / 1000),
347 content: encrypted,
348 tags: [['p', recipientPubkey]]
349 };
350
351 const serialized = JSON.stringify([
352 0,
353 event.pubkey,
354 event.created_at,
355 event.kind,
356 event.tags,
357 event.content
358 ]);
359 const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
360 event.id = bytesToHex(new Uint8Array(hash));
361
362 const sig = secp256k1.sign(hexToBytes(event.id), userPrivkey);
363 event.sig = sig.toCompactHex();
364
365 ws.send(JSON.stringify(['EVENT', event]));
366 console.log('[BunkerWorker] Sent response for:', requestId);
367 }
368
369 // Message handler from main thread
370 self.onmessage = async (event) => {
371 const { type, ...data } = event.data;
372
373 switch (type) {
374 case 'configure':
375 userPubkey = data.userPubkey;
376 userPrivkey = data.userPrivkey ? hexToBytes(data.userPrivkey) : null;
377 relayUrl = data.relayUrl;
378 if (data.secrets) {
379 allowedSecrets = new Set(data.secrets);
380 }
381 console.log('[BunkerWorker] Configured for pubkey:', userPubkey?.substring(0, 8));
382 break;
383
384 case 'connect':
385 try {
386 await connect();
387 } catch (err) {
388 postError(err.message);
389 }
390 break;
391
392 case 'disconnect':
393 disconnect();
394 break;
395
396 case 'addSecret':
397 allowedSecrets.add(data.secret);
398 break;
399
400 case 'removeSecret':
401 allowedSecrets.delete(data.secret);
402 break;
403
404 case 'getStatus':
405 postStatus(connected ? 'connected' : 'disconnected');
406 postClientsUpdate();
407 break;
408
409 default:
410 console.warn('[BunkerWorker] Unknown message type:', type);
411 }
412 };
413
414 console.log('[BunkerWorker] Worker initialized');
415