ciphermesh 1.2.1 β†’ 2.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.
package/README.md CHANGED
@@ -42,7 +42,7 @@ forwarding, survives CGNAT).
42
42
  |-----|---------|----------|
43
43
  | πŸ” | **Real E2EE** | Curve25519 + XSalsa20-Poly1305 via libsodium, keys in `sodium_malloc` β€” never touch disk |
44
44
  | πŸ”„ | **Perfect Forward Secrecy** | Double Ratchet: one key per message, compromise today β‰  read yesterday |
45
- | πŸ•ΆοΈ | **Metadata resistance** | Fixed-bucket length padding on every ciphertext + opt-in cover traffic (`/cover`) to blur when you chat |
45
+ | πŸ•ΆοΈ | **Metadata resistance** | **Sealed sender** β€” the relay never sees who sent a message β€” plus fixed-bucket length padding on every ciphertext and opt-in cover traffic (`/cover`) |
46
46
  | πŸ•΅οΈ | **TOFU + SAS** | Key-change detection (MITM alarm), 6-digit voice-verifiable codes, and inline **βœ“/βœ—** trust badges next to names |
47
47
  | 🌐 | **LAN & internet** | Auto-detects Tailscale, shows the reachable address in the banner |
48
48
  | πŸ“¨ | **Invites with QR** | `/invite` prints a `ciphermesh://` string + QR β€” paste it, you're in the right room |
package/README.pt-BR.md CHANGED
@@ -42,7 +42,7 @@ forwarding, imune a CGNAT).
42
42
  |-----|---------|--------|
43
43
  | πŸ” | **E2EE de verdade** | Curve25519 + XSalsa20-Poly1305 via libsodium, chaves em `sodium_malloc` β€” nunca tocam o disco |
44
44
  | πŸ”„ | **Perfect Forward Secrecy** | Double Ratchet: uma chave por mensagem β€” comprometer hoje β‰  ler ontem |
45
- | πŸ•ΆοΈ | **ResistΓͺncia a metadados** | Padding de comprimento em buckets fixos em todo ciphertext + cover traffic opcional (`/cover`) pra borrar quando vocΓͺ conversa |
45
+ | πŸ•ΆοΈ | **ResistΓͺncia a metadados** | **Sealed sender** β€” o relay nunca vΓͺ quem enviou a mensagem β€” + padding de comprimento em buckets fixos em todo ciphertext e cover traffic opcional (`/cover`) |
46
46
  | πŸ•΅οΈ | **TOFU + SAS** | Alarme de troca de chave (MITM), cΓ³digo de 6 dΓ­gitos verificΓ‘vel por voz e badges de confianΓ§a **βœ“/βœ—** inline ao lado dos nomes |
47
47
  | 🌐 | **LAN e internet** | Detecta Tailscale sozinho e mostra o endereΓ§o alcanΓ§Γ‘vel no banner |
48
48
  | πŸ“¨ | **Convites com QR** | `/invite` gera uma string `ciphermesh://` + QR β€” colou, caiu na sala certa |
@@ -533,18 +533,14 @@ export const FILE_CHUNK_SIZE = 49152; // 48KB
533
533
  ```json
534
534
  {
535
535
  "type": "encrypted_message",
536
- "version": 1,
536
+ "version": 2,
537
537
  "timestamp": 1739800001000,
538
- "from": "550e8400-e29b-41d4-a716-446655440000",
539
538
  "to": "660e8400-e29b-41d4-a716-446655440001",
540
- "payload": {
541
- "ciphertext": "base64(mensagem cifrada com crypto_box_easy)",
542
- "nonce": "base64(24 bytes do nonce usado)"
543
- }
539
+ "sealed": "base64( crypto_box_seal({ from, payload }, recipientPublicKey) )"
544
540
  }
545
541
  ```
546
542
 
547
- **Note**: The `payload` field is completely opaque to the server. It only reads `from` and `to` for routing.
543
+ **Note (sealed sender, v2)**: The relay sees only `to` (for routing) and an opaque `sealed` blob. The sender (`from`) *and* the whole already-E2E-encrypted `payload` are sealed to the recipient's public key with libsodium `crypto_box_seal` β€” an anonymous box only the recipient can open. The relay therefore learns **neither who sent the message nor what's inside**, and it never stamps, stores, or logs a sender. The recipient opens the seal to recover `{ from, payload }`, then decrypts the payload as before. (See Β§10 for the exact β€” honest β€” guarantee and its limits.)
548
544
 
