ciphermesh 1.0.0

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,296 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createLogger } from '../shared/logger.js';
3
+
4
+ const log = createLogger('session');
5
+
6
+ export class SessionManager {
7
+ #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, room }>
8
+ #nicknames; // Set<nickname> for quick dupe check
9
+ #recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
10
+ #rooms; // Map<roomName, Set<sessionId>>
11
+ #roomOwners; // Map<roomName, sessionId>
12
+ #muteState; // Map<sessionId, { until: timestamp }>
13
+ #banList; // Map<roomName, Set<nickname_lower>>
14
+
15
+ constructor() {
16
+ this.#sessions = new Map();
17
+ this.#nicknames = new Set();
18
+ this.#recentlyLeft = new Map();
19
+ this.#rooms = new Map();
20
+ this.#roomOwners = new Map();
21
+ this.#muteState = new Map();
22
+ this.#banList = new Map();
23
+ // Ensure default room exists
24
+ this.#rooms.set('general', new Set());
25
+ }
26
+
27
+ isNicknameTaken(nickname) {
28
+ return this.#nicknames.has(nickname.toLowerCase());
29
+ }
30
+
31
+ addSession(ws, nickname, publicKey, room = 'general') {
32
+ const sessionId = randomUUID();
33
+ const session = {
34
+ ws,
35
+ nickname,
36
+ publicKey,
37
+ connectedAt: Date.now(),
38
+ room,
39
+ };
40
+
41
+ this.#sessions.set(sessionId, session);
42
+ this.#nicknames.add(nickname.toLowerCase());
43
+ this.#joinRoom(sessionId, room);
44
+
45
+ log.info(`${nickname} connected (${sessionId.slice(0, 8)}) in room ${room}`);
46
+ return sessionId;
47
+ }
48
+
49
+ removeSession(sessionId) {
50
+ const session = this.#sessions.get(sessionId);
51
+ if (!session) {
52
+ return null;
53
+ }
54
+
55
+ this.#leaveRoom(sessionId);
56
+ this.#nicknames.delete(session.nickname.toLowerCase());
57
+ this.#sessions.delete(sessionId);
58
+ this.#muteState.delete(sessionId);
59
+
60
+ // Track recently left peers for offline queue
61
+ this.#recentlyLeft.set(sessionId, {
62
+ nickname: session.nickname,
63
+ publicKey: session.publicKey,
64
+ leftAt: Date.now(),
65
+ });
66
+
67
+ log.info(`${session.nickname} disconnected (${sessionId.slice(0, 8)})`);
68
+ return session;
69
+ }
70
+
71
+ getSession(sessionId) {
72
+ return this.#sessions.get(sessionId);
73
+ }
74
+
75
+ /**
76
+ * Find sessionId by WebSocket reference (for disconnect handling).
77
+ */
78
+ findSessionByWs(ws) {
79
+ for (const [id, session] of this.#sessions) {
80
+ if (session.ws === ws) {
81
+ return id;
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Return peer list (excluding a given sessionId), filtered by room.
89
+ */
90
+ getPeers(excludeSessionId, room = null) {
91
+ const peers = [];
92
+ for (const [id, session] of this.#sessions) {
93
+ if (id !== excludeSessionId) {
94
+ if (room && session.room !== room) {
95
+ continue;
96
+ }
97
+ peers.push({
98
+ sessionId: id,
99
+ nickname: session.nickname,
100
+ publicKey: session.publicKey,
101
+ });
102
+ }
103
+ }
104
+ return peers;
105
+ }
106
+
107
+ /**
108
+ * Send a JSON message to all sessions except one.
109
+ */
110
+ broadcast(msg, excludeSessionId) {
111
+ const data = JSON.stringify(msg);
112
+ for (const [id, session] of this.#sessions) {
113
+ if (id !== excludeSessionId && session.ws.readyState === 1) {
114
+ session.ws.send(data);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Broadcast to members of a specific room only.
121
+ */
122
+ broadcastToRoom(room, msg, excludeSessionId) {
123
+ const members = this.#rooms.get(room);
124
+ if (!members) {
125
+ return;
126
+ }
127
+
128
+ const data = JSON.stringify(msg);
129
+ for (const sid of members) {
130
+ if (sid === excludeSessionId) {
131
+ continue;
132
+ }
133
+ const session = this.#sessions.get(sid);
134
+ if (session && session.ws.readyState === 1) {
135
+ session.ws.send(data);
136
+ }
137
+ }
138
+ }
139
+
140
+ // ── Room management ──────────────────────────────────────────
141
+
142
+ #joinRoom(sessionId, room) {
143
+ const isNew = !this.#rooms.has(room) || this.#rooms.get(room).size === 0;
144
+ if (!this.#rooms.has(room)) {
145
+ this.#rooms.set(room, new Set());
146
+ }
147
+ this.#rooms.get(room).add(sessionId);
148
+
149
+ // First person to create/join an empty non-general room becomes owner
150
+ if (isNew && room !== 'general' && !this.#roomOwners.has(room)) {
151
+ this.#roomOwners.set(room, sessionId);
152
+ log.info(`${sessionId.slice(0, 8)} is now owner of room ${room}`);
153
+ }
154
+ }
155
+
156
+ #leaveRoom(sessionId) {
157
+ const session = this.#sessions.get(sessionId);
158
+ if (!session) {
159
+ return;
160
+ }
161
+
162
+ const room = session.room;
163
+ const members = this.#rooms.get(room);
164
+ if (members) {
165
+ members.delete(sessionId);
166
+
167
+ if (members.size === 0 && room !== 'general') {
168
+ // Empty non-general room — drop it and all associated moderation state
169
+ // (otherwise a recreated room stays owner-less and unmoderatable).
170
+ this.#rooms.delete(room);
171
+ this.#roomOwners.delete(room);
172
+ this.#banList.delete(room);
173
+ } else if (this.#roomOwners.get(room) === sessionId) {
174
+ // Owner left but room still has members — transfer ownership so the
175
+ // room keeps a moderator instead of becoming owner-less.
176
+ const nextOwner = members.values().next().value;
177
+ this.#roomOwners.set(room, nextOwner);
178
+ log.info(`Ownership of room ${room} transferred to ${nextOwner.slice(0, 8)}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ switchRoom(sessionId, newRoom) {
184
+ const session = this.#sessions.get(sessionId);
185
+ if (!session) {
186
+ return null;
187
+ }
188
+
189
+ const oldRoom = session.room;
190
+ if (oldRoom === newRoom) {
191
+ return null;
192
+ }
193
+
194
+ this.#leaveRoom(sessionId);
195
+ session.room = newRoom;
196
+ this.#joinRoom(sessionId, newRoom);
197
+
198
+ log.info(`${session.nickname} switched room: ${oldRoom} → ${newRoom}`);
199
+ return { oldRoom, newRoom };
200
+ }
201
+
202
+ getRoomPeers(room, excludeSessionId) {
203
+ return this.getPeers(excludeSessionId, room);
204
+ }
205
+
206
+ listRooms() {
207
+ const rooms = [];
208
+ for (const [name, members] of this.#rooms) {
209
+ if (members.size > 0) {
210
+ rooms.push({ name, memberCount: members.size });
211
+ }
212
+ }
213
+ // Always include 'general' even if empty
214
+ if (!rooms.some((r) => r.name === 'general')) {
215
+ rooms.unshift({ name: 'general', memberCount: 0 });
216
+ }
217
+ return rooms.sort((a, b) => a.name.localeCompare(b.name));
218
+ }
219
+
220
+ getSessionRoom(sessionId) {
221
+ const session = this.#sessions.get(sessionId);
222
+ return session?.room || null;
223
+ }
224
+
225
+ updatePublicKey(sessionId, newPublicKey) {
226
+ const session = this.#sessions.get(sessionId);
227
+ if (session) {
228
+ session.publicKey = newPublicKey;
229
+ log.info(`${session.nickname} rotated keys (${sessionId.slice(0, 8)})`);
230
+ }
231
+ }
232
+
233
+ getRecentlyLeft(sessionId) {
234
+ return this.#recentlyLeft.get(sessionId) || null;
235
+ }
236
+
237
+ cleanupRecentlyLeft(maxAgeMs) {
238
+ const now = Date.now();
239
+ for (const [key, entry] of this.#recentlyLeft) {
240
+ if (now - entry.leftAt > maxAgeMs) {
241
+ this.#recentlyLeft.delete(key);
242
+ }
243
+ }
244
+ }
245
+
246
+ get size() {
247
+ return this.#sessions.size;
248
+ }
249
+
250
+ // ── Admin / Moderation ─────────────────────────────────────
251
+
252
+ isRoomOwner(room, sessionId) {
253
+ return this.#roomOwners.get(room) === sessionId;
254
+ }
255
+
256
+ getRoomOwner(room) {
257
+ return this.#roomOwners.get(room) || null;
258
+ }
259
+
260
+ isMuted(sessionId) {
261
+ const state = this.#muteState.get(sessionId);
262
+ if (!state) {
263
+ return false;
264
+ }
265
+ if (Date.now() >= state.until) {
266
+ this.#muteState.delete(sessionId);
267
+ return false;
268
+ }
269
+ return true;
270
+ }
271
+
272
+ mutePeer(sessionId, durationMs) {
273
+ this.#muteState.set(sessionId, { until: Date.now() + durationMs });
274
+ }
275
+
276
+ banPeer(room, nickname) {
277
+ if (!this.#banList.has(room)) {
278
+ this.#banList.set(room, new Set());
279
+ }
280
+ this.#banList.get(room).add(nickname.toLowerCase());
281
+ }
282
+
283
+ isBanned(room, nickname) {
284
+ const banned = this.#banList.get(room);
285
+ return banned ? banned.has(nickname.toLowerCase()) : false;
286
+ }
287
+
288
+ findSessionByNickname(nickname) {
289
+ for (const [id, session] of this.#sessions) {
290
+ if (session.nickname.toLowerCase() === nickname.toLowerCase()) {
291
+ return id;
292
+ }
293
+ }
294
+ return null;
295
+ }
296
+ }