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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- package/src/shared/voiceNote.js +128 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROTOCOL_VERSION,
|
|
3
|
+
MAX_NICKNAME_LENGTH,
|
|
4
|
+
MAX_PAYLOAD_SIZE,
|
|
5
|
+
PUBLIC_KEY_SIZE,
|
|
6
|
+
} from '../shared/constants.js';
|
|
7
|
+
|
|
8
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
9
|
+
function isString(v) {
|
|
10
|
+
return typeof v === 'string';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isNumber(v) {
|
|
14
|
+
return typeof v === 'number' && Number.isFinite(v);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isObject(v) {
|
|
18
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isValidBase64(str, expectedBytes) {
|
|
22
|
+
if (!isString(str)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const buf = Buffer.from(str, 'base64');
|
|
27
|
+
return expectedBytes ? buf.length === expectedBytes : buf.length > 0;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeNickname(nick) {
|
|
34
|
+
if (!isString(nick)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
const clean = nick.replace(/[\x00-\x1f\x7f]/g, '').trim();
|
|
39
|
+
if (clean.length === 0 || clean.length > MAX_NICKNAME_LENGTH) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(clean)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return clean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Parse + validate incoming JSON ─────────────────────────────
|
|
49
|
+
export function parseMessage(raw) {
|
|
50
|
+
if (typeof raw === 'string' && raw.length > MAX_PAYLOAD_SIZE) {
|
|
51
|
+
return { valid: false, error: 'Payload too large' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let msg;
|
|
55
|
+
try {
|
|
56
|
+
msg = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
57
|
+
} catch {
|
|
58
|
+
return { valid: false, error: 'Invalid JSON' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!isObject(msg)) {
|
|
62
|
+
return { valid: false, error: 'Message must be an object' };
|
|
63
|
+
}
|
|
64
|
+
if (msg.version !== PROTOCOL_VERSION) {
|
|
65
|
+
return { valid: false, error: `Unsupported protocol version: ${msg.version}` };
|
|
66
|
+
}
|
|
67
|
+
if (!isString(msg.type)) {
|
|
68
|
+
return { valid: false, error: 'Missing message type' };
|
|
69
|
+
}
|
|
70
|
+
if (!isNumber(msg.timestamp)) {
|
|
71
|
+
return { valid: false, error: 'Missing timestamp' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { valid: true, msg };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Type-specific validators ───────────────────────────────────
|
|
78
|
+
export function validateJoin(msg) {
|
|
79
|
+
const nick = sanitizeNickname(msg.nickname);
|
|
80
|
+
if (!nick) {
|
|
81
|
+
return { valid: false, error: 'Invalid nickname (1-20 chars, alphanumeric/underscore/dash)' };
|
|
82
|
+
}
|
|
83
|
+
if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
|
|
84
|
+
return { valid: false, error: 'Invalid public key' };
|
|
85
|
+
}
|
|
86
|
+
return { valid: true, nickname: nick };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function validateEncryptedMessage(msg) {
|
|
90
|
+
if (!isString(msg.from) || !isString(msg.to)) {
|
|
91
|
+
return { valid: false, error: 'Missing from/to fields' };
|
|
92
|
+
}
|
|
93
|
+
if (!isObject(msg.payload)) {
|
|
94
|
+
return { valid: false, error: 'Missing payload' };
|
|
95
|
+
}
|
|
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
|
+
return { valid: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function validateKeyUpdate(msg) {
|
|
120
|
+
if (!isValidBase64(msg.publicKey, PUBLIC_KEY_SIZE)) {
|
|
121
|
+
return { valid: false, error: 'Invalid public key' };
|
|
122
|
+
}
|
|
123
|
+
return { valid: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateChangeRoom(msg) {
|
|
127
|
+
if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
|
|
128
|
+
return { valid: false, error: 'Invalid room name (1-30 chars)' };
|
|
129
|
+
}
|
|
130
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
|
|
131
|
+
return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
|
|
132
|
+
}
|
|
133
|
+
return { valid: true, room: msg.room.toLowerCase() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function validateListRooms() {
|
|
137
|
+
return { valid: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function validateKickPeer(msg) {
|
|
141
|
+
const nick = sanitizeNickname(msg.targetNickname);
|
|
142
|
+
if (!nick) {
|
|
143
|
+
return { valid: false, error: 'Invalid target nickname' };
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
valid: true,
|
|
147
|
+
targetNickname: nick,
|
|
148
|
+
reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function validateMutePeer(msg) {
|
|
153
|
+
const nick = sanitizeNickname(msg.targetNickname);
|
|
154
|
+
if (!nick) {
|
|
155
|
+
return { valid: false, error: 'Invalid target nickname' };
|
|
156
|
+
}
|
|
157
|
+
if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
|
|
158
|
+
return { valid: false, error: 'Invalid mute duration' };
|
|
159
|
+
}
|
|
160
|
+
return { valid: true, targetNickname: nick, durationMs: msg.durationMs };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function validateBanPeer(msg) {
|
|
164
|
+
const nick = sanitizeNickname(msg.targetNickname);
|
|
165
|
+
if (!nick) {
|
|
166
|
+
return { valid: false, error: 'Invalid target nickname' };
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
valid: true,
|
|
170
|
+
targetNickname: nick,
|
|
171
|
+
reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export { sanitizeNickname };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { generateKeyPairSync, createSign, createHash } from 'node:crypto';
|
|
4
|
+
import { createLogger } from '../shared/logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('cert');
|
|
7
|
+
|
|
8
|
+
const CERT_DIR = join(process.cwd(), 'certs');
|
|
9
|
+
const KEY_PATH = join(CERT_DIR, 'server.key');
|
|
10
|
+
const CERT_PATH = join(CERT_DIR, 'server.cert');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load or auto-generate self-signed TLS certificates.
|
|
14
|
+
* @returns {{ key: Buffer, cert: Buffer }}
|
|
15
|
+
*/
|
|
16
|
+
export function loadOrGenerateCerts() {
|
|
17
|
+
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
|
|
18
|
+
log.info('TLS certificates loaded from certs/');
|
|
19
|
+
return {
|
|
20
|
+
key: readFileSync(KEY_PATH),
|
|
21
|
+
cert: readFileSync(CERT_PATH),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
log.info('Generating self-signed TLS certificate...');
|
|
26
|
+
|
|
27
|
+
if (!existsSync(CERT_DIR)) {
|
|
28
|
+
mkdirSync(CERT_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
32
|
+
modulusLength: 2048,
|
|
33
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
34
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const cert = createSelfSignedCert(publicKey, privateKey);
|
|
38
|
+
|
|
39
|
+
writeFileSync(KEY_PATH, privateKey);
|
|
40
|
+
writeFileSync(CERT_PATH, cert);
|
|
41
|
+
|
|
42
|
+
log.info('Self-signed TLS certificate generated in certs/');
|
|
43
|
+
return { key: Buffer.from(privateKey), cert: Buffer.from(cert) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a minimal self-signed X.509 certificate in PEM format.
|
|
48
|
+
*/
|
|
49
|
+
function createSelfSignedCert(publicKeyPem, privateKeyPem) {
|
|
50
|
+
// Build a basic X.509 v3 certificate using DER encoding
|
|
51
|
+
const now = new Date();
|
|
52
|
+
const notAfter = new Date(now);
|
|
53
|
+
notAfter.setFullYear(notAfter.getFullYear() + 1);
|
|
54
|
+
|
|
55
|
+
// Extract raw public key bytes from SPKI PEM
|
|
56
|
+
const spkiDer = pemToDer(publicKeyPem);
|
|
57
|
+
|
|
58
|
+
// Build TBS (To Be Signed) certificate
|
|
59
|
+
const serialNumber = createHash('sha256')
|
|
60
|
+
.update(Buffer.from(Date.now().toString()))
|
|
61
|
+
.digest()
|
|
62
|
+
.subarray(0, 8);
|
|
63
|
+
|
|
64
|
+
const issuer = derSequence([
|
|
65
|
+
derSet([
|
|
66
|
+
derSequence(
|
|
67
|
+
[
|
|
68
|
+
Buffer.from('550403', 'hex'), // OID: commonName
|
|
69
|
+
derUtf8('CipherMesh'),
|
|
70
|
+
].map((b, i) => (i === 0 ? derOid(b) : b)),
|
|
71
|
+
),
|
|
72
|
+
]),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const validity = derSequence([derUtcTime(now), derUtcTime(notAfter)]);
|
|
76
|
+
|
|
77
|
+
const tbs = derSequence([
|
|
78
|
+
derExplicit(0, derInteger(2)), // version v3
|
|
79
|
+
derInteger(serialNumber),
|
|
80
|
+
derSequence([derOid(Buffer.from('2a864886f70d01010b', 'hex')), derNull()]), // sha256WithRSA
|
|
81
|
+
issuer,
|
|
82
|
+
validity,
|
|
83
|
+
issuer, // subject = issuer (self-signed)
|
|
84
|
+
Buffer.from(spkiDer), // subjectPublicKeyInfo
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Sign the TBS with SHA-256 + RSA
|
|
88
|
+
const signer = createSign('SHA256');
|
|
89
|
+
signer.update(tbs);
|
|
90
|
+
const signature = signer.sign(privateKeyPem);
|
|
91
|
+
|
|
92
|
+
// Build the full certificate
|
|
93
|
+
const cert = derSequence([
|
|
94
|
+
tbs,
|
|
95
|
+
derSequence([derOid(Buffer.from('2a864886f70d01010b', 'hex')), derNull()]),
|
|
96
|
+
derBitString(signature),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
return derToPem(cert, 'CERTIFICATE');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── DER encoding helpers ─────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function derTag(tag, content) {
|
|
105
|
+
const len = derLength(content.length);
|
|
106
|
+
return Buffer.concat([Buffer.from([tag]), len, content]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function derLength(length) {
|
|
110
|
+
if (length < 0x80) {
|
|
111
|
+
return Buffer.from([length]);
|
|
112
|
+
}
|
|
113
|
+
if (length < 0x100) {
|
|
114
|
+
return Buffer.from([0x81, length]);
|
|
115
|
+
}
|
|
116
|
+
return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function derSequence(items) {
|
|
120
|
+
return derTag(0x30, Buffer.concat(items));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function derSet(items) {
|
|
124
|
+
return derTag(0x31, Buffer.concat(items));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function derInteger(value) {
|
|
128
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from([value]);
|
|
129
|
+
// Prepend 0x00 if high bit is set (positive integer)
|
|
130
|
+
const padded = buf[0] & 0x80 ? Buffer.concat([Buffer.from([0]), buf]) : buf;
|
|
131
|
+
return derTag(0x02, padded);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function derOid(buf) {
|
|
135
|
+
return derTag(0x06, buf);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function derNull() {
|
|
139
|
+
return Buffer.from([0x05, 0x00]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function derUtf8(str) {
|
|
143
|
+
return derTag(0x0c, Buffer.from(str, 'utf-8'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function derUtcTime(date) {
|
|
147
|
+
const y = String(date.getUTCFullYear()).slice(-2);
|
|
148
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
149
|
+
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
150
|
+
const h = String(date.getUTCHours()).padStart(2, '0');
|
|
151
|
+
const min = String(date.getUTCMinutes()).padStart(2, '0');
|
|
152
|
+
const s = String(date.getUTCSeconds()).padStart(2, '0');
|
|
153
|
+
return derTag(0x17, Buffer.from(`${y}${m}${d}${h}${min}${s}Z`, 'ascii'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function derBitString(content) {
|
|
157
|
+
return derTag(0x03, Buffer.concat([Buffer.from([0x00]), content]));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function derExplicit(tag, content) {
|
|
161
|
+
return derTag(0xa0 | tag, content);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function pemToDer(pem) {
|
|
165
|
+
const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, '');
|
|
166
|
+
return Buffer.from(b64, 'base64');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function derToPem(der, label) {
|
|
170
|
+
const b64 = der.toString('base64');
|
|
171
|
+
const lines = b64.match(/.{1,64}/g) || [];
|
|
172
|
+
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
|
173
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createLogger } from '../shared/logger.js';
|
|
2
|
+
import { RATE_LIMIT_PER_SECOND } from '../shared/constants.js';
|
|
3
|
+
import { createError, ERR } from '../protocol/messages.js';
|
|
4
|
+
|
|
5
|
+
const log = createLogger('router');
|
|
6
|
+
|
|
7
|
+
export class MessageRouter {
|
|
8
|
+
#sessionManager;
|
|
9
|
+
#offlineQueue;
|
|
10
|
+
#rateCounts; // Map<sessionId, { count, resetAt }>
|
|
11
|
+
|
|
12
|
+
constructor(sessionManager, offlineQueue) {
|
|
13
|
+
this.#sessionManager = sessionManager;
|
|
14
|
+
this.#offlineQueue = offlineQueue;
|
|
15
|
+
this.#rateCounts = new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Route an encrypted_message from sender to recipient.
|
|
20
|
+
* The server NEVER inspects payload contents.
|
|
21
|
+
*/
|
|
22
|
+
route(senderSessionId, msg) {
|
|
23
|
+
// Rate limit check
|
|
24
|
+
if (!this.#checkRateLimit(senderSessionId)) {
|
|
25
|
+
const senderSession = this.#sessionManager.getSession(senderSessionId);
|
|
26
|
+
if (senderSession?.ws.readyState === 1) {
|
|
27
|
+
senderSession.ws.send(
|
|
28
|
+
JSON.stringify(createError(ERR.RATE_LIMITED, 'Too many messages per second')),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const recipientSession = this.#sessionManager.getSession(msg.to);
|
|
35
|
+
if (!recipientSession) {
|
|
36
|
+
// Try to enqueue for offline delivery
|
|
37
|
+
const leftPeer = this.#sessionManager.getRecentlyLeft(msg.to);
|
|
38
|
+
if (leftPeer) {
|
|
39
|
+
this.#offlineQueue.enqueue(leftPeer.nickname, leftPeer.publicKey, msg);
|
|
40
|
+
log.debug(`Message queued for ${leftPeer.nickname} (offline)`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const senderSession = this.#sessionManager.getSession(senderSessionId);
|
|
45
|
+
if (senderSession?.ws.readyState === 1) {
|
|
46
|
+
senderSession.ws.send(
|
|
47
|
+
JSON.stringify(createError(ERR.PEER_NOT_FOUND, 'Recipient not found')),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
log.warn(`Peer ${msg.to?.slice(0, 8)} not found`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (recipientSession.ws.readyState === 1) {
|
|
55
|
+
recipientSession.ws.send(JSON.stringify(msg));
|
|
56
|
+
log.debug(`${senderSessionId.slice(0, 8)} -> ${msg.to.slice(0, 8)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#checkRateLimit(sessionId) {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const entry = this.#rateCounts.get(sessionId);
|
|
63
|
+
|
|
64
|
+
if (!entry || now >= entry.resetAt) {
|
|
65
|
+
this.#rateCounts.set(sessionId, { count: 1, resetAt: now + 1000 });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (entry.count >= RATE_LIMIT_PER_SECOND) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
entry.count++;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cleanupSession(sessionId) {
|
|
78
|
+
this.#rateCounts.delete(sessionId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createLogger } from '../shared/logger.js';
|
|
2
|
+
import {
|
|
3
|
+
OFFLINE_QUEUE_MAX_PER_PEER,
|
|
4
|
+
OFFLINE_QUEUE_MAX_AGE_MS,
|
|
5
|
+
OFFLINE_QUEUE_MAX_TOTAL,
|
|
6
|
+
} from '../shared/constants.js';
|
|
7
|
+
|
|
8
|
+
const log = createLogger('offline-queue');
|
|
9
|
+
|
|
10
|
+
export class OfflineQueue {
|
|
11
|
+
#queues; // Map<nickname_lower, { publicKey, messages[] }>
|
|
12
|
+
#totalCount;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
this.#queues = new Map();
|
|
16
|
+
this.#totalCount = 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Enqueue a message for an offline peer.
|
|
21
|
+
* @param {string} nickname - Peer's nickname
|
|
22
|
+
* @param {string} publicKey - Peer's public key (base64)
|
|
23
|
+
* @param {object} msg - The raw encrypted_message to store
|
|
24
|
+
*/
|
|
25
|
+
enqueue(nickname, publicKey, msg) {
|
|
26
|
+
if (this.#totalCount >= OFFLINE_QUEUE_MAX_TOTAL) {
|
|
27
|
+
log.warn('Global offline queue full, dropping message');
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const key = nickname.toLowerCase();
|
|
32
|
+
let entry = this.#queues.get(key);
|
|
33
|
+
|
|
34
|
+
if (!entry) {
|
|
35
|
+
entry = { publicKey, messages: [] };
|
|
36
|
+
this.#queues.set(key, entry);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (entry.publicKey !== publicKey) {
|
|
40
|
+
log.debug(`PublicKey changed for ${nickname}, dropping old queue`);
|
|
41
|
+
this.#totalCount -= entry.messages.length;
|
|
42
|
+
entry.publicKey = publicKey;
|
|
43
|
+
entry.messages = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (entry.messages.length >= OFFLINE_QUEUE_MAX_PER_PEER) {
|
|
47
|
+
log.warn(`Queue full for ${nickname}, dropping oldest message`);
|
|
48
|
+
entry.messages.shift();
|
|
49
|
+
this.#totalCount--;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
entry.messages.push({ msg, queuedAt: Date.now() });
|
|
53
|
+
this.#totalCount++;
|
|
54
|
+
|
|
55
|
+
log.debug(`Message queued for ${nickname} (${entry.messages.length} in queue)`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Dequeue all messages for a peer if publicKey matches.
|
|
61
|
+
* @param {string} nickname - Peer's nickname
|
|
62
|
+
* @param {string} publicKey - Peer's current public key (base64)
|
|
63
|
+
* @returns {object[]} Array of stored messages (may be empty)
|
|
64
|
+
*/
|
|
65
|
+
dequeue(nickname, publicKey) {
|
|
66
|
+
const key = nickname.toLowerCase();
|
|
67
|
+
const entry = this.#queues.get(key);
|
|
68
|
+
|
|
69
|
+
if (!entry) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (entry.publicKey !== publicKey) {
|
|
74
|
+
log.info(`${nickname} reconnected with a different key, dropping queue`);
|
|
75
|
+
this.#totalCount -= entry.messages.length;
|
|
76
|
+
this.#queues.delete(key);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const valid = entry.messages.filter((item) => now - item.queuedAt < OFFLINE_QUEUE_MAX_AGE_MS);
|
|
82
|
+
|
|
83
|
+
const expired = entry.messages.length - valid.length;
|
|
84
|
+
if (expired > 0) {
|
|
85
|
+
log.debug(`${expired} expired messages dropped for ${nickname}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.#totalCount -= entry.messages.length;
|
|
89
|
+
this.#queues.delete(key);
|
|
90
|
+
|
|
91
|
+
log.info(`${valid.length} messages delivered to ${nickname}`);
|
|
92
|
+
return valid.map((item) => item.msg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove expired entries across all queues.
|
|
97
|
+
*/
|
|
98
|
+
cleanup() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
let removed = 0;
|
|
101
|
+
|
|
102
|
+
for (const [key, entry] of this.#queues) {
|
|
103
|
+
const before = entry.messages.length;
|
|
104
|
+
entry.messages = entry.messages.filter(
|
|
105
|
+
(item) => now - item.queuedAt < OFFLINE_QUEUE_MAX_AGE_MS,
|
|
106
|
+
);
|
|
107
|
+
const diff = before - entry.messages.length;
|
|
108
|
+
removed += diff;
|
|
109
|
+
this.#totalCount -= diff;
|
|
110
|
+
|
|
111
|
+
if (entry.messages.length === 0) {
|
|
112
|
+
this.#queues.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (removed > 0) {
|
|
117
|
+
log.info(`Cleanup: ${removed} expired messages removed`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get size() {
|
|
122
|
+
return this.#totalCount;
|
|
123
|
+
}
|
|
124
|
+
}
|