549
545
  ### 5.4 Decrypted content (never travels in cleartext)
550
546
 
@@ -939,17 +935,29 @@ a server change and is not wired up there yet.)
939
935
  | **Denial of Service** | Rate limiting + maxPayload | Partial |
940
936
  | **Forward secrecy** | Double Ratchet β€” a unique key per message | Mitigated |
941
937
  | **Metadata analysis** | Message padding + fixed sizes | Partial |
938
+ | **Relay learns who SENT each message** | Sealed sender β€” the sender + payload are sealed to the recipient; the relay routes by recipient only and never stamps, stores, or logs a sender | Partial (honest relay) |
942
939
 
943
940
  ### 10.2 What the server CAN deduce (metadata)
944
941
 
945
942
  Even without reading content, the server knows:
946
- - **Who** is online
947
- - **Who** talks to whom
943
+ - **Who** is online (the roster: nicknames + public keys, from JOIN)
944
+ - **To whom** each message is routed (the recipient) β€” *the **sender** is hidden by sealed sender; an honest relay never learns who sent a given message*
948
945
  - **When** messages are sent β€” *mitigated by cover traffic (optional)*
949
946
  - **Approximate size** of messages β€” *mitigated: only the padding bucket leaks*
950
947
  - **Frequency** of communication β€” *mitigated by cover traffic (optional)*
951
948
 
952
949
  Implemented mitigations:
950
+ - **Sealed sender** (`crypto/SealedSender.js`, protocol v2): every
951
+ `encrypted_message` carries only `to` + an opaque `sealed` blob. The sender's
952
+ identity and the inner payload are wrapped in a libsodium `crypto_box_seal`
953
+ (anonymous box) to the recipient's key; the relay routes by recipient and
954
+ **never stamps, stores, or logs who sent a message**. *Honest guarantee &
955
+ limits:* this protects against an honest-but-curious relay, offline-queue
956
+ compromise, and network observers β€” the same model as Signal's sealed sender.
957
+ It does **not** anonymise the sender against a *malicious* relay that
958
+ correlates the sending socket (inherent to a persistent authenticated
959
+ connection), the **recipient** is still visible (routing needs it), and P2P
960
+ mode has no relay at all.
953
961
  - **Length padding** (`MessageCrypto.padMessage`, buckets
954
962
  `[128..32768]`): applied on all three encryption paths (static, ratchet, and
955
963
  deniable) before encrypting. The server sees only which bucket, not the real size.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ciphermesh",
3
- "version": "1.2.1",
3
+ "version": "2.0.0",
4
4
  "description": "Secure terminal chat for the local network (LAN) with real end-to-end encryption (E2EE) using libsodium",
5
5
  "type": "module",
6
6
  "main": "src/client/index.js",
