Files
securebit-chat/tests/sas-verification.test.mjs
T
lockbitchat 27279ae7c6
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run
feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1
Adds the Double Ratchet (Signal's design) on top of the existing ECDH session
keys, so message protection no longer rests on one set of keys lasting the whole
conversation. Every message gets its own key, derived through a one-way function
and discarded after use, and each change of direction introduces a fresh ECDH
key pair that re-keys the session root.

The ratchet needed no handshake change: both peers already hold each other's
authenticated ECDH public key, and the safety code compared during verification
covers exactly those keys. Its root is derived from the existing shared secret
through its own branch of the key schedule.

Support is negotiated in the invitation and response and used only when both
sides have it; a peer on an earlier release falls back to per-session keys. The
security panel reports which of the two is actually in force.

Out-of-order delivery is supported within fixed bounds (512 skipped keys per
chain, 1024 retained, five-minute expiry), and inbound frames are authenticated
before any ratchet state is committed, so a malformed frame cannot desynchronise
a live session.

Also in this release:

- Verification is enforced as a gate, not a label: control frames (reconnection
  signalling, call setup, message deletion, delivery receipts) are acted on only
  after both peers have compared the safety code, and verified state is set in a
  single guarded place.
- Chat content reaches the interface through one authenticated path; an older,
  weaker inbound path was retired.
- The security panel measures what it displays — several checks previously
  returned a fixed result and now exercise the subsystem they describe.
- Invitation data is no longer kept in local storage, and entries left by earlier
  versions are cleared on first launch.
- View-once and disappearing messages no longer place their text in system
  notifications.
- Shared-secret buffers are overwritten once derivation completes; scanned QR
  codes are decompressed with a size limit; voice notes are validated against
  audio type and size budgets before skipping the consent prompt; the master
  password is collected by the app rather than a browser dialog.
- Connection setup no longer fails on networks where STUN/TURN are unreachable:
  it proceeds as soon as usable candidates exist and only waits while there are
  none.

Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise
re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds,
tamper resistance, negotiation fallback, and byte-level key-derivation
compatibility with 5.6.0.
2026-08-05 23:02:20 -04:00

506 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);
}
}
};
// Both budgets are passed explicitly: gathering now waits past the soft
// deadline while there is nothing at all to export (see
// ice-gathering-patience.test.mjs), so relying on the default hard ceiling
// here would stall this assertion for 25 s. The property under test is
// unchanged — a timeout must report false, never "complete".
assert.equal(
await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager, 0, 0),
false
);
}
// 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');