bunker-service.js raw
1 /**
2 * BunkerService - NIP-46 Remote Signer
3 *
4 * Implements the signer side of NIP-46 protocol.
5 * Listens for signing requests from remote clients and responds using
6 * the user's private key stored in ORLY.
7 *
8 * Protocol:
9 * - Kind 24133 events for request/response
10 * - NIP-04 encryption for payloads
11 * - Methods: connect, get_public_key, sign_event, nip04_encrypt, nip04_decrypt, ping
12 */
13
14 import { nip04 } from 'nostr-tools';
15 import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
16 import { secp256k1 } from '@noble/curves/secp256k1';
17
18 // NIP-46 methods
19 const NIP46_METHOD = {
20 CONNECT: 'connect',
21 GET_PUBLIC_KEY: 'get_public_key',
22 SIGN_EVENT: 'sign_event',
23 NIP04_ENCRYPT: 'nip04_encrypt',
24 NIP04_DECRYPT: 'nip04_decrypt',
25 PING: 'ping'
26 };
27
28 /**
29 * Generate a random hex string.
30 */
31 function generateRandomHex(bytes = 16) {
32 const arr = new Uint8Array(bytes);
33 crypto.getRandomValues(arr);
34 return bytesToHex(arr);
35 }
36
37 /**
38 * BunkerService class - implements NIP-46 signer protocol.
39 */
40 export class BunkerService {
41 /**
42 * @param {string} relayUrl - WebSocket URL of the relay
43 * @param {string} userPubkey - User's public key (hex)
44 * @param {Uint8Array} userPrivkey - User's private key (32 bytes)
45 */
46 constructor(relayUrl, userPubkey, userPrivkey) {
47 this.relayUrl = relayUrl;
48 this.userPubkey = userPubkey;
49 this.userPrivkey = userPrivkey;
50 this.ws = null;
51 this.connected = false;
52 this.allowedSecrets = new Set();
53 this.connectedClients = new Map(); // pubkey -> { connectedAt, lastActivity }
54 this.requestLog = [];
55 this.heartbeatInterval = null;
56 this.subscriptionId = null;
57
58 // Callbacks
59 this.onClientConnected = null;
60 this.onClientDisconnected = null;
61 this.onRequest = null;
62 this.onStatusChange = null;
63 }
64
65 /**
66 * Add an allowed connection secret.
67 */
68 addAllowedSecret(secret) {
69 this.allowedSecrets.add(secret);
70 }
71
72 /**
73 * Remove an allowed secret.
74 */
75 removeAllowedSecret(secret) {
76 this.allowedSecrets.delete(secret);
77 }
78
79 /**
80 * Connect to the relay and start listening for NIP-46 requests.
81 */
82 async connect() {
83 return new Promise((resolve, reject) => {
84 // Build WebSocket URL
85 let wsUrl = this.relayUrl;
86 if (wsUrl.startsWith('http://')) {
87 wsUrl = 'ws://' + wsUrl.slice(7);
88 } else if (wsUrl.startsWith('https://')) {
89 wsUrl = 'wss://' + wsUrl.slice(8);
90 } else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
91 wsUrl = 'wss://' + wsUrl;
92 }
93
94 console.log('[BunkerService] Connecting to:', wsUrl);
95
96 const ws = new WebSocket(wsUrl);
97
98 const timeout = setTimeout(() => {
99 ws.close();
100 reject(new Error('Connection timeout'));
101 }, 10000);
102
103 ws.onopen = () => {
104 clearTimeout(timeout);
105 this.ws = ws;
106 this.connected = true;
107 console.log('[BunkerService] Connected to relay');
108
109 // Subscribe to NIP-46 events for our pubkey
110 this.subscriptionId = generateRandomHex(8);
111 const sub = JSON.stringify([
112 'REQ',
113 this.subscriptionId,
114 {
115 kinds: [24133],
116 '#p': [this.userPubkey],
117 since: Math.floor(Date.now() / 1000) - 60
118 }
119 ]);
120 ws.send(sub);
121 console.log('[BunkerService] Subscribed to NIP-46 events');
122
123 // Start heartbeat
124 this.startHeartbeat();
125
126 if (this.onStatusChange) {
127 this.onStatusChange('connected');
128 }
129
130 resolve();
131 };
132
133 ws.onerror = (error) => {
134 clearTimeout(timeout);
135 console.error('[BunkerService] WebSocket error:', error);
136 reject(new Error('WebSocket error'));
137 };
138
139 ws.onclose = () => {
140 this.connected = false;
141 this.ws = null;
142 this.stopHeartbeat();
143 console.log('[BunkerService] Disconnected from relay');
144
145 if (this.onStatusChange) {
146 this.onStatusChange('disconnected');
147 }
148 };
149
150 ws.onmessage = (event) => {
151 this.handleMessage(event.data);
152 };
153 });
154 }
155
156 /**
157 * Start WebSocket heartbeat to keep connection alive.
158 */
159 startHeartbeat(intervalMs = 30000) {
160 this.stopHeartbeat();
161 this.heartbeatInterval = setInterval(() => {
162 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
163 // Send a ping via Nostr protocol (re-subscribe)
164 const sub = JSON.stringify([
165 'REQ',
166 this.subscriptionId,
167 {
168 kinds: [24133],
169 '#p': [this.userPubkey],
170 since: Math.floor(Date.now() / 1000) - 60
171 }
172 ]);
173 this.ws.send(sub);
174 }
175 }, intervalMs);
176 }
177
178 /**
179 * Stop WebSocket heartbeat.
180 */
181 stopHeartbeat() {
182 if (this.heartbeatInterval) {
183 clearInterval(this.heartbeatInterval);
184 this.heartbeatInterval = null;
185 }
186 }
187
188 /**
189 * Disconnect from the relay.
190 */
191 disconnect() {
192 this.stopHeartbeat();
193 if (this.ws) {
194 // Close subscription
195 if (this.subscriptionId) {
196 this.ws.send(JSON.stringify(['CLOSE', this.subscriptionId]));
197 }
198 this.ws.close();
199 this.ws = null;
200 }
201 this.connected = false;
202 this.connectedClients.clear();
203 }
204
205 /**
206 * Handle incoming WebSocket messages.
207 */
208 async handleMessage(data) {
209 try {
210 const msg = JSON.parse(data);
211 if (!Array.isArray(msg)) return;
212
213 const [type, ...rest] = msg;
214
215 if (type === 'EVENT') {
216 const [, event] = rest;
217 if (event.kind === 24133) {
218 await this.handleNIP46Request(event);
219 }
220 } else if (type === 'OK') {
221 // Event published confirmation
222 console.log('[BunkerService] Event published:', rest[0]?.substring(0, 8));
223 } else if (type === 'NOTICE') {
224 console.warn('[BunkerService] Relay notice:', rest[0]);
225 }
226 } catch (err) {
227 console.error('[BunkerService] Failed to parse message:', err);
228 }
229 }
230
231 /**
232 * Handle NIP-46 request event.
233 */
234 async handleNIP46Request(event) {
235 try {
236 // Decrypt the content with NIP-04
237 const privkeyHex = bytesToHex(this.userPrivkey);
238 const decrypted = await nip04.decrypt(privkeyHex, event.pubkey, event.content);
239 const request = JSON.parse(decrypted);
240
241 console.log('[BunkerService] Received request:', request.method, 'from:', event.pubkey.substring(0, 8));
242
243 // Log the request
244 this.requestLog.push({
245 id: request.id,
246 method: request.method,
247 from: event.pubkey,
248 timestamp: Date.now()
249 });
250 if (this.requestLog.length > 100) {
251 this.requestLog.shift();
252 }
253
254 if (this.onRequest) {
255 this.onRequest(request, event.pubkey);
256 }
257
258 // Handle the request
259 let result = null;
260 let error = null;
261
262 try {
263 switch (request.method) {
264 case NIP46_METHOD.CONNECT:
265 result = await this.handleConnect(request, event.pubkey);
266 break;
267 case NIP46_METHOD.GET_PUBLIC_KEY:
268 result = await this.handleGetPublicKey(request, event.pubkey);
269 break;
270 case NIP46_METHOD.SIGN_EVENT:
271 result = await this.handleSignEvent(request, event.pubkey);
272 break;
273 case NIP46_METHOD.NIP04_ENCRYPT:
274 result = await this.handleNip04Encrypt(request, event.pubkey);
275 break;
276 case NIP46_METHOD.NIP04_DECRYPT:
277 result = await this.handleNip04Decrypt(request, event.pubkey);
278 break;
279 case NIP46_METHOD.PING:
280 result = 'pong';
281 break;
282 default:
283 error = `Unknown method: ${request.method}`;
284 }
285 } catch (err) {
286 console.error('[BunkerService] Error handling request:', err);
287 error = err.message;
288 }
289
290 // Send response
291 await this.sendResponse(request.id, result, error, event.pubkey);
292
293 } catch (err) {
294 console.error('[BunkerService] Failed to handle NIP-46 request:', err);
295 }
296 }
297
298 /**
299 * Handle connect request.
300 */
301 async handleConnect(request, senderPubkey) {
302 const [clientPubkey, secret] = request.params;
303
304 // Validate secret if required
305 if (this.allowedSecrets.size > 0) {
306 if (!secret || !this.allowedSecrets.has(secret)) {
307 throw new Error('Invalid or missing connection secret');
308 }
309 }
310
311 // Register connected client
312 this.connectedClients.set(senderPubkey, {
313 clientPubkey: clientPubkey || senderPubkey,
314 connectedAt: Date.now(),
315 lastActivity: Date.now()
316 });
317
318 console.log('[BunkerService] Client connected:', senderPubkey.substring(0, 8));
319
320 if (this.onClientConnected) {
321 this.onClientConnected(senderPubkey);
322 }
323
324 return 'ack';
325 }
326
327 /**
328 * Handle get_public_key request.
329 */
330 async handleGetPublicKey(request, senderPubkey) {
331 // Update last activity
332 if (this.connectedClients.has(senderPubkey)) {
333 this.connectedClients.get(senderPubkey).lastActivity = Date.now();
334 }
335
336 return this.userPubkey;
337 }
338
339 /**
340 * Handle sign_event request.
341 */
342 async handleSignEvent(request, senderPubkey) {
343 // Check if client is connected
344 if (!this.connectedClients.has(senderPubkey)) {
345 throw new Error('Not connected');
346 }
347
348 // Update last activity
349 this.connectedClients.get(senderPubkey).lastActivity = Date.now();
350
351 const [eventJson] = request.params;
352 const event = JSON.parse(eventJson);
353
354 // Ensure pubkey matches
355 if (event.pubkey && event.pubkey !== this.userPubkey) {
356 throw new Error('Event pubkey does not match signer pubkey');
357 }
358
359 // Set pubkey if not set
360 event.pubkey = this.userPubkey;
361
362 // Calculate event ID
363 const serialized = JSON.stringify([
364 0,
365 event.pubkey,
366 event.created_at,
367 event.kind,
368 event.tags,
369 event.content
370 ]);
371 const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
372 event.id = bytesToHex(new Uint8Array(hash));
373
374 // Sign the event
375 const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
376 event.sig = sig.toCompactHex();
377
378 console.log('[BunkerService] Signed event:', event.id.substring(0, 8), 'kind:', event.kind);
379
380 return JSON.stringify(event);
381 }
382
383 /**
384 * Handle nip04_encrypt request.
385 */
386 async handleNip04Encrypt(request, senderPubkey) {
387 // Check if client is connected
388 if (!this.connectedClients.has(senderPubkey)) {
389 throw new Error('Not connected');
390 }
391
392 // Update last activity
393 this.connectedClients.get(senderPubkey).lastActivity = Date.now();
394
395 const [pubkey, plaintext] = request.params;
396 const privkeyHex = bytesToHex(this.userPrivkey);
397 const ciphertext = await nip04.encrypt(privkeyHex, pubkey, plaintext);
398 return ciphertext;
399 }
400
401 /**
402 * Handle nip04_decrypt request.
403 */
404 async handleNip04Decrypt(request, senderPubkey) {
405 // Check if client is connected
406 if (!this.connectedClients.has(senderPubkey)) {
407 throw new Error('Not connected');
408 }
409
410 // Update last activity
411 this.connectedClients.get(senderPubkey).lastActivity = Date.now();
412
413 const [pubkey, ciphertext] = request.params;
414 const privkeyHex = bytesToHex(this.userPrivkey);
415 const plaintext = await nip04.decrypt(privkeyHex, pubkey, ciphertext);
416 return plaintext;
417 }
418
419 /**
420 * Send NIP-46 response to client.
421 */
422 async sendResponse(requestId, result, error, recipientPubkey) {
423 if (!this.ws || !this.connected) {
424 console.error('[BunkerService] Cannot send response: not connected');
425 return;
426 }
427
428 const response = {
429 id: requestId,
430 result: result !== null ? result : undefined,
431 error: error !== null ? error : undefined
432 };
433
434 // Encrypt response with NIP-04
435 const privkeyHex = bytesToHex(this.userPrivkey);
436 const encrypted = await nip04.encrypt(privkeyHex, recipientPubkey, JSON.stringify(response));
437
438 // Create response event
439 const event = {
440 kind: 24133,
441 pubkey: this.userPubkey,
442 created_at: Math.floor(Date.now() / 1000),
443 content: encrypted,
444 tags: [['p', recipientPubkey]]
445 };
446
447 // Calculate event ID
448 const serialized = JSON.stringify([
449 0,
450 event.pubkey,
451 event.created_at,
452 event.kind,
453 event.tags,
454 event.content
455 ]);
456 const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized));
457 event.id = bytesToHex(new Uint8Array(hash));
458
459 // Sign the event
460 const sig = secp256k1.sign(hexToBytes(event.id), this.userPrivkey);
461 event.sig = sig.toCompactHex();
462
463 // Send to relay
464 this.ws.send(JSON.stringify(['EVENT', event]));
465 console.log('[BunkerService] Sent response for:', requestId);
466 }
467
468 /**
469 * Check if the service is connected.
470 */
471 isConnected() {
472 return this.connected;
473 }
474
475 /**
476 * Get list of connected clients.
477 */
478 getConnectedClients() {
479 return Array.from(this.connectedClients.entries()).map(([pubkey, info]) => ({
480 pubkey,
481 ...info
482 }));
483 }
484
485 /**
486 * Get request log.
487 */
488 getRequestLog() {
489 return [...this.requestLog];
490 }
491 }
492