fix: bind SAS verification to DTLS fingerprint strings
This commit is contained in:
@@ -3526,7 +3526,7 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
|||||||
while ((match = fingerprintRegex.exec(sdp)) !== null) {
|
while ((match = fingerprintRegex.exec(sdp)) !== null) {
|
||||||
fingerprints.push({
|
fingerprints.push({
|
||||||
algorithm: match[1].toLowerCase(),
|
algorithm: match[1].toLowerCase(),
|
||||||
fingerprint: match[2].toLowerCase().replace(/:/g, '')
|
fingerprint: match[2].trim()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3536,7 +3536,7 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
|||||||
while ((match = altFingerprintRegex.exec(sdp)) !== null) {
|
while ((match = altFingerprintRegex.exec(sdp)) !== null) {
|
||||||
fingerprints.push({
|
fingerprints.push({
|
||||||
algorithm: match[1].toLowerCase(),
|
algorithm: match[1].toLowerCase(),
|
||||||
fingerprint: match[2].toLowerCase().replace(/:/g, '')
|
fingerprint: match[2].trim()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3549,14 +3549,24 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
|||||||
throw new Error('No DTLS fingerprints found in SDP');
|
throw new Error('No DTLS fingerprints found in SDP');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer SHA-256 fingerprints
|
// Prefer SHA-256, then choose lexicographically so multiple SDP
|
||||||
const sha256Fingerprint = fingerprints.find(fp => fp.algorithm === 'sha-256');
|
// fingerprint lines resolve to the same deterministic primary value.
|
||||||
if (sha256Fingerprint) {
|
const primaryFingerprint = [...fingerprints].sort((a, b) => {
|
||||||
return sha256Fingerprint.fingerprint;
|
const aIsSha256 = a.algorithm === 'sha-256';
|
||||||
}
|
const bIsSha256 = b.algorithm === 'sha-256';
|
||||||
|
if (aIsSha256 !== bIsSha256) {
|
||||||
|
return aIsSha256 ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to first available fingerprint
|
const algorithmComparison = a.algorithm.localeCompare(b.algorithm);
|
||||||
return fingerprints[0].fingerprint;
|
if (algorithmComparison !== 0) {
|
||||||
|
return algorithmComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.fingerprint.localeCompare(b.fingerprint);
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
return primaryFingerprint.fingerprint;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this._secureLog('error', 'Failed to extract DTLS fingerprint from SDP', {
|
this._secureLog('error', 'Failed to extract DTLS fingerprint from SDP', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@@ -3625,18 +3635,28 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
|||||||
async _computeSAS(keyMaterialRaw, localFP, remoteFP) {
|
async _computeSAS(keyMaterialRaw, localFP, remoteFP) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (!keyMaterialRaw || !localFP || !remoteFP) {
|
if (!keyMaterialRaw) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!keyMaterialRaw) missing.push('keyMaterialRaw');
|
if (!keyMaterialRaw) missing.push('keyMaterialRaw');
|
||||||
if (!localFP) missing.push('localFP');
|
|
||||||
if (!remoteFP) missing.push('remoteFP');
|
|
||||||
throw new Error(`Missing required parameters for SAS computation: ${missing.join(', ')}`);
|
throw new Error(`Missing required parameters for SAS computation: ${missing.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
const normalizeFingerprintForSAS = (fingerprint, label) => {
|
||||||
|
if (typeof fingerprint !== 'string' || fingerprint.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Security error: ${label} must be a non-empty DTLS fingerprint string for SAS computation`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint.trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedLocalFP = normalizeFingerprintForSAS(localFP, 'localFP');
|
||||||
|
const normalizedRemoteFP = normalizeFingerprintForSAS(remoteFP, 'remoteFP');
|
||||||
|
|
||||||
const salt = enc.encode(
|
const salt = enc.encode(
|
||||||
'webrtc-sas|' + [localFP, remoteFP].sort().join('|')
|
'webrtc-sas|' + [normalizedLocalFP, normalizedRemoteFP].sort().join('|')
|
||||||
);
|
);
|
||||||
|
|
||||||
let keyBuffer;
|
let keyBuffer;
|
||||||
@@ -3682,8 +3702,8 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
|||||||
|
|
||||||
|
|
||||||
this._secureLog('info', 'SAS code computed successfully', {
|
this._secureLog('info', 'SAS code computed successfully', {
|
||||||
localFP: localFP.substring(0, 16) + '...',
|
localFP: normalizedLocalFP.substring(0, 16) + '...',
|
||||||
remoteFP: remoteFP.substring(0, 16) + '...',
|
remoteFP: normalizedRemoteFP.substring(0, 16) + '...',
|
||||||
sasLength: sasCode.length,
|
sasLength: sasCode.length,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { webcrypto } from 'node:crypto';
|
||||||
|
|
||||||
let compareCalls = 0;
|
let compareCalls = 0;
|
||||||
globalThis.window = {
|
globalThis.window = {
|
||||||
@@ -38,6 +39,12 @@ function createFakeManager() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSASManager() {
|
||||||
|
return {
|
||||||
|
_secureLog() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// testSASNormalization
|
// testSASNormalization
|
||||||
{
|
{
|
||||||
const manager = createFakeManager();
|
const manager = createFakeManager();
|
||||||
@@ -84,4 +91,85 @@ function createFakeManager() {
|
|||||||
assert.equal(validManager.sent[0].data.verificationMethod, 'MANUAL_SAS_ENTRY');
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('SAS verification tests passed');
|
console.log('SAS verification tests passed');
|
||||||
|
|||||||
Reference in New Issue
Block a user