A group is an orchestration layer over the pairwise sessions the app already holds. It owns no transport and no shared key: every frame leaves over a chat that is already SAS-verified and already ratcheted, so a removed member simply stops being sent anything. Membership is a roster the admin signs, ordered by epoch, and the safety code is a commit-then-reveal round over every member's fingerprint and nonce. Delivery was the part that did not match its own description. The admin held a link to everyone and nobody else held a link to anybody, so the relay path — the documented fallback — was in fact the entire topology, and the admin going away partitioned the group. Now, once the code is confirmed, each pair without a link dials one over that relay path. The descriptors are compact enough to ride a group frame and are signed with the sender's group identity key, so the relaying member can drop a dial but cannot substitute one. The member with the smaller fingerprint dials, which is the whole glare protocol. Mesh links are released without a human comparing digits. Twenty-eight codes for a group of eight is not a check anyone performs; the guarantee moves rather than disappears, since the descriptor was signed by a key the signed roster names and the group code covers. markGroupLinkVerified refuses any session whose in-band exchange has not completed and whose peer has not proved possession of that key. An existing 1:1 chat between two members is adopted instead of re-dialled, via a probe bound to that session's own key fingerprint so it cannot be replayed onto another chat to impersonate its author. Security fix: g_hello was accepted on any session from anyone who knew the group id, so any member could publish an identity the admin never invited and have the admin sign and broadcast a roster containing it. It is now accepted only on a session an invitation went out on, which also confines it to a direct link. Mesh connections are kept out of the chat registry and muted from the document events the header listens to, so a routing detail cannot tear down the display of a conversation the user actually opened.
143 lines
5.6 KiB
JavaScript
143 lines
5.6 KiB
JavaScript
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 = {
|
|
// A manager announces its lifecycle through _dispatchAppEvent rather
|
|
// than touching `document` directly, so that a connection with no window
|
|
// of its own — a group's mesh link — can be muted. The real method is
|
|
// borrowed here so this test still exercises the path the app uses.
|
|
_emitGlobalEvents: true,
|
|
_dispatchAppEvent: EnhancedSecureWebRTCManager.prototype._dispatchAppEvent,
|
|
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'));
|
|
}
|
|
|
|
// A connection with no window of its own must not speak to the application.
|
|
//
|
|
// A group's mesh link is a routing detail, not a chat: it has no transcript and
|
|
// no header. Letting it broadcast peer-disconnect would have a link the user
|
|
// never opened reset the display of the chat they are actually looking at.
|
|
{
|
|
const dispatch = EnhancedSecureWebRTCManager.prototype._dispatchAppEvent;
|
|
const before = dispatchedEvents.length;
|
|
|
|
assert.equal(dispatch.call({ _emitGlobalEvents: false }, { type: 'peer-disconnect' }), false);
|
|
assert.equal(dispatchedEvents.length, before, 'a muted connection dispatches nothing');
|
|
|
|
dispatch.call({ _emitGlobalEvents: true }, { type: 'peer-disconnect' });
|
|
assert.equal(dispatchedEvents.length, before + 1, 'an ordinary chat still announces itself');
|
|
}
|
|
|
|
console.log('Disconnect cleanup tests passed');
|