507 lines
19 KiB
JavaScript
507 lines
19 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { webcrypto } from 'node:crypto';
|
|
|
|
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() {}
|
|
};
|
|
}
|
|
|
|
function createSASManager() {
|
|
return {
|
|
_secureLog() {}
|
|
};
|
|
}
|
|
|
|
function createVerificationReadinessManager({
|
|
localDescription = { type: 'answer' },
|
|
remoteDescription = { type: 'offer' },
|
|
dataChannelState = 'connecting',
|
|
verificationCode = 'A1-B2-C3',
|
|
localFingerprint = 'AA:BB',
|
|
remoteFingerprint = 'CC:DD'
|
|
} = {}) {
|
|
const notifications = [];
|
|
return {
|
|
peerConnection: { localDescription, remoteDescription },
|
|
dataChannel: { readyState: dataChannelState },
|
|
verificationCode,
|
|
_sasLocalFingerprint: localFingerprint,
|
|
_sasRemoteFingerprint: remoteFingerprint,
|
|
notifications,
|
|
_isVerificationReady: EnhancedSecureWebRTCManager.prototype._isVerificationReady,
|
|
onStatusChange(status) {
|
|
notifications.push({ kind: 'status', value: status });
|
|
},
|
|
onVerificationRequired(code) {
|
|
notifications.push({ kind: 'verification', value: code });
|
|
}
|
|
};
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
// SAS is deterministic for the same key material and normalized fingerprints,
|
|
// and changes when either fingerprint changes.
|
|
{
|
|
const manager = createSASManager();
|
|
const keyMaterial = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
const computeSAS = EnhancedSecureWebRTCManager.prototype._computeSAS;
|
|
|
|
const baseline = await computeSAS.call(manager, keyMaterial, ' AA:BB ', 'CC:DD');
|
|
const sameInputsNormalized = await computeSAS.call(manager, keyMaterial, 'aa:bb', ' cc:dd ');
|
|
const changedLocal = await computeSAS.call(manager, keyMaterial, 'AA:BC', 'CC:DD');
|
|
const changedRemote = await computeSAS.call(manager, keyMaterial, 'AA:BB', 'CC:DE');
|
|
|
|
assert.equal(baseline, sameInputsNormalized);
|
|
assert.notEqual(baseline, changedLocal);
|
|
assert.notEqual(baseline, changedRemote);
|
|
}
|
|
|
|
// SAS rejects non-string or empty fingerprints instead of allowing JS coercion.
|
|
{
|
|
const manager = createSASManager();
|
|
const keyMaterial = new Uint8Array([1, 2, 3, 4]);
|
|
const computeSAS = EnhancedSecureWebRTCManager.prototype._computeSAS;
|
|
const invalidFingerprints = [{ fingerprint: 'aa' }, ['aa'], null, ''];
|
|
|
|
for (const invalidFingerprint of invalidFingerprints) {
|
|
await assert.rejects(
|
|
() => computeSAS.call(manager, keyMaterial, invalidFingerprint, 'CC:DD'),
|
|
/Security error: localFP must be a non-empty DTLS fingerprint string/
|
|
);
|
|
await assert.rejects(
|
|
() => computeSAS.call(manager, keyMaterial, 'AA:BB', invalidFingerprint),
|
|
/Security error: remoteFP must be a non-empty DTLS fingerprint string/
|
|
);
|
|
}
|
|
}
|
|
|
|
// The salt is built only from normalized fingerprint strings.
|
|
{
|
|
const manager = createSASManager();
|
|
const keyMaterial = new Uint8Array([9, 8, 7, 6]);
|
|
let capturedSalt = '';
|
|
const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
|
|
|
Object.defineProperty(globalThis, 'crypto', {
|
|
configurable: true,
|
|
value: {
|
|
subtle: {
|
|
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
|
deriveBits: async (params, ...args) => {
|
|
capturedSalt = new TextDecoder().decode(params.salt);
|
|
return webcrypto.subtle.deriveBits(params, ...args);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await EnhancedSecureWebRTCManager.prototype._computeSAS.call(manager, keyMaterial, ' AA:BB ', 'CC:DD ');
|
|
assert.equal(capturedSalt, 'webrtc-sas|aa:bb|cc:dd');
|
|
assert.equal(capturedSalt.includes('[object Object]'), false);
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'crypto', originalCryptoDescriptor);
|
|
}
|
|
}
|
|
|
|
// Extraction returns a deterministic primary string for SAS binding.
|
|
{
|
|
const manager = createSASManager();
|
|
const sdp = [
|
|
'v=0',
|
|
'a=fingerprint:sha-512 FF:EE',
|
|
'a=fingerprint:sha-256 BB:BB',
|
|
'a=fingerprint:sha-256 AA:AA'
|
|
].join('\r\n');
|
|
|
|
assert.equal(
|
|
EnhancedSecureWebRTCManager.prototype._extractDTLSFingerprintFromSDP.call(manager, sdp),
|
|
'AA:AA'
|
|
);
|
|
}
|
|
|
|
// ICE diagnostics classify candidate types so connectivity failures are visible.
|
|
{
|
|
const manager = createSASManager();
|
|
const sdp = [
|
|
'v=0',
|
|
'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host',
|
|
'a=candidate:2 1 UDP 1686052607 203.0.113.10 40000 typ srflx raddr 192.168.1.2 rport 54400',
|
|
'a=candidate:3 1 UDP 41819902 198.51.100.20 50000 typ relay raddr 0.0.0.0 rport 0',
|
|
'a=candidate:4 1 UDP 1518280447 198.51.100.30 60000 typ prflx',
|
|
'a=candidate:5 1 UDP 1518280447 198.51.100.40 61000 generation 0'
|
|
].join('\r\n');
|
|
|
|
assert.deepEqual(
|
|
EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call(manager, sdp),
|
|
{ total: 5, host: 1, srflx: 1, relay: 1, prflx: 1, unknown: 1 }
|
|
);
|
|
}
|
|
|
|
// Manual exchange must not treat an ICE gathering timeout as completion.
|
|
{
|
|
const listeners = new Map();
|
|
const manager = {
|
|
peerConnection: {
|
|
iceGatheringState: 'gathering',
|
|
addEventListener(eventName, handler) {
|
|
listeners.set(eventName, handler);
|
|
},
|
|
removeEventListener(eventName) {
|
|
listeners.delete(eventName);
|
|
}
|
|
}
|
|
};
|
|
|
|
const originalTimeout = EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT;
|
|
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = 0;
|
|
try {
|
|
assert.equal(
|
|
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager),
|
|
false
|
|
);
|
|
} finally {
|
|
EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = originalTimeout;
|
|
}
|
|
}
|
|
|
|
// A timed-out ICE gathering can still yield usable candidates for manual export.
|
|
{
|
|
const summary = EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call(
|
|
createSASManager(),
|
|
'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host\r\n'
|
|
);
|
|
assert.equal(summary.total > 0, true);
|
|
}
|
|
|
|
// ICE gathering resolves positively only after the peer reports completion.
|
|
{
|
|
let listener = null;
|
|
const manager = {
|
|
peerConnection: {
|
|
iceGatheringState: 'gathering',
|
|
addEventListener(_eventName, handler) {
|
|
listener = handler;
|
|
},
|
|
removeEventListener() {}
|
|
}
|
|
};
|
|
|
|
const gathering = EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager);
|
|
manager.peerConnection.iceGatheringState = 'complete';
|
|
listener();
|
|
assert.equal(await gathering, true);
|
|
}
|
|
|
|
// ICE failure diagnostics summarize candidate-pair states without crashing.
|
|
{
|
|
const reports = new Map([
|
|
['local-1', { id: 'local-1', type: 'local-candidate', candidateType: 'host', protocol: 'udp', address: '192.168.1.2', port: 5000 }],
|
|
['remote-1', { id: 'remote-1', type: 'remote-candidate', candidateType: 'srflx', protocol: 'udp', address: '203.0.113.10', port: 6000 }],
|
|
['pair-1', { id: 'pair-1', type: 'candidate-pair', state: 'failed', nominated: false, writable: false, bytesSent: 0, bytesReceived: 0, localCandidateId: 'local-1', remoteCandidateId: 'remote-1' }]
|
|
]);
|
|
|
|
const manager = {
|
|
peerConnection: {
|
|
async getStats() {
|
|
return reports;
|
|
}
|
|
}
|
|
};
|
|
|
|
assert.deepEqual(
|
|
await EnhancedSecureWebRTCManager.prototype._collectIceFailureDiagnostics.call(manager),
|
|
{
|
|
pairCount: 1,
|
|
states: { failed: 1 },
|
|
pairs: [{
|
|
state: 'failed',
|
|
nominated: false,
|
|
writable: false,
|
|
bytesSent: 0,
|
|
bytesReceived: 0,
|
|
currentRoundTripTime: null,
|
|
local: {
|
|
type: 'local-candidate',
|
|
candidateType: 'host',
|
|
protocol: 'udp',
|
|
address: '192.168.1.2',
|
|
port: 5000,
|
|
networkType: null
|
|
},
|
|
remote: {
|
|
type: 'remote-candidate',
|
|
candidateType: 'srflx',
|
|
protocol: 'udp',
|
|
address: '203.0.113.10',
|
|
port: 6000,
|
|
networkType: null
|
|
}
|
|
}]
|
|
}
|
|
);
|
|
}
|
|
|
|
// Remote SDP candidate summaries use the same parser as local diagnostics.
|
|
{
|
|
const sdp = [
|
|
'v=0',
|
|
'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host',
|
|
'a=candidate:2 1 UDP 1686052607 203.0.113.10 40000 typ srflx'
|
|
].join('\r\n');
|
|
assert.deepEqual(
|
|
EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call(createSASManager(), sdp),
|
|
{ total: 2, host: 1, srflx: 1, relay: 0, prflx: 0, unknown: 0 }
|
|
);
|
|
}
|
|
|
|
// ICE candidate details are redacted but still expose routing-relevant classes.
|
|
{
|
|
const sdp = [
|
|
'v=0',
|
|
'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host',
|
|
'a=candidate:2 1 UDP 1686052607 203.0.113.10 40000 typ srflx',
|
|
'a=candidate:3 1 TCP 1518280447 abcdef.local 9 typ host tcptype passive'
|
|
].join('\r\n');
|
|
|
|
assert.deepEqual(
|
|
EnhancedSecureWebRTCManager.prototype._describeIceCandidatesInSDP.call(createSASManager(), sdp),
|
|
[
|
|
{ candidateType: 'host', protocol: 'udp', addressKind: 'private-ipv4', portPresent: true, tcpType: null },
|
|
{ candidateType: 'srflx', protocol: 'udp', addressKind: 'public-ipv4', portPresent: true, tcpType: null },
|
|
{ candidateType: 'host', protocol: 'tcp', addressKind: 'mdns', portPresent: true, tcpType: 'passive' }
|
|
]
|
|
);
|
|
}
|
|
|
|
// ICE diagnostics include a copyable JSON string so browser logs do not hide
|
|
// candidate details behind collapsed DevTools objects.
|
|
{
|
|
const logs = [];
|
|
const originalConsoleInfo = console.info;
|
|
console.info = (...args) => logs.push(args);
|
|
try {
|
|
const manager = {
|
|
_summarizeIceCandidatesInSDP: EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP,
|
|
_describeIceCandidatesInSDP: EnhancedSecureWebRTCManager.prototype._describeIceCandidatesInSDP,
|
|
_logIceCandidateDiagnostics: EnhancedSecureWebRTCManager.prototype._logIceCandidateDiagnostics
|
|
};
|
|
manager._logIceCandidateDiagnostics('test candidates', 'a=candidate:1 1 UDP 1 192.168.1.2 5000 typ host', {
|
|
signalingState: 'stable'
|
|
});
|
|
|
|
assert.equal(logs[0][0], '[SecureBit ICE] test candidates');
|
|
assert.equal(typeof logs[0][1].candidateDetailsJson, 'string');
|
|
assert.match(logs[0][1].candidateDetailsJson, /private-ipv4/);
|
|
assert.equal(logs[0][1].signalingState, 'stable');
|
|
} finally {
|
|
console.info = originalConsoleInfo;
|
|
}
|
|
}
|
|
|
|
// Remote mDNS-only candidates are surfaced as a user-visible TURN warning.
|
|
{
|
|
const messages = [];
|
|
const manager = {
|
|
_secureLog() {},
|
|
deliverMessageToUI(message, type) {
|
|
messages.push({ message, type });
|
|
},
|
|
_summarizeIceCandidatesInSDP: EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP,
|
|
_describeIceCandidatesInSDP: EnhancedSecureWebRTCManager.prototype._describeIceCandidatesInSDP,
|
|
_hasOnlyMdnsHostCandidates: EnhancedSecureWebRTCManager.prototype._hasOnlyMdnsHostCandidates,
|
|
_warnIfRemoteCandidatesNeedRelay: EnhancedSecureWebRTCManager.prototype._warnIfRemoteCandidatesNeedRelay
|
|
};
|
|
const mdnsOnlySdp = 'a=candidate:1 1 UDP 1 abcdef.local 5000 typ host';
|
|
const srflxSdp = 'a=candidate:1 1 UDP 1 203.0.113.10 5000 typ srflx';
|
|
|
|
assert.equal(manager._hasOnlyMdnsHostCandidates(mdnsOnlySdp), true);
|
|
assert.equal(manager._warnIfRemoteCandidatesNeedRelay('answer', mdnsOnlySdp), true);
|
|
assert.equal(messages[0].type, 'system');
|
|
assert.match(messages[0].message, /TURN is configured/i);
|
|
assert.equal(manager._hasOnlyMdnsHostCandidates(srflxSdp), false);
|
|
}
|
|
|
|
// Pending offer context preserves the creator's manual-exchange salt until the
|
|
// answer is applied, even if transient ICE/UI state temporarily loses it.
|
|
{
|
|
const manager = {
|
|
sessionSalt: Array.from({ length: 64 }, (_, index) => index),
|
|
sessionId: 'session-a',
|
|
connectionId: 'connection-a',
|
|
keyFingerprint: 'AA:BB',
|
|
_secureLog() {},
|
|
_secureWipeMemory() {},
|
|
_storePendingOfferContext: EnhancedSecureWebRTCManager.prototype._storePendingOfferContext,
|
|
_restorePendingOfferContextIfNeeded: EnhancedSecureWebRTCManager.prototype._restorePendingOfferContextIfNeeded,
|
|
_clearPendingOfferContext: EnhancedSecureWebRTCManager.prototype._clearPendingOfferContext
|
|
};
|
|
|
|
manager._storePendingOfferContext();
|
|
manager.sessionSalt = null;
|
|
manager.sessionId = null;
|
|
manager.connectionId = null;
|
|
manager.keyFingerprint = null;
|
|
|
|
assert.equal(manager._restorePendingOfferContextIfNeeded(), true);
|
|
assert.equal(manager.sessionSalt.length, 64);
|
|
assert.equal(manager.sessionId, 'session-a');
|
|
assert.equal(manager.connectionId, 'connection-a');
|
|
assert.equal(manager.keyFingerprint, 'AA:BB');
|
|
|
|
manager._clearPendingOfferContext();
|
|
manager.sessionSalt = null;
|
|
assert.equal(manager._restorePendingOfferContextIfNeeded(), false);
|
|
}
|
|
|
|
// Joining with an offer and generating an answer does not open verification
|
|
// before the answer has been applied by the creator and the channel opens.
|
|
{
|
|
const joiner = createVerificationReadinessManager({
|
|
dataChannelState: 'connecting'
|
|
});
|
|
assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(joiner), false);
|
|
assert.equal(
|
|
EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(joiner),
|
|
false
|
|
);
|
|
assert.deepEqual(joiner.notifications, []);
|
|
}
|
|
|
|
// The creator has applied the answer only once both descriptions exist; even
|
|
// then verification waits for a real ready transport.
|
|
{
|
|
const creatorBeforeAnswer = createVerificationReadinessManager({
|
|
remoteDescription: null,
|
|
dataChannelState: 'open'
|
|
});
|
|
assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(creatorBeforeAnswer), false);
|
|
|
|
const creatorAfterAnswerBeforeOpen = createVerificationReadinessManager({
|
|
dataChannelState: 'connecting'
|
|
});
|
|
assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(creatorAfterAnswerBeforeOpen), false);
|
|
}
|
|
|
|
// Verification opens only after negotiated descriptions, open data channel, and
|
|
// valid SAS fingerprint material are all present.
|
|
{
|
|
const missingFingerprint = createVerificationReadinessManager({
|
|
dataChannelState: 'open',
|
|
remoteFingerprint: ''
|
|
});
|
|
assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(missingFingerprint), false);
|
|
|
|
const ready = createVerificationReadinessManager({
|
|
dataChannelState: 'open'
|
|
});
|
|
assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(ready), true);
|
|
assert.equal(
|
|
EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(ready),
|
|
true
|
|
);
|
|
assert.deepEqual(ready.notifications, [
|
|
{ kind: 'status', value: 'verifying' },
|
|
{ kind: 'verification', value: 'A1-B2-C3' }
|
|
]);
|
|
|
|
// Existing happy path stays idempotent after the UI is opened once.
|
|
EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(ready);
|
|
assert.equal(ready.notifications.length, 2);
|
|
}
|
|
|
|
// SDP diagnostics distinguish candidate-less exports from usable manual payloads.
|
|
{
|
|
assert.equal(
|
|
EnhancedSecureWebRTCManager.prototype._countIceCandidatesInSDP.call({}, 'v=0\r\na=mid:0'),
|
|
0
|
|
);
|
|
assert.equal(
|
|
EnhancedSecureWebRTCManager.prototype._countIceCandidatesInSDP.call(
|
|
{},
|
|
'v=0\r\na=candidate:1 1 udp 1 192.0.2.1 1234 typ host\r\na=candidate:2 1 udp 1 198.51.100.1 2345 typ srflx'
|
|
),
|
|
2
|
|
);
|
|
}
|
|
|
|
console.log('SAS verification tests passed');
|