uniwrtc 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +3 -2
  2. package/src/crypto.js +96 -0
  3. package/src/main.js +184 -49
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniwrtc",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "A universal WebRTC signaling service",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "author": "",
28
28
  "license": "MIT",
29
29
  "dependencies": {
30
- "nostr-tools": "^2.9.0"
30
+ "nostr-tools": "^2.9.0",
31
+ "unsea": "^1.1.2"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@playwright/test": "^1.57.0",
package/src/crypto.js ADDED
@@ -0,0 +1,96 @@
1
+ import { encryptMessageWithMeta, decryptMessageWithMeta } from 'unsea';
2
+
3
+ /**
4
+ * Encryption utilities for signaling messages using unsea (ECIES)
5
+ * Uses elliptic curve cryptography for secure peer-to-peer encryption
6
+ */
7
+
8
+ // Store peer public keys by peer ID (hex)
9
+ const peerPublicKeys = new Map();
10
+
11
+ /**
12
+ * Register a peer's public key (hex string)
13
+ * @param {string} peerId - Peer ID (hex)
14
+ * @param {object} publicKeyObj - Public key object from unsea
15
+ */
16
+ export function registerPeerPublicKey(peerId, publicKeyObj) {
17
+ peerPublicKeys.set(peerId, publicKeyObj);
18
+ }
19
+
20
+ /**
21
+ * Get a peer's public key
22
+ * @param {string} peerId - Peer ID (hex)
23
+ * @returns {object|null} Public key object or null
24
+ */
25
+ export function getPeerPublicKey(peerId) {
26
+ return peerPublicKeys.get(peerId) || null;
27
+ }
28
+
29
+ /**
30
+ * Encrypt a JSON payload for transmission to a specific peer
31
+ * @param {object} payload - Object to encrypt
32
+ * @param {object} recipientPublicKey - Recipient's public key object from unsea
33
+ * @returns {Promise<object>} Encrypted message with metadata
34
+ */
35
+ export async function encryptPayload(payload, recipientPublicKey) {
36
+ try {
37
+ const plaintext = JSON.stringify(payload);
38
+ const encrypted = await encryptMessageWithMeta(plaintext, recipientPublicKey);
39
+ return encrypted;
40
+ } catch (e) {
41
+ console.error('[Crypto] Encryption failed:', e);
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Decrypt a payload using unsea
48
+ * @param {object} encrypted - Encrypted message object
49
+ * @param {object} senderPublicKey - Sender's public key object
50
+ * @param {object} myPrivateKey - This peer's private key object
51
+ * @returns {Promise<object>} Decrypted JSON payload
52
+ */
53
+ export async function decryptPayload(encrypted, senderPublicKey, myPrivateKey) {
54
+ try {
55
+ const plaintext = await decryptMessageWithMeta(encrypted, senderPublicKey, myPrivateKey);
56
+ return JSON.parse(plaintext);
57
+ } catch (e) {
58
+ console.error('[Crypto] Decryption failed:', e);
59
+ throw e;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Wrap a payload for encrypted transmission
65
+ * @param {object} payload - Payload to wrap
66
+ * @param {object} recipientPublicKey - Recipient's public key object from unsea
67
+ * @returns {Promise<object>} Wrapped envelope with encrypted content
68
+ */
69
+ export async function wrapEncryptedPayload(payload, recipientPublicKey) {
70
+ const encrypted = await encryptPayload(payload, recipientPublicKey);
71
+ return {
72
+ type: 'encrypted',
73
+ encrypted: true,
74
+ content: JSON.stringify(encrypted),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Unwrap an encrypted payload
80
+ * @param {object} envelope - Encrypted envelope
81
+ * @param {object} senderPublicKey - Sender's public key object
82
+ * @param {object} myPrivateKey - This peer's private key object
83
+ * @returns {Promise<object>} Decrypted payload
84
+ */
85
+ export async function unwrapEncryptedPayload(envelope, senderPublicKey, myPrivateKey) {
86
+ if (!envelope.encrypted) {
87
+ throw new Error('Payload is not encrypted');
88
+ }
89
+ const encrypted = JSON.parse(envelope.content);
90
+ return decryptPayload(encrypted, senderPublicKey, myPrivateKey);
91
+ }
92
+
93
+ // Dummy function for compatibility
94
+ export function deriveSharedSecret(pubkey1, pubkey2) {
95
+ return { pubkey1, pubkey2 };
96
+ }
package/src/main.js CHANGED
@@ -2,6 +2,8 @@ import './style.css';
2
2
  import UniWRTCClient from '../client-browser.js';
3
3
  import { createNostrClient } from './nostr/nostrClient.js';
4
4
  import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
5
+ import { wrapEncryptedPayload, unwrapEncryptedPayload, deriveSharedSecret, registerPeerPublicKey, getPeerPublicKey } from './crypto.js';
6
+ import { generateRandomPair } from 'unsea';
5
7
 
6
8
  // Make UniWRTCClient available globally for backwards compatibility
7
9
  window.UniWRTCClient = UniWRTCClient;
@@ -10,6 +12,8 @@ window.UniWRTCClient = UniWRTCClient;
10
12
  let nostrClient = null;
11
13
  let myPeerId = null;
12
14
  let mySessionNonce = null;
15
+ let encryptionEnabled = true; // Encryption toggle - defaults to ON
16
+ let myKeyPair = null; // unsea key pair for encryption { publicKey, privateKey }
13
17
  const peerSessions = new Map();
14
18
  const peerProbeState = new Map();
15
19
  const peerResyncState = new Map();
@@ -76,10 +80,16 @@ document.getElementById('app').innerHTML = `
76
80
  <input type="text" id="roomId" data-testid="roomId" placeholder="my-room">
77
81
  </div>
78
82
  </div>
79
- <div style="display: flex; gap: 10px; align-items: center;">
80
- <button onclick="window.connect()" class="btn-primary" id="connectBtn" data-testid="connectBtn">Connect</button>
81
- <button onclick="window.disconnect()" class="btn-danger" id="disconnectBtn" data-testid="disconnectBtn" disabled>Disconnect</button>
82
- <span id="statusBadge" data-testid="statusBadge" class="status-badge status-disconnected">Disconnected</span>
83
+ <div style="display: flex; gap: 10px; align-items: center; justify-content: space-between;">
84
+ <div style="display: flex; gap: 10px; align-items: center;">
85
+ <button onclick="window.connect()" class="btn-primary" id="connectBtn" data-testid="connectBtn">Connect</button>
86
+ <button onclick="window.disconnect()" class="btn-danger" id="disconnectBtn" data-testid="disconnectBtn" disabled>Disconnect</button>
87
+ <span id="statusBadge" data-testid="statusBadge" class="status-badge status-disconnected">Disconnected</span>
88
+ </div>
89
+ <label style="display: flex; align-items: center; gap: 8px; color: #64748b; font-size: 13px; white-space: nowrap;">
90
+ <input type="checkbox" id="encryptionToggle" onchange="window.toggleEncryption()" checked style="cursor: pointer; width: 16px; height: 16px;">
91
+ Encrypt
92
+ </label>
83
93
  </div>
84
94
  </div>
85
95
 
@@ -97,11 +107,6 @@ document.getElementById('app').innerHTML = `
97
107
  </div>
98
108
  </div>
99
109
 
100
- <div class="card">
101
- <h2>Role</h2>
102
- <div id="roleBadge" data-testid="roleBadge" class="status-badge status-disconnected">Not assigned</div>
103
- </div>
104
-
105
110
  <div class="card">
106
111
  <h2>Connected Peers</h2>
107
112
  <div id="peerList" data-testid="peerList" class="peer-list">
@@ -129,6 +134,16 @@ document.getElementById('app').innerHTML = `
129
134
  </div>
130
135
  `;
131
136
 
137
+ // FORCE ENCRYPTION DEFAULT ON IMMEDIATELY AFTER HTML IS CREATED
138
+ const encryptCb = document.getElementById('encryptionToggle');
139
+ if (encryptCb) {
140
+ console.log('[ENCRYPTION] Found checkbox, forcing ON');
141
+ encryptCb.defaultChecked = true;
142
+ encryptCb.checked = true;
143
+ encryptionEnabled = true;
144
+ console.log('[ENCRYPTION] Set encryptionEnabled=true, checkbox.checked=' + encryptCb.checked);
145
+ }
146
+
132
147
  // Prefill room input from URL (?room= or ?session=); otherwise set a visible default the user can override
133
148
  const roomInput = document.getElementById('roomId');
134
149
  const params = new URLSearchParams(window.location.search);
@@ -155,6 +170,16 @@ roomInput.addEventListener('input', () => {
155
170
  document.getElementById('sessionId').textContent = roomInput.value.trim() || 'Not joined';
156
171
  });
157
172
 
173
+ // Force encryption toggle to default ON on load
174
+ const encryptionCheckbox = document.getElementById('encryptionToggle');
175
+ if (encryptionCheckbox) {
176
+ encryptionCheckbox.defaultChecked = true; // force default state
177
+ encryptionCheckbox.checked = true; // override any persisted form state
178
+ encryptionEnabled = true;
179
+ window.toggleEncryption?.(); // ensure UI + state sync on load
180
+ log('Signaling encryption defaulted to ON', 'success');
181
+ }
182
+
158
183
  // ICE servers: STUN-only by default (no TURN). For deterministic local testing,
159
184
  // support host-only ICE via URL flag: ?ice=host (or ?ice=none)
160
185
  const iceMode = (params.get('ice') || '').toLowerCase();
@@ -230,27 +255,6 @@ function updateStatus(connected) {
230
255
  }
231
256
  }
232
257
 
233
- function updateRole(role) {
234
- const badge = document.getElementById('roleBadge');
235
- if (!badge) return;
236
-
237
- if (role === 'coordinator') {
238
- badge.textContent = 'Coordinator';
239
- badge.className = 'status-badge status-connected';
240
- // If assigned a role, we must be connected
241
- updateStatus(true);
242
- } else if (role === 'peer') {
243
- badge.textContent = 'Peer';
244
- badge.className = 'status-badge status-info';
245
- // If assigned a role, we must be connected
246
- updateStatus(true);
247
- } else {
248
- badge.textContent = 'Not assigned';
249
- badge.className = 'status-badge status-disconnected';
250
- updateStatus(false);
251
- }
252
- }
253
-
254
258
  function updatePeerList() {
255
259
  const peerList = document.getElementById('peerList');
256
260
  if (!peerList) return;
@@ -302,32 +306,63 @@ function isPoliteFor(peerId) {
302
306
  return !shouldInitiateWith(peerId);
303
307
  }
304
308
 
305
- function sendSignal(to, payload) {
309
+ async function sendSignal(to, payload) {
306
310
  if (!nostrClient) throw new Error('Not connected to Nostr');
307
-
308
- const toSession = peerSessions.get(to);
309
311
  const type = payload?.type;
310
- const needsToSession = type !== 'probe';
312
+ const isBroadcast = !to;
313
+ const toSession = isBroadcast ? null : peerSessions.get(to);
314
+ const needsToSession = !isBroadcast && type !== 'probe';
311
315
 
312
316
  if (needsToSession && !toSession) throw new Error('No peer session yet');
313
317
 
314
- return nostrClient.send({
318
+ let finalPayload = {
315
319
  ...payload,
316
- to,
320
+ ...(to ? { to } : {}),
317
321
  ...(needsToSession ? { toSession } : {}),
318
322
  fromSession: mySessionNonce,
319
- });
323
+ };
324
+
325
+ // Optionally encrypt the payload using unsea
326
+ if (encryptionEnabled && to && myKeyPair) {
327
+ try {
328
+ const recipientPublicKey = getPeerPublicKey(to);
329
+ if (recipientPublicKey) {
330
+ finalPayload = await wrapEncryptedPayload(finalPayload, recipientPublicKey);
331
+ }
332
+ // Silently fall back to unencrypted if no peer key yet - will be available from their hello
333
+ } catch (e) {
334
+ console.warn('[Crypto] Encryption failed, sending unencrypted:', e?.message);
335
+ }
336
+ }
337
+
338
+ return nostrClient.send(finalPayload);
320
339
  }
321
340
 
322
- function sendSignalToSession(to, payload, toSession) {
341
+ async function sendSignalToSession(to, payload, toSession) {
323
342
  if (!nostrClient) throw new Error('Not connected to Nostr');
324
343
  if (!toSession) throw new Error('toSession is required');
325
- return nostrClient.send({
344
+
345
+ let finalPayload = {
326
346
  ...payload,
327
347
  to,
328
348
  toSession,
329
349
  fromSession: mySessionNonce,
330
- });
350
+ };
351
+
352
+ // Optionally encrypt the payload using unsea
353
+ if (encryptionEnabled && to && myKeyPair) {
354
+ try {
355
+ const recipientPublicKey = getPeerPublicKey(to);
356
+ if (recipientPublicKey) {
357
+ finalPayload = await wrapEncryptedPayload(finalPayload, recipientPublicKey);
358
+ }
359
+ // Silently fall back to unencrypted if no peer key yet - will be available from their hello
360
+ } catch (e) {
361
+ console.warn('[Crypto] Encryption failed, sending unencrypted:', e?.message);
362
+ }
363
+ }
364
+
365
+ return nostrClient.send(finalPayload);
331
366
  }
332
367
 
333
368
  async function maybeProbePeer(peerId) {
@@ -342,7 +377,16 @@ async function maybeProbePeer(peerId) {
342
377
  const probeId = Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
343
378
  peerProbeState.set(peerId, { session, ts: Date.now(), probeId });
344
379
  try {
345
- await sendSignal(peerId, { type: 'probe', probeId });
380
+ const probePayload = { type: 'probe', probeId };
381
+ // Include our encryption public key so peer can encrypt responses
382
+ if (encryptionEnabled && myKeyPair && myKeyPair.publicKey) {
383
+ try {
384
+ probePayload.encryptionPublicKey = JSON.stringify(myKeyPair.publicKey);
385
+ } catch (e) {
386
+ console.warn('[Crypto] Failed to serialize public key for probe:', e?.message);
387
+ }
388
+ }
389
+ await sendSignal(peerId, probePayload);
346
390
  log(`Probing peer ${peerId.substring(0, 6)}...`, 'info');
347
391
  } catch (e) {
348
392
  log(`Probe failed: ${e?.message || e}`, 'warning');
@@ -486,9 +530,11 @@ async function connectNostr() {
486
530
  nostrClient = null;
487
531
  }
488
532
 
489
- // Reset local peer state to avoid stale sessions targeting the wrong browser tab.
533
+ // Preserve session across relay reconnects to avoid breaking existing signaling.
534
+ // Only reset on true disconnect (Disconnect button), not on relay failures.
490
535
  myPeerId = myPeerId || ensureIdentity();
491
- mySessionNonce = null;
536
+ // NOTE: Do NOT reset mySessionNonce here. Keep it stable across relay changes.
537
+ // If no session exists yet, it will be created below.
492
538
  peerSessions.clear();
493
539
  peerProbeState.clear();
494
540
  readyPeers.clear();
@@ -524,7 +570,21 @@ async function connectNostr() {
524
570
  // Any client instance will derive the same per-tab keypair.
525
571
  const myPubkey = myPeerId || ensureIdentity();
526
572
  myPeerId = myPubkey;
527
- mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
573
+
574
+ // Generate unsea key pair for encryption (once per connection, defaults to enabled)
575
+ if (!myKeyPair) {
576
+ try {
577
+ myKeyPair = await generateRandomPair();
578
+ console.log('[Crypto] Generated unsea key pair for peer', myPeerId.substring(0, 6));
579
+ } catch (e) {
580
+ console.warn('[Crypto] Failed to generate key pair:', e?.message);
581
+ }
582
+ }
583
+
584
+ // Generate session nonce only once per connect session (preserve across relay changes)
585
+ if (!mySessionNonce) {
586
+ mySessionNonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
587
+ }
528
588
  document.getElementById('clientId').textContent = myPubkey.substring(0, 16) + '...';
529
589
  document.getElementById('sessionId').textContent = effectiveRoom;
530
590
 
@@ -533,8 +593,12 @@ async function connectNostr() {
533
593
  room: effectiveRoom,
534
594
  secretKeyHex: mySecretKeyHex,
535
595
  onState: (state) => {
536
- if (state === 'connected') updateStatus(true);
537
- if (state === 'disconnected') updateStatus(false);
596
+ if (state === 'connected') {
597
+ updateStatus(true);
598
+ }
599
+ if (state === 'disconnected') {
600
+ updateStatus(false);
601
+ }
538
602
  },
539
603
  onNotice: (notice) => {
540
604
  log(`Relay NOTICE (${relayUrl}): ${String(notice)}`, 'warning');
@@ -546,6 +610,27 @@ async function connectNostr() {
546
610
  const peerId = from;
547
611
  if (!peerId || peerId === myPeerId) return;
548
612
 
613
+ // Try to decrypt if encrypted (using unsea)
614
+ let decrypted = payload;
615
+ if (payload && payload.encrypted && payload.content && myKeyPair && myKeyPair.privateKey) {
616
+ try {
617
+ const senderPublicKey = getPeerPublicKey(peerId);
618
+ if (senderPublicKey) {
619
+ decrypted = await unwrapEncryptedPayload(payload, senderPublicKey, myKeyPair.privateKey);
620
+ console.log('[Crypto] Decrypted message from', peerId.substring(0, 6));
621
+ } else {
622
+ console.warn('[Crypto] No public key for sender, cannot decrypt');
623
+ }
624
+ } catch (e) {
625
+ console.warn('[Crypto] Failed to decrypt message from', peerId.substring(0, 6), ':', e?.message);
626
+ // If decryption fails, ignore the message (safety-first for encrypted content)
627
+ return;
628
+ }
629
+ }
630
+
631
+ // Use decrypted payload for all subsequent processing
632
+ payload = decrypted;
633
+
549
634
  // Use the existing peer list UI as a simple "seen peers" list
550
635
  if (!peerConnections.has(peerId)) {
551
636
  peerConnections.set(peerId, null);
@@ -574,6 +659,17 @@ async function connectNostr() {
574
659
  readyPeers.delete(peerId);
575
660
  }
576
661
 
662
+ // Extract peer's encryption public key if included
663
+ if (encryptionEnabled && payload.encryptionPublicKey) {
664
+ try {
665
+ const publicKeyObj = JSON.parse(payload.encryptionPublicKey);
666
+ registerPeerPublicKey(peerId, publicKeyObj);
667
+ console.log('[Crypto] Registered peer encryption key from hello:', peerId.substring(0, 6));
668
+ } catch (e) {
669
+ console.warn('[Crypto] Failed to parse peer public key from hello:', e?.message);
670
+ }
671
+ }
672
+
577
673
  // We may receive peer presence while still selecting a relay.
578
674
  // Store and probe once we have a selected/connected `nostrClient`.
579
675
  deferredHelloPeers.add(peerId);
@@ -631,6 +727,16 @@ async function connectNostr() {
631
727
  logDrop(peerId, payload, 'probeId mismatch');
632
728
  return;
633
729
  }
730
+ // Extract peer's encryption public key from probe-ack if included
731
+ if (encryptionEnabled && payload.encryptionPublicKey) {
732
+ try {
733
+ const publicKeyObj = JSON.parse(payload.encryptionPublicKey);
734
+ registerPeerPublicKey(peerId, publicKeyObj);
735
+ console.log('[Crypto] Registered peer encryption key from probe-ack:', peerId.substring(0, 6));
736
+ } catch (e) {
737
+ console.warn('[Crypto] Failed to parse peer public key from probe-ack:', e?.message);
738
+ }
739
+ }
634
740
  // Peer session can legitimately rotate between hello/probe/ack (reloads, relay history).
635
741
  // Since this message is already targeted to our toSession, accept it and update our view.
636
742
  if (typeof payload.fromSession === 'string' && payload.fromSession.length >= 6) {
@@ -790,7 +896,17 @@ async function connectNostr() {
790
896
  log(`Selected relay: ${selected.relayUrl}`, 'success');
791
897
 
792
898
  log(`Joined Nostr room: ${effectiveRoom}`, 'success');
793
- await nostrClient.send({ type: 'hello', session: mySessionNonce });
899
+ // Include unsea public key in hello if encryption is enabled
900
+ const helloPayload = { type: 'hello', session: mySessionNonce };
901
+ if (encryptionEnabled && myKeyPair && myKeyPair.publicKey) {
902
+ try {
903
+ helloPayload.encryptionPublicKey = JSON.stringify(myKeyPair.publicKey);
904
+ console.log('[Crypto] Including public key in hello message');
905
+ } catch (e) {
906
+ console.warn('[Crypto] Failed to serialize public key:', e?.message);
907
+ }
908
+ }
909
+ await nostrClient.send(helloPayload);
794
910
 
795
911
  // Kick any peers we saw while selecting relays.
796
912
  for (const peerId of deferredHelloPeers) {
@@ -929,7 +1045,6 @@ async function connectWebRTC() {
929
1045
  }
930
1046
 
931
1047
  window.disconnect = function() {
932
- updateRole(null);
933
1048
  if (nostrClient) {
934
1049
  nostrClient.disconnect().catch(() => {});
935
1050
  nostrClient = null;
@@ -957,6 +1072,16 @@ window.disconnect = function() {
957
1072
  }
958
1073
  };
959
1074
 
1075
+ window.toggleEncryption = function() {
1076
+ const checkbox = document.getElementById('encryptionToggle');
1077
+ encryptionEnabled = checkbox.checked;
1078
+ const status = encryptionEnabled ? 'enabled' : 'disabled';
1079
+ console.log('[Crypto] Encryption', status);
1080
+ log(`Signaling encryption ${status}`, 'info');
1081
+ };
1082
+
1083
+ // REMOVED: load handler was firing before HTML existed
1084
+
960
1085
  async function createPeerConnection(peerId, shouldInitiate) {
961
1086
  if (peerConnections.has(peerId)) {
962
1087
  const existing = peerConnections.get(peerId);
@@ -1060,7 +1185,17 @@ function setupDataChannel(peerId, dataChannel) {
1060
1185
  };
1061
1186
 
1062
1187
  dataChannel.onmessage = (event) => {
1063
- displayChatMessage(event.data, `${peerId.substring(0, 6)}...`, false);
1188
+ let message;
1189
+ try {
1190
+ message = JSON.parse(event.data);
1191
+ } catch {
1192
+ // Treat as plain text chat message
1193
+ displayChatMessage(event.data, `${peerId.substring(0, 6)}...`, false);
1194
+ return;
1195
+ }
1196
+
1197
+ // Treat as chat message
1198
+ displayChatMessage(JSON.stringify(message), `${peerId.substring(0, 6)}...`, false);
1064
1199
  };
1065
1200
 
1066
1201
  dataChannel.onclose = () => {