@@ -9,6 +9,7 @@ import {
9
9
  createJoin,
10
10
  createEncryptedMessage,
11
11
  createRatchetedMessage,
12
+ createSealedMessage,
12
13
  createKeyUpdate,
13
14
  createChangeRoom,
14
15
  createListRooms,
@@ -17,6 +18,7 @@ import {
17
18
  createBanPeer,
18
19
  ERR,
19
20
  } from '../protocol/messages.js';
21
+ import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
20
22
  import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
21
23
  import { KeyManager } from '../crypto/KeyManager.js';
22
24
  import { Handshake } from '../crypto/Handshake.js';
@@ -507,6 +509,19 @@ export class ChatController {
507
509
 
508
510
  // ── Received encrypted message ────────────────────────────────
509
511
  #onEncryptedMessage(msg) {
512
+ // Sealed sender: the relay handed us only `to` + an opaque blob. Open it
513
+ // with our identity key to recover the real sender + payload; from here the
514
+ // rest of the handler is unchanged. A blob that isn't for us (or is tampered)
515
+ // simply fails to open and is dropped.
516
+ if (typeof msg.sealed === 'string') {
517
+ const opened = this.#openSealed(msg.sealed);
518
+ if (!opened || typeof opened.from !== 'string' || !opened.payload) {
519
+ return;
520
+ }
521
+ // Rebind to a fresh object β€” never mutate the received message.
522
+ msg = { ...msg, from: opened.from, payload: opened.payload };
523
+ }
524
+
510
525
  const peer = this.#peers.get(msg.from);
511
526
  if (!peer) {
512
527
  this.#ui.addErrorMessage('Message from unknown peer');
@@ -2121,6 +2136,28 @@ export class ChatController {
2121
2136
  }
2122
2137
  }
2123
2138
 
2139
+ // Sealed sender: wrap an outgoing wire message so the relay sees only `to` and
2140
+ // an opaque blob. The sender identity (`from`) + the payload are sealed to the
2141
+ // recipient's key β€” only they can open it.
2142
+ #sealAndSend(recipientPublicKey, wireMsg) {
2143
+ const sealed = sealEnvelope(wireMsg.from, wireMsg.payload, recipientPublicKey);
2144
+ this.#connection.send(createSealedMessage(wireMsg.to, sealed));
2145
+ }
2146
+
2147
+ // Open a sealed envelope with our identity key, falling back to the previous
2148
+ // key during the post-rotation grace window. Returns { from, payload } or null.
2149
+ #openSealed(sealedB64) {
2150
+ let opened = openEnvelope(sealedB64, this.#keyManager.publicKey, this.#keyManager.secretKey);
2151
+ if (!opened && this.#keyManager.previousPublicKey) {
2152
+ opened = openEnvelope(
2153
+ sealedB64,
2154
+ this.#keyManager.previousPublicKey,
2155
+ this.#keyManager.previousSecretKey,
2156
+ );
2157
+ }
2158
+ return opened;
2159
+ }
2160
+
2124
2161
  // ── Send encrypted payload to a single peer ────────────────────
2125
2162
  #sendPayloadToPeer(peerId, payload) {
2126
2163
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
@@ -2132,7 +2169,7 @@ export class ChatController {
2132
2169
  if (ratchet && ratchet.isInitialized) {
2133
2170
  try {
2134
2171
  const result = ratchet.encrypt(payload);
2135
- this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2172
+ this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
2136
2173
  return;
2137
2174
  } catch {
2138
2175
  // Fall through to static path
@@ -2146,7 +2183,8 @@ export class ChatController {
2146
2183
  peerPublicKey,
2147
2184
  this.#handshake.secretKey,
2148
2185
  );
2149
- this.#connection.send(
2186
+ this.#sealAndSend(
2187
+ peerPublicKey,
2150
2188
  createEncryptedMessage(
2151
2189
  this.#sessionId,
2152
2190
  peerId,
@@ -2262,7 +2300,7 @@ export class ChatController {
2262
2300
  nonce.toString('base64'),
2263
2301
  );
2264
2302
  msg.payload.deniable = true;
2265
- this.#connection.send(msg);
2303
+ this.#sealAndSend(peerPublicKey, msg);
2266
2304
  continue;
2267
2305
  }
2268
2306
 
@@ -2271,7 +2309,7 @@ export class ChatController {
2271
2309
  if (ratchet && ratchet.isInitialized) {
2272
2310
  try {
2273
2311
  const result = ratchet.encrypt(payload);
2274
- this.#connection.send(createRatchetedMessage(this.#sessionId, peerId, result));
2312
+ this.#sealAndSend(peerPublicKey, createRatchetedMessage(this.#sessionId, peerId, result));
2275
2313
  continue;
2276
2314
  } catch {
2277
2315
  // Ratchet failed β€” fall through to static path
@@ -2287,7 +2325,8 @@ export class ChatController {
2287
2325
  this.#handshake.secretKey,
2288
2326
  );
2289
2327
 
2290
- this.#connection.send(
2328
+ this.#sealAndSend(
2329
+ peerPublicKey,
2291
2330
  createEncryptedMessage(
2292
2331
  this.#sessionId,
2293
2332
  peerId,
@@ -81,6 +81,13 @@ export function createRatchetedMessage(from, to, payload) {
81
81
  };
82
82
  }
83
83
 
84
+ // Sealed-sender envelope (protocol v2): the relay sees only the recipient and an
85
+ // opaque blob. The sender's identity + the inner payload are sealed to the
86
+ // recipient's key (see crypto/SealedSender.js). There is deliberately no `from`.
87
+ export function createSealedMessage(to, sealedB64) {
88
+ return { ...base(MSG.ENCRYPTED_MESSAGE), to, sealed: sealedB64 };
89
+ }
90
+
84
91
  export function createError(code, message) {
85
92
  return { ...base(MSG.ERROR), code, message };
86
93
  }
@@ -86,33 +86,18 @@ export function validateJoin(msg) {
86
86
  return { valid: true, nickname: nick };
87
87
  }
88
88
 
89
+ // Sealed sender (protocol v2): the relay only ever sees the recipient and an
90
+ // opaque sealed blob β€” never the sender or the inner payload. So there is
91
+ // nothing to validate here beyond routing target + a non-empty base64 envelope
92
+ // (the overall size is already capped in validateMessage). The recipient's
93
+ // unseal is fail-safe on garbage, so the relay needn't inspect further.
89
94
  export function validateEncryptedMessage(msg) {
90
- if (!isString(msg.from) || !isString(msg.to)) {
91
- return { valid: false, error: 'Missing from/to fields' };
95
+ if (!isString(msg.to)) {
96
+ return { valid: false, error: 'Missing to field' };
92
97
  }
93
- if (!isObject(msg.payload)) {
94
- return { valid: false, error: 'Missing payload' };
98
+ if (!isValidBase64(msg.sealed)) {
99
+ return { valid: false, error: 'Missing or invalid sealed envelope' };
95
100
  }
96
- if (!isString(msg.payload.ciphertext) || !isString(msg.payload.nonce)) {
97
- return { valid: false, error: 'Invalid payload structure' };
98
- }
99
- if (!isValidBase64(msg.payload.nonce, 24)) {
100
- return { valid: false, error: 'Invalid nonce' };
101
- }
102
-
103
- // Ratcheted message: validate extra fields
104
- if (msg.payload.ephemeralPublicKey !== undefined) {
105
- if (!isValidBase64(msg.payload.ephemeralPublicKey, PUBLIC_KEY_SIZE)) {
106
- return { valid: false, error: 'Invalid ephemeral public key' };
107
- }
108
- if (!Number.isInteger(msg.payload.counter) || msg.payload.counter < 0) {
109
- return { valid: false, error: 'Invalid counter' };
110
- }
111
- if (!Number.isInteger(msg.payload.previousCounter) || msg.payload.previousCounter < 0) {
112
- return { valid: false, error: 'Invalid previousCounter' };
113
- }
114
- }
115
-
116
101
  return { valid: true };
117
102
  }
118
103
 
@@ -53,7 +53,9 @@ export class MessageRouter {
53
53
 
54
54
  if (recipientSession.ws.readyState === 1) {
55
55
  recipientSession.ws.send(JSON.stringify(msg));
56
- log.debug(`${senderSessionId.slice(0, 8)} -> ${msg.to.slice(0, 8)}`);
56
+ // Sealed sender: never log who sent it β€” only that something was routed to
57
+ // the recipient. Correlating sender->recipient in logs would defeat it.
58
+ log.debug(`routed -> ${msg.to.slice(0, 8)}`);
57
59
  }
58
60
  }
59
61
 
@@ -293,8 +293,11 @@ export class SecureWSServer {
293
293
  return;
294
294
  }
295
295
 
296
- // Ensure the 'from' field matches the sender's actual session
297
- msg.from = ws.sessionId;
296
+ // Sealed sender: the relay routes purely by `to`. It deliberately does NOT
297
+ // learn, stamp, store, or log who sent this β€” the sender's identity is
298
+ // sealed inside the envelope for the recipient only. Strip any stray `from`
299
+ // a client might set so it can never be forwarded.
300
+ delete msg.from;
298
301
 
299
302
  this.#messageRouter.route(ws.sessionId, msg);
300
303
  }
@@ -1,4 +1,4 @@
1
- export const PROTOCOL_VERSION = 1;
1
+ export const PROTOCOL_VERSION = 2; // v2: sealed sender (encrypted_message carries `sealed`, no `from`)
2
2
 
3
3
  // Network
4
4
  export const SERVER_PORT = 3600;