release: prepare v4.8.5 security hardening release
This commit is contained in:
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user