mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 20:15:06 +03:00
146 lines
5.4 KiB
TypeScript
146 lines
5.4 KiB
TypeScript
/**
|
|
* E2EE Worker for WebRTC Insertable Streams
|
|
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
|
* Uses RTP timestamps for IVs to handle out-of-order and dropped frames
|
|
*/
|
|
|
|
export interface FrameMetadata {
|
|
contributingSources?: number[];
|
|
mimeType?: string;
|
|
payloadType?: number;
|
|
rtpTimestamp: number;
|
|
synchronizationSource: number;
|
|
dependencies?: number[];
|
|
frameId?: number;
|
|
spatialIndex?: number;
|
|
temporalIndex?: number;
|
|
}
|
|
|
|
export interface EncodedFrame {
|
|
data: Uint8Array | ArrayBuffer;
|
|
timestamp?: number;
|
|
type?: string;
|
|
getMetadata?: () => FrameMetadata;
|
|
}
|
|
|
|
export interface WorkerOptions {
|
|
key: CryptoKey;
|
|
mode: 'encrypt' | 'decrypt';
|
|
sessionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Extract sequence number from encoded frame
|
|
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
|
* otherwise fall back to extracting from RTP header
|
|
*/
|
|
function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
|
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
|
// Frame data can differ between sender/receiver due to encoding differences
|
|
const ivBuffer = new ArrayBuffer(12);
|
|
const view = new DataView(ivBuffer);
|
|
|
|
if (encodedFrame.getMetadata) {
|
|
try {
|
|
const metadata = encodedFrame.getMetadata();
|
|
if (metadata && typeof metadata.rtpTimestamp === 'number') {
|
|
// Use ONLY RTP timestamp + sync source - these are identical on both sides
|
|
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
|
|
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
|
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
|
|
|
return ivBuffer;
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to get metadata:", e);
|
|
}
|
|
}
|
|
|
|
// Fallback: use timestamp only (no random to avoid desync)
|
|
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
|
|
view.setUint32(4, 0, false);
|
|
view.setUint32(8, 0, false);
|
|
return ivBuffer;
|
|
}
|
|
|
|
addEventListener("rtctransform", (event) => {
|
|
const { transformer } = event;
|
|
const { readable, writable } = transformer;
|
|
const { key, mode } = transformer.options as WorkerOptions;
|
|
|
|
const isEncrypting = mode === 'encrypt';
|
|
|
|
let frameCount = 0;
|
|
|
|
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
|
try {
|
|
const data = new Uint8Array(encodedFrame.data);
|
|
|
|
// Increment frame counter
|
|
frameCount++;
|
|
|
|
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
|
const iv = makeIV(encodedFrame);
|
|
|
|
// Ensure IV is properly typed
|
|
const ivArray = new Uint8Array(iv);
|
|
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
|
|
|
|
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
|
|
// This prevents most visual leakage while maintaining decodability
|
|
let headerSize = 0;
|
|
let payloadData: Uint8Array;
|
|
|
|
if (data.length > 20) {
|
|
// For video frames, preserve first 8 bytes for better codec compatibility
|
|
// This includes frame type, keyframe info, and basic header structure
|
|
headerSize = Math.min(8, Math.floor(data.length / 10));
|
|
payloadData = data.slice(headerSize);
|
|
} else {
|
|
// For small frames (likely audio), encrypt everything
|
|
payloadData = data;
|
|
}
|
|
|
|
// Encrypt the payload data
|
|
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
|
|
new Uint8Array(payloadBuffer).set(payloadData);
|
|
|
|
let encryptedPayload: ArrayBuffer;
|
|
if (isEncrypting) {
|
|
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
|
|
} else {
|
|
try {
|
|
encryptedPayload = await crypto.subtle.decrypt(params, key, payloadBuffer);
|
|
} catch (error) {
|
|
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, error);
|
|
return; // Drop the frame
|
|
}
|
|
}
|
|
|
|
// Reconstruct frame: minimal headers + encrypted payload
|
|
const encryptedArray = new Uint8Array(encryptedPayload);
|
|
const result = new Uint8Array(headerSize + encryptedArray.length);
|
|
|
|
if (headerSize > 0) {
|
|
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
|
|
result.set(encryptedArray, headerSize); // Add encrypted payload
|
|
} else {
|
|
result.set(encryptedArray, 0);
|
|
}
|
|
|
|
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
|
encodedFrame.data = result.buffer;
|
|
controller.enqueue(encodedFrame);
|
|
} catch (e) {
|
|
// FAIL SECURELY: Never send unencrypted frames
|
|
const data = new Uint8Array(encodedFrame.data);
|
|
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
|
return; // Drop the frame completely
|
|
}
|
|
}
|
|
|
|
readable
|
|
.pipeThrough(new TransformStream({ transform }))
|
|
.pipeTo(writable);
|
|
});
|