release: prepare v4.8.5 security hardening release
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

This commit is contained in:
lockbitchat
2026-05-17 14:48:52 -04:00
parent 4b8c8829f1
commit 0a42aa13c3
35 changed files with 2975 additions and 11976 deletions
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
globalThis.window = {
EnhancedSecureCryptoUtils: {
secureLog: { log() {} }
}
};
globalThis.CustomEvent = class CustomEvent {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
const dispatchedEvents = [];
globalThis.document = {
dispatchEvent(event) {
dispatchedEvents.push(event);
}
};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
function closableChannel() {
return {
readyState: 'open',
closed: false,
onopen() {},
onclose() {},
onmessage() {},
onerror() {},
close() { this.closed = true; }
};
}
{
let transferCleanups = 0;
const dataChannel = closableChannel();
const heartbeatChannel = closableChannel();
const decoyChannel = closableChannel();
const peerConnection = {
closed: false,
onconnectionstatechange() {},
ondatachannel() {},
close() { this.closed = true; }
};
const timer = setTimeout(() => {}, 10_000);
const manager = {
intentionalDisconnect: false,
fileTransferSystem: { cleanup() { transferCleanups += 1; } },
dataChannel,
heartbeatChannel,
peerConnection,
decoyTimers: new Map([['decoy', timer]]),
decoyChannels: new Map([['decoy', decoyChannel]]),
packetBuffer: new Map([['p', 1]]),
chunkQueue: [1],
processedMessageIds: new Set(['m']),
messageCounter: 4,
keyVersions: new Map([['v', 1]]),
oldKeys: new Map([['o', 1]]),
currentKeyVersion: 3,
lastKeyRotation: 1,
sequenceNumber: 7,
expectedSequenceNumber: 8,
replayWindow: new Set([9]),
messageQueue: [{ secret: true }],
calls: [],
_stopAllTimers() { this.calls.push('_stopAllTimers'); },
stopHeartbeat() { this.calls.push('stopHeartbeat'); },
stopFakeTrafficGeneration() { this.calls.push('stopFakeTrafficGeneration'); },
_wipeEphemeralKeys() { this.calls.push('_wipeEphemeralKeys'); },
_hardWipeOldKeys() { this.calls.push('_hardWipeOldKeys'); },
_secureCleanupCryptographicMaterials() { this.calls.push('_secureCleanupCryptographicMaterials'); },
_clearVerificationStates() {
this.calls.push('_clearVerificationStates');
this.localVerificationConfirmed = false;
this.remoteVerificationConfirmed = false;
this.bothVerificationsConfirmed = false;
this.isVerified = false;
this.verificationCode = null;
this.pendingSASCode = null;
},
_secureWipeMemory() { this.calls.push('_secureWipeMemory'); },
_forceGarbageCollection() { return Promise.resolve(); },
sendDisconnectNotification() { this.calls.push('sendDisconnectNotification'); },
onStatusChange(value) { this.status = value; },
onKeyExchange(value) { this.keyExchange = value; },
onVerificationRequired(value) { this.verificationRequired = value; },
_secureLog() {}
};
EnhancedSecureWebRTCManager.prototype.disconnect.call(manager);
assert.equal(transferCleanups, 1);
assert.equal(manager.fileTransferSystem, null);
assert.equal(dataChannel.closed, true);
assert.equal(heartbeatChannel.closed, true);
assert.equal(decoyChannel.closed, true);
assert.equal(peerConnection.closed, true);
assert.equal(manager.dataChannel, null);
assert.equal(manager.heartbeatChannel, null);
assert.equal(manager.peerConnection, null);
assert.equal(manager.decoyTimers.size, 0);
assert.equal(manager.decoyChannels.size, 0);
assert.equal(manager.packetBuffer.size, 0);
assert.deepEqual(manager.chunkQueue, []);
assert.equal(manager.processedMessageIds.size, 0);
assert.equal(manager.keyVersions.size, 0);
assert.equal(manager.oldKeys.size, 0);
assert.equal(manager.replayWindow.size, 0);
assert.deepEqual(manager.messageQueue, []);
assert.equal(manager.status, 'disconnected');
assert.equal(manager.keyExchange, '');
assert.equal(manager.verificationRequired, '');
assert.ok(manager.calls.includes('_clearVerificationStates'));
assert.ok(dispatchedEvents.some(event => event.type === 'peer-disconnect'));
assert.ok(dispatchedEvents.some(event => event.type === 'connection-cleaned'));
}
console.log('Disconnect cleanup tests passed');
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: {} };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
{
const oldProgress = () => {};
const manager = {
fileTransferSystem: {
onProgress: oldProgress,
onFileReceived: oldProgress,
onError: oldProgress,
onIncomingFileRequest: oldProgress
}
};
EnhancedSecureWebRTCManager.prototype.setFileTransferCallbacks.call(
manager,
null,
null,
null,
null
);
assert.equal(manager.fileTransferSystem.onProgress, null);
assert.equal(manager.fileTransferSystem.onFileReceived, null);
assert.equal(manager.fileTransferSystem.onError, null);
assert.equal(manager.fileTransferSystem.onIncomingFileRequest, null);
}
console.log('File transfer callback propagation tests passed');
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { EnhancedSecureFileTransfer } from '../src/transfer/EnhancedSecureFileTransfer.js';
function createSystem() {
const manager = {
dataChannel: { onmessage: null, send() {}, readyState: 'open' },
isVerified: true,
fileTransferSystem: null,
isConnected: () => true
};
return new EnhancedSecureFileTransfer(manager);
}
// cleanupTransfer rejects pending sender consent immediately and clears its timeout.
{
const system = createSystem();
let rejectionMessage = null;
const timer = setTimeout(() => {}, 10_000);
system.activeTransfers.set('file_waiting', {
consentTimeout: timer,
rejectConsent(error) { rejectionMessage = error.message; },
resolveConsent() {}
});
system.sessionKeys.set('file_waiting', {});
system.transferNonces.set('file_waiting', 1);
system.cleanupTransfer('file_waiting');
assert.equal(system.activeTransfers.has('file_waiting'), false);
assert.equal(system.sessionKeys.has('file_waiting'), false);
assert.equal(system.transferNonces.has('file_waiting'), false);
assert.equal(rejectionMessage, 'Transfer cancelled during cleanup or disconnect');
assert.equal(system.activeTransfers.size, 0);
}
// global cleanup does not leave pending consent promises alive until timeout.
{
const system = createSystem();
let rejected = false;
const timer = setTimeout(() => {}, 10_000);
system.activeTransfers.set('file_waiting', {
consentTimeout: timer,
rejectConsent() { rejected = true; },
resolveConsent() {}
});
system.cleanup();
assert.equal(rejected, true);
assert.equal(system.activeTransfers.size, 0);
}
// receivedFileBuffers is bounded and evicts the oldest retained buffer.
{
const system = createSystem();
system.MAX_RETAINED_RECEIVED_FILE_BUFFERS = 2;
system._storeReceivedFileBuffer('a', { buffer: new Uint8Array([1]).buffer });
system._storeReceivedFileBuffer('b', { buffer: new Uint8Array([2]).buffer });
system._storeReceivedFileBuffer('c', { buffer: new Uint8Array([3]).buffer });
assert.equal(system.receivedFileBuffers.size, 2);
assert.equal(system.receivedFileBuffers.has('a'), false);
assert.equal(system.receivedFileBuffers.has('b'), true);
assert.equal(system.receivedFileBuffers.has('c'), true);
}
// Evicted received buffers fail gracefully for old download closures.
{
const system = createSystem();
system.MAX_RETAINED_RECEIVED_FILE_BUFFERS = 1;
let fileData = null;
system.onFileReceived = data => { fileData = data; };
system.calculateFileHashFromData = async () => 'hash';
system.sendSecureMessage = async () => {};
const receivingState = {
fileId: 'old',
fileName: 'old.pdf',
fileSize: 1,
fileType: 'application/pdf',
fileHash: 'hash',
totalChunks: 1,
receivedChunks: new Map([[0, new Uint8Array([1]).buffer]]),
startTime: Date.now()
};
await system.assembleFile(receivingState);
system._storeReceivedFileBuffer('new', { buffer: new Uint8Array([2]).buffer });
await assert.rejects(
() => fileData.getObjectURL(),
/no longer available for download/i
);
}
console.log('File transfer cleanup tests passed');
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { EnhancedSecureFileTransfer } from '../src/transfer/EnhancedSecureFileTransfer.js';
function createSystem(onIncomingFileRequest = () => {}) {
const manager = {
dataChannel: { onmessage: null, send() {}, readyState: 'open' },
isVerified: true,
fileTransferSystem: null,
isConnected: () => true
};
const system = new EnhancedSecureFileTransfer(manager, null, null, null, null, onIncomingFileRequest);
system.sendSecureMessage = async () => {};
return system;
}
function validMetadata(overrides = {}) {
return {
type: 'file_transfer_start',
fileId: 'file_1',
fileName: 'report.pdf',
fileSize: 1024,
fileType: 'application/pdf',
fileHash: 'abc',
totalChunks: 1,
chunkSize: 1024,
salt: new Array(32).fill(1),
...overrides
};
}
// Metadata is validated before a consent prompt is shown.
{
const system = createSystem();
assert.equal(system.validateIncomingMetadata(validMetadata()).isValid, true);
assert.equal(system.validateIncomingMetadata(validMetadata({ fileName: '../evil.pdf' })).isValid, false);
assert.equal(system.validateIncomingMetadata(validMetadata({ fileSize: 200 * 1024 * 1024 })).isValid, false);
}
// No receiving state or chunk buffers are allocated before explicit acceptance.
{
let prompted = null;
const system = createSystem(request => { prompted = request; });
await system.handleFileTransferStart(validMetadata());
assert.equal(prompted.fileName, 'report.pdf');
assert.equal(system.pendingIncomingTransfers.size, 1);
assert.equal(system.receivingTransfers.size, 0);
await system.handleFileChunk({ fileId: 'file_1', chunkIndex: 0 });
assert.equal(system.pendingChunks.size, 0);
}
// Incoming request spam is bounded.
{
const system = createSystem();
for (let index = 0; index < system.MAX_PENDING_INCOMING_TRANSFERS; index += 1) {
await system.handleFileTransferStart(validMetadata({ fileId: `file_${index}` }));
}
await system.handleFileTransferStart(validMetadata({ fileId: 'file_overflow' }));
assert.equal(system.pendingIncomingTransfers.size, system.MAX_PENDING_INCOMING_TRANSFERS);
}
console.log('File transfer consent tests passed');
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const effects = [];
const setterCalls = [];
let stateIndex = 0;
const callbackCalls = [];
const context = {
window: {},
React: {
useState(initialValue) {
const index = stateIndex++;
return [initialValue, value => setterCalls.push({ index, value })];
},
useRef(initialValue) {
return { current: initialValue };
},
useEffect(effect) {
effects.push(effect);
},
createElement() {
return null;
}
}
};
const source = fs.readFileSync(new URL('../src/components/ui/FileTransfer.jsx', import.meta.url), 'utf8');
vm.runInNewContext(source, context);
const manager = {
fileTransferSystem: {
onProgress: () => {},
onFileReceived: () => {},
onError: () => {},
onIncomingFileRequest: () => {}
},
setFileTransferCallbacks(...args) {
callbackCalls.push(args);
this.onFileProgress = args[0];
this.onFileReceived = args[1];
this.onFileError = args[2];
this.onIncomingFileRequest = args[3];
if (this.fileTransferSystem) {
this.fileTransferSystem.onProgress = args[0];
this.fileTransferSystem.onFileReceived = args[1];
this.fileTransferSystem.onError = args[2];
this.fileTransferSystem.onIncomingFileRequest = args[3];
}
},
getFileTransfers() {
return { sending: [], receiving: [] };
},
isConnected() {
return false;
},
isVerified: false
};
context.window.FileTransferComponent({ webrtcManager: manager, isConnected: false });
const cleanups = effects.map(effect => effect()).filter(Boolean);
assert.ok(setterCalls.some(call => call.index === 2 && Array.isArray(call.value) && call.value.length === 0));
assert.ok(setterCalls.some(call => call.index === 3 && Array.isArray(call.value) && call.value.length === 0));
assert.ok(setterCalls.some(call => call.index === 1 && call.value.sending.length === 0 && call.value.receiving.length === 0));
cleanups.forEach(cleanup => cleanup());
assert.deepEqual(callbackCalls.at(-1), [null, null, null, null]);
assert.equal(manager.fileTransferSystem.onProgress, null);
assert.equal(manager.fileTransferSystem.onFileReceived, null);
assert.equal(manager.fileTransferSystem.onError, null);
assert.equal(manager.fileTransferSystem.onIncomingFileRequest, null);
console.log('File transfer UI cleanup tests passed');
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { EnhancedSecureFileTransfer } from '../src/transfer/EnhancedSecureFileTransfer.js';
function createSystem() {
const manager = {
dataChannel: { onmessage: null, send() {}, readyState: 'open' },
isVerified: true,
fileTransferSystem: null,
isConnected: () => true
};
return new EnhancedSecureFileTransfer(manager, null, null, null, null, null);
}
function file(name, type, size = 1024) {
return { name, type, size };
}
const system = createSystem();
// Allowed files
assert.equal(system.validateFile(file('photo.png', 'image/png')).isValid, true);
assert.equal(system.validateFile(file('report.pdf', 'application/pdf')).isValid, true);
assert.equal(system.validateFile(file('notes.txt', 'text/plain')).isValid, true);
assert.equal(system.validateFile(file('bundle.zip', 'application/zip')).isValid, true);
// Explicitly blocked extensions
for (const name of ['run.exe', 'boot.bat', 'shell.sh', 'payload.js', 'page.html', 'vector.svg']) {
assert.equal(system.validateFile(file(name, 'application/octet-stream')).isValid, false, name);
}
// MIME spoofing: safe extension with unsafe MIME and unsafe extension with safe MIME are blocked.
assert.equal(system.validateFile(file('photo.png', 'application/x-msdownload')).isValid, false);
assert.equal(system.validateFile(file('payload.exe', 'image/png')).isValid, false);
// Missing MIME is unsafe.
assert.equal(system.validateFile(file('photo.png', '')).isValid, false);
// Uppercase extension bypass is blocked.
assert.equal(system.validateFile(file('PAYLOAD.EXE', 'application/octet-stream')).isValid, false);
console.log('File type allowlist tests passed');
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
globalThis.window = {
DEBUG_MODE: true,
DEVELOPMENT_MODE: true,
webpackHotUpdate: {},
location: {
hostname: 'localhost',
search: '?debug'
}
};
const { EnhancedSecureCryptoUtils } = await import('../src/crypto/EnhancedSecureCryptoUtils.js');
window.EnhancedSecureCryptoUtils = EnhancedSecureCryptoUtils;
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
function createManager() {
return {
delivered: [],
_debugMode: false,
_secureLog() {},
_sanitizeIncomingChatMessage: EnhancedSecureWebRTCManager.prototype._sanitizeIncomingChatMessage,
onMessage(message, type) {
this.delivered.push({ message, type });
}
};
}
// Normal text survives unchanged.
{
const manager = createManager();
EnhancedSecureWebRTCManager.prototype.deliverMessageToUI.call(manager, 'hello secure world', 'received');
assert.deepEqual(manager.delivered[0], { message: 'hello secure world', type: 'received' });
}
// XSS-like and HTML payloads are sanitized before UI delivery.
{
const manager = createManager();
EnhancedSecureWebRTCManager.prototype.deliverMessageToUI.call(manager, '<script>alert(1)</script>Hello <b>peer</b>', 'received');
assert.deepEqual(manager.delivered[0], { message: 'Hello peer', type: 'received' });
}
// Event-handler and protocol strings are removed before reaching React state.
{
const manager = createManager();
EnhancedSecureWebRTCManager.prototype.deliverMessageToUI.call(manager, '<img src=x onerror=alert(1)> javascript:alert(1)', 'received');
assert.deepEqual(manager.delivered[0], { message: 'alert(1)', type: 'received' });
}
// Outgoing/system messages are not altered by the incoming-message gate.
{
const manager = createManager();
EnhancedSecureWebRTCManager.prototype.deliverMessageToUI.call(manager, '<b>system</b>', 'system');
assert.deepEqual(manager.delivered[0], { message: '<b>system</b>', type: 'system' });
}
console.log('Incoming message sanitization tests passed');
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
globalThis.window = { EnhancedSecureCryptoUtils: {} };
const { SecureIndexedDBWrapper, SecurePersistentKeyStorage } = await import('../src/network/EnhancedSecureWebRTCManager.js');
class FakeMasterKeyManager {
isUnlocked() { return true; }
async unlock() {}
async encryptBytes(bytes) {
return { encryptedData: Uint8Array.from(bytes, byte => byte ^ 0xaa), iv: new Uint8Array(12).fill(7) };
}
async decryptBytes(bytes, iv) {
if (!iv || iv[0] !== 7) throw new Error('bad iv');
return Uint8Array.from(bytes, byte => byte ^ 0xaa);
}
}
class FakeDB {
constructor(records = []) {
this.records = new Map(records.map(record => [record.keyId, record]));
}
async initialize() {}
async listKeys() { return [...this.records.values()]; }
async getKeyMetadataRecord(keyId) { return this.records.get(keyId) || null; }
async putKeyMetadataRecord(record) { this.records.set(record.keyId, record); }
}
class FakeIndexedDBConnection {
constructor() {
this.records = new Map();
}
transaction(storeNames) {
const transaction = {
objectStore: (name) => ({
put: (record) => {
this.records.set(name, record);
queueMicrotask(() => transaction.oncomplete?.());
return {};
}
}),
oncomplete: null,
onerror: null
};
return transaction;
}
}
// New metadata is encrypted and sensitive fields are not plaintext.
{
const db = new FakeDB();
const storage = new SecurePersistentKeyStorage(new FakeMasterKeyManager(), db);
const encrypted = await storage._encryptMetadata({
created: 111,
lastAccessed: 222,
sessionId: 'session-secret',
peerId: 'peer-secret'
});
await db.putKeyMetadataRecord({ keyId: 'k1', ...encrypted });
const raw = db.records.get('k1');
assert.equal(raw.created, undefined);
assert.equal(raw.lastAccessed, undefined);
assert.equal(raw.sessionId, undefined);
assert.ok(Array.isArray(raw.encryptedMetadata));
}
// Old plaintext metadata can be read and is migrated.
{
const db = new FakeDB([{ keyId: 'legacy', created: 1, lastAccessed: 2, sessionId: 'old-session' }]);
const storage = new SecurePersistentKeyStorage(new FakeMasterKeyManager(), db);
const listed = await storage.listStoredKeys();
assert.equal(listed[0].sessionId, 'old-session');
const migrated = db.records.get('legacy');
assert.equal(migrated.sessionId, undefined);
assert.ok(migrated.encryptedMetadata);
}
// Corrupted encrypted metadata fails safely and is not exposed.
{
const db = new FakeDB([{ keyId: 'bad', encryptedMetadata: [1, 2, 3], metadataIv: [0] }]);
const storage = new SecurePersistentKeyStorage(new FakeMasterKeyManager(), db);
const listed = await storage.listStoredKeys();
assert.deepEqual(listed, []);
}
// Plaintext timestamp fields are avoidable in the encrypted envelope.
{
const storage = new SecurePersistentKeyStorage(new FakeMasterKeyManager(), new FakeDB());
const encrypted = await storage._encryptMetadata({ created: 10, lastAccessed: 20, usageCount: 3 });
assert.equal('created' in encrypted, false);
assert.equal('lastAccessed' in encrypted, false);
assert.equal('usageCount' in encrypted, false);
}
// Avoidable timestamps are not left plaintext in new IndexedDB records.
{
const wrapper = new SecureIndexedDBWrapper();
wrapper.db = new FakeIndexedDBConnection();
await wrapper.storeEncryptedKey(
'k2',
new Uint8Array([1]),
new Uint8Array([2]),
{ name: 'AES-GCM' },
['encrypt'],
'secret',
{ metadataVersion: 1, encryptedMetadata: [3], metadataIv: [4] }
);
assert.equal('timestamp' in wrapper.db.records.get(wrapper.KEYS_STORE), false);
assert.equal('created' in wrapper.db.records.get(wrapper.METADATA_STORE), false);
assert.equal('lastAccessed' in wrapper.db.records.get(wrapper.METADATA_STORE), false);
}
console.log('IndexedDB metadata encryption tests passed');
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
let compareCalls = 0;
globalThis.window = {
EnhancedSecureCryptoUtils: {
constantTimeCompare(a, b) {
compareCalls += 1;
return a === b;
}
}
};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
function createFakeManager() {
const sent = [];
return {
sent,
verificationCode: 'A1-B2-C3',
sasValidationAttempts: 0,
localVerificationConfirmed: false,
remoteVerificationConfirmed: false,
bothVerificationsConfirmed: false,
disconnected: false,
_validateSASCode: EnhancedSecureWebRTCManager.prototype._validateSASCode,
_secureLog() {},
deliverMessageToUI() {},
disconnect() {
this.disconnected = true;
},
dataChannel: {
send(payload) {
sent.push(JSON.parse(payload));
}
},
_checkBothVerificationsConfirmed() {},
processMessageQueue() {}
};
}
// testSASNormalization
{
const manager = createFakeManager();
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, 'a1 b2 c3'), true);
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, 'A1B2C3'), true);
}
// testConstantTimeCompare
{
const manager = createFakeManager();
compareCalls = 0;
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, 'A1-B2-C3'), true);
assert.equal(compareCalls, 1);
}
// testInvalidInputs
{
const manager = createFakeManager();
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, null), false);
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, 'A1B2'), false);
assert.equal(EnhancedSecureWebRTCManager.prototype._validateSASCode.call(manager, 'FFFFFF'), false);
}
// three failed attempts disconnect; a correct attempt signals only after validation
{
const manager = createFakeManager();
for (let i = 0; i < 2; i += 1) {
assert.throws(
() => EnhancedSecureWebRTCManager.prototype.confirmVerification.call(manager, 'FFFFFF'),
/SAS_MISMATCH/
);
}
assert.equal(manager.disconnected, false);
assert.throws(
() => EnhancedSecureWebRTCManager.prototype.confirmVerification.call(manager, 'FFFFFF'),
/SAS_MAX_ATTEMPTS/
);
assert.equal(manager.disconnected, true);
const validManager = createFakeManager();
EnhancedSecureWebRTCManager.prototype.confirmVerification.call(validManager, 'a1 b2 c3');
assert.equal(validManager.localVerificationConfirmed, true);
assert.equal(validManager.sent[0].type, 'verification_confirmed');
assert.equal(validManager.sent[0].data.verificationMethod, 'MANUAL_SAS_ENTRY');
}
console.log('SAS verification tests passed');
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
globalThis.window = {
EnhancedSecureCryptoUtils: {
secureLog: { log() {} }
}
};
globalThis.CustomEvent = class CustomEvent {
constructor(type, init) {
this.type = type;
this.detail = init?.detail;
}
};
globalThis.document = { dispatchEvent() {} };
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
const realSetTimeout = globalThis.setTimeout;
const realClearTimeout = globalThis.clearTimeout;
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
const timers = [];
globalThis.setTimeout = (callback, delay) => {
const timer = { kind: 'timeout', callback, delay, cleared: false };
timers.push(timer);
return timer;
};
globalThis.clearTimeout = (timer) => {
if (timer) timer.cleared = true;
};
globalThis.setInterval = (callback, delay) => {
const timer = { kind: 'interval', callback, delay, cleared: false };
timers.push(timer);
return timer;
};
globalThis.clearInterval = (timer) => {
if (timer) timer.cleared = true;
};
try {
// Periodic log cleanup is tracked and cleared with the existing timer system.
{
const manager = {
_activeTimers: new Set(),
_startKeySecurityMonitoring() {},
_verifyAPIIntegrity() { return true; },
_startSecurityMonitoring() {},
_cleanupLogs() {},
_secureLog() {}
};
manager._trackActiveTimer = EnhancedSecureWebRTCManager.prototype._trackActiveTimer;
EnhancedSecureWebRTCManager.prototype._finalizeSecureInitialization.call(manager);
assert.equal(manager._activeTimers.size, 1);
const logTimer = manager._logCleanupInterval;
EnhancedSecureWebRTCManager.prototype._stopAllTimers.call(manager);
assert.equal(logTimer.cleared, true);
}
// Deferred file-transfer retries are tracked, cleared, and cannot re-run after session shutdown.
{
let initCalls = 0;
const manager = {
_sessionAlive: true,
_activeTimers: new Set(),
_fileTransferInitRetryTimers: new Set(),
fileTransferSystem: null,
dataChannel: { readyState: 'open' },
isVerified: false,
_secureLog() {},
_trackActiveTimer: EnhancedSecureWebRTCManager.prototype._trackActiveTimer,
_untrackActiveTimer: EnhancedSecureWebRTCManager.prototype._untrackActiveTimer,
_scheduleFileTransferInitRetry: EnhancedSecureWebRTCManager.prototype._scheduleFileTransferInitRetry,
initializeFileTransfer() {
initCalls += 1;
}
};
EnhancedSecureWebRTCManager.prototype.initializeFileTransfer.call(manager);
const retryTimer = [...manager._fileTransferInitRetryTimers][0];
manager._sessionAlive = false;
EnhancedSecureWebRTCManager.prototype._stopAllTimers.call(manager);
if (!retryTimer.cleared) retryTimer.callback();
assert.equal(retryTimer.cleared, true);
assert.equal(manager._fileTransferInitRetryTimers.size, 0);
assert.equal(initCalls, 0);
}
// Repeated peer-disconnect notifications schedule only one delayed cleanup, and cleanup is cancelled on disconnect.
{
let disconnectCalls = 0;
const manager = {
_sessionAlive: true,
_activeTimers: new Set(),
peerDisconnectNotificationSent: false,
_peerDisconnectCleanupTimer: null,
deliverMessageToUI() {},
onStatusChange() {},
stopHeartbeat() {},
onKeyExchange() {},
onVerificationRequired() {},
disconnect() { disconnectCalls += 1; },
_secureLog() {},
_trackActiveTimer: EnhancedSecureWebRTCManager.prototype._trackActiveTimer,
_untrackActiveTimer: EnhancedSecureWebRTCManager.prototype._untrackActiveTimer
};
EnhancedSecureWebRTCManager.prototype.handlePeerDisconnectNotification.call(manager, { reason: 'connection_lost' });
EnhancedSecureWebRTCManager.prototype.handlePeerDisconnectNotification.call(manager, { reason: 'connection_lost' });
const scheduled = timers.filter(timer => timer.kind === 'timeout' && timer.delay === 2000);
assert.equal(scheduled.length, 1);
manager._sessionAlive = false;
EnhancedSecureWebRTCManager.prototype._stopAllTimers.call(manager);
if (!scheduled[0].cleared) scheduled[0].callback();
assert.equal(scheduled[0].cleared, true);
assert.equal(disconnectCalls, 0);
}
} finally {
globalThis.setTimeout = realSetTimeout;
globalThis.clearTimeout = realClearTimeout;
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
}
console.log('Timer lifecycle tests passed');
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
globalThis.window = {
EnhancedSecureCryptoUtils: {},
DEBUG_MODE: true,
DEVELOPMENT_MODE: true,
location: { hostname: 'localhost', search: '?debug' },
webpackHotUpdate: {}
};
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
function fake(config = {}) {
return {
_config: {
webrtc: {
relayOnly: config.relayOnly ?? false,
iceServers: config.iceServers ?? [{ urls: 'stun:stun.example.test:3478' }]
}
},
_ipLeakWarningShown: false,
delivered: [],
deliverMessageToUI(message, type) {
this.delivered.push({ message, type });
},
_hasTurnServer: EnhancedSecureWebRTCManager.prototype._hasTurnServer
};
}
// Default mode preserves current behavior.
{
const manager = fake();
const config = EnhancedSecureWebRTCManager.prototype._buildPeerConnectionConfig.call(manager);
assert.equal(config.iceTransportPolicy, undefined);
assert.equal(config.iceServers[0].urls, 'stun:stun.example.test:3478');
}
// Privacy mode uses relay-only transport.
{
const manager = fake({ relayOnly: true, iceServers: [{ urls: 'turn:turn.example.test:3478' }] });
const config = EnhancedSecureWebRTCManager.prototype._buildPeerConnectionConfig.call(manager);
assert.equal(config.iceTransportPolicy, 'relay');
}
// Missing TURN warns clearly.
{
const manager = fake();
EnhancedSecureWebRTCManager.prototype._warnIfTurnMissing.call(manager);
assert.match(manager.delivered[0].message, /may expose IP addresses/i);
}
// STUN-only config does not claim IP protection, even with privacy mode selected.
{
const manager = fake({ relayOnly: true, iceServers: [{ urls: 'stun:stun.example.test:3478' }] });
assert.equal(EnhancedSecureWebRTCManager.prototype._hasTurnServer.call(manager), false);
EnhancedSecureWebRTCManager.prototype._warnIfTurnMissing.call(manager);
assert.match(manager.delivered[0].message, /STUN alone does not hide IP addresses/i);
}
console.log('WebRTC privacy mode tests passed');