switch to MLS for real, secure E2EE

This commit is contained in:
Zoe
2025-09-10 23:48:09 -05:00
parent f78a156f34
commit 7fca00698a
11 changed files with 297 additions and 221 deletions

View File

@@ -1,9 +1,10 @@
import { get } from 'svelte/store';
import { ws } from '../stores/websocketStore';
import { WebSocketMessageType } from '../types/websocket';
import { WebRTCPacketType, type KeyStore, type WebRTCPeerCallbacks } from '../types/webrtc';
import { clientKeyConfig } from '../shared/keyConfig';
import { WebRTCPacketType, type WebRTCPeerCallbacks } from '../types/webrtc';
import { browser } from '$app/environment';
import { createApplicationMessage, createCommit, createGroup, decodeMlsMessage, defaultCapabilities, defaultLifetime, emptyPskIndex, encodeMlsMessage, generateKeyPackage, getCiphersuiteFromName, getCiphersuiteImpl, joinGroup, processPrivateMessage, type CiphersuiteImpl, type ClientState, type Credential, type GroupContext, type KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
export class WebRTCPeer {
private peer: RTCPeerConnection | null = null;
@@ -11,10 +12,11 @@ export class WebRTCPeer {
private isInitiator: boolean;
private roomId: string;
private callbacks: WebRTCPeerCallbacks;
private keys: KeyStore = {
localKeys: null,
peersPublicKey: null,
};
private credential: Credential;
private clientState: ClientState | undefined;
private cipherSuite: CiphersuiteImpl | undefined;
private keyPackage: { publicPackage: KeyPackage, privatePackage: PrivateKeyPackage } | undefined;
private encyptionReady: boolean = false;
private iceServers = [
{ urls: "stun:stun.l.google.com:19302" },
@@ -27,6 +29,9 @@ export class WebRTCPeer {
this.roomId = roomId;
this.isInitiator = isInitiator;
this.callbacks = callbacks;
const id = crypto.getRandomValues(new Uint8Array(32));
this.credential = { credentialType: "basic", identity: id };
}
private sendIceCandidate(candidate: RTCIceCandidate) {
@@ -45,6 +50,7 @@ export class WebRTCPeer {
// dont initialize twice
if (this.peer) return;
console.log("Initializing peer");
this.peer = new RTCPeerConnection({
iceServers: this.iceServers,
});
@@ -89,10 +95,21 @@ export class WebRTCPeer {
channel.onopen = async () => {
console.log('data channel open');
this.callbacks.onDataChannelOpen();
this.callbacks.onKeyExchangeDone();
await this.generateKeyPair();
try {
if (this.isInitiator) {
await this.startKeyExchange();
let groupId = crypto.getRandomValues(new Uint8Array(24));
this.clientState = await createGroup(groupId, this.keyPackage!.publicPackage, this.keyPackage!.privatePackage, [], this.cipherSuite!);
this.send(new TextEncoder().encode("group-open").buffer, WebRTCPacketType.GROUP_OPEN);
} else {
// the peer needs to send the initiator their keypackage first so that the initiator can commit it
// to the group state then inform the peer
// await this.startKeyExchange();
}
} catch (e) {
console.error("Error starting key exchange:", e);
@@ -105,65 +122,108 @@ export class WebRTCPeer {
// event is binary data, we need to parse it, convert it into a WebRTCMessage, and then decrypt it if
// necessary
let data = new Uint8Array(event.data);
const encrypted = (data[0] >> 7) & 1;
const type = data[0] & 0b01111111;
if (data.length < 2) return;
const encrypted = (data[0]! >> 7) & 1;
const type = data[0]! & 0b01111111;
data = data.slice(1);
if (this.encyptionReady && !encrypted) {
console.log("Received unencrypted message after encryption is ready, ignoring");
return;
}
console.log("parsed data", data, encrypted, type);
if (type == WebRTCPacketType.KEY_EXCHANGE) {
if (this.keys.peersPublicKey) {
console.error("Key exchange already done");
return;
if (type === WebRTCPacketType.GROUP_OPEN) {
await this.startKeyExchange();
return;
}
if (type === WebRTCPacketType.KEY_PACKAGE) {
if (!this.cipherSuite) throw new Error("Cipher suite not set");
if (!this.clientState) throw new Error("Client state not set");
console.log("Received key package", data);
const decodedPeerKeyPackage = decodeMlsMessage(data, 0)![0];
if (decodedPeerKeyPackage.wireformat != "mls_key_package") throw new Error("Invalid key package");
const addPeerProposal: Proposal = {
proposalType: `add`,
add: {
keyPackage: decodedPeerKeyPackage.keyPackage,
}
}
console.log("Received key exchange", data.buffer);
this.keys.peersPublicKey = await window.crypto.subtle.importKey(
"spki",
data.buffer,
clientKeyConfig,
const commitResult = await createCommit(
this.clientState,
emptyPskIndex,
true,
["wrapKey"],
[addPeerProposal],
this.cipherSuite,
true,
)
console.log("Commit result", commitResult);
this.clientState = commitResult.newState;
console.log("sending welcome to peer");
const encodedWelcome = encodeMlsMessage({
welcome: commitResult.welcome!,
wireformat: "mls_welcome",
version: "mls10",
});
const encodedWelcomeBuf = new ArrayBuffer(encodedWelcome.byteLength);
new Uint8Array(encodedWelcomeBuf).set(encodedWelcome);
this.send(encodedWelcomeBuf, WebRTCPacketType.WELCOME);
this.encyptionReady = true;
return;
}
if (type === WebRTCPacketType.WELCOME) {
if (!this.keyPackage) throw new Error("Key package not set");
if (!this.cipherSuite) throw new Error("Cipher suite not set");
console.log("Received welcome", data);
const decodedWelcome = decodeMlsMessage(data, 0)![0];
if (decodedWelcome.wireformat != "mls_welcome") throw new Error("Invalid welcome");
this.clientState = await joinGroup(
decodedWelcome.welcome,
this.keyPackage.publicPackage,
this.keyPackage.privatePackage,
emptyPskIndex,
this.cipherSuite,
);
// if our keys are not generated, start the reponding side of the key exchange
if (!this.keys.localKeys) {
await this.startKeyExchange();
}
// by this point, both peers should have exchanged their keys
this.callbacks.onKeyExchangeDone();
console.log("Joined group", this.clientState);
this.encyptionReady = true;
return;
}
if (encrypted) {
if (!this.keys.localKeys) {
throw new Error("Local keypair not generated");
}
if (!this.cipherSuite) throw new Error("Cipher suite not set");
if (!this.clientState) throw new Error("Client state not set");
// start at 0 since the header is already sliced off
let keyLength = data[0] << 8 | data[1];
const decodedPrivateMessage = decodeMlsMessage(data, 0)![0];
if (decodedPrivateMessage.wireformat != "mls_private_message") throw new Error("Invalid private message");
let aeskey = await window.crypto.subtle.unwrapKey(
"raw",
data.subarray(2, 2 + keyLength),
this.keys.localKeys.privateKey,
clientKeyConfig,
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"],
)
const processMessageResult = await processPrivateMessage(
this.clientState,
decodedPrivateMessage.privateMessage,
emptyPskIndex,
this.cipherSuite,
);
let iv = data.subarray(2 + keyLength, 2 + keyLength + 16);
let encryptedData = data.subarray(2 + keyLength + 16);
this.clientState = processMessageResult.newState;
console.log("Decrypting message", encryptedData);
if (processMessageResult.kind === "newState") throw new Error("Expected application message");
data = new Uint8Array(await this.decrypt(encryptedData, aeskey, iv));
data = new Uint8Array(processMessageResult.message);
}
let message = {
@@ -255,58 +315,37 @@ export class WebRTCPeer {
}
private async generateKeyPair() {
console.log("Generating key pair");
// this key pair is used for wrapping the unique AES-GCM key for each message
const keyPair = await window.crypto.subtle.generateKey(
clientKeyConfig,
true,
["wrapKey", "unwrapKey"],
);
try {
console.log("getting cipher suite");
this.cipherSuite = await getCiphersuiteImpl(getCiphersuiteFromName("MLS_256_XWING_CHACHA20POLY1305_SHA512_Ed25519"))
console.log("generated key pair", keyPair);
console.log("generating credential");
this.keys.localKeys = keyPair;
// genreate an random id and format it in hex
// const genRandHex = (size: number) => '0x' + [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
console.log("Generating key package");
this.keyPackage = await generateKeyPackage(this.credential, defaultCapabilities(), defaultLifetime, [], this.cipherSuite);
} catch (e) {
console.error("Error generating key package:", e);
this.callbacks.onError(e);
}
}
private async startKeyExchange() {
if (!this.keyPackage) throw new Error("Key package not set");
console.log("Starting key exchange");
await this.generateKeyPair();
if (!this.keys.localKeys) throw new Error("Key pair not generated");
const keyPackageMessage = encodeMlsMessage({
keyPackage: this.keyPackage.publicPackage,
wireformat: "mls_key_package",
version: "mls10",
});
console.log("exporting key", this.keys.localKeys.publicKey);
const keyPackageMessageBuf = new ArrayBuffer(keyPackageMessage.byteLength);
new Uint8Array(keyPackageMessageBuf).set(keyPackageMessage);
const exported = await window.crypto.subtle.exportKey("spki", this.keys.localKeys.publicKey);
// convert exported key to a string then pack that sting into an array buffer
console.log("exported key buffer", exported);
this.send(exported, WebRTCPacketType.KEY_EXCHANGE);
}
private async encrypt(data: Uint8Array<ArrayBuffer>, key: CryptoKey, iv: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
return await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
length: 256,
iv,
tagLength: 128,
},
key,
data,
);
}
private async decrypt(data: Uint8Array<ArrayBuffer>, key: CryptoKey, iv: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {
return await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
length: 256,
iv,
tagLength: 128,
},
key,
data,
);
this.send(keyPackageMessageBuf, WebRTCPacketType.KEY_PACKAGE);
}
public async send(data: ArrayBuffer, type: WebRTCPacketType) {
@@ -314,41 +353,37 @@ export class WebRTCPeer {
if (!this.dataChannel || this.dataChannel.readyState !== 'open') throw new Error('Data channel not initialized');
console.log(this.keys)
// console.log(this.keys)
let header = (type & 0x7F);
console.log(this.encyptionReady);
// the key exchange is done, encrypt the message
if (this.keys.peersPublicKey && type != WebRTCPacketType.KEY_EXCHANGE) {
if (this.encyptionReady) {
if (!this.clientState) throw new Error("Client state not set");
if (!this.cipherSuite) throw new Error("Cipher suite not set");
console.log("Sending encrypted message", data);
let iv = window.crypto.getRandomValues(new Uint8Array(16));
let key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"],
)
const createMessageResult = await createApplicationMessage(
this.clientState,
new Uint8Array(data),
this.cipherSuite,
);
let encryptedData = await this.encrypt(new Uint8Array(data), key, iv);
this.clientState = createMessageResult.newState;
let exportedKey = await window.crypto.subtle.wrapKey(
"raw",
key,
this.keys.peersPublicKey,
clientKeyConfig,
)
const encodedPrivateMessage = encodeMlsMessage({
privateMessage: createMessageResult.privateMessage,
wireformat: "mls_private_message",
version: "mls10",
});
header |= 1 << 7;
let buf = new Uint8Array(encryptedData.byteLength + 3 + exportedKey.byteLength + iv.byteLength);
let buf = new Uint8Array(encodedPrivateMessage.byteLength + 1);
buf[0] = header;
buf[1] = (exportedKey.byteLength >> 8) & 0xFF;
buf[2] = exportedKey.byteLength & 0xFF;
buf.subarray(3).set(new Uint8Array(exportedKey));
buf.subarray(3 + exportedKey.byteLength).set(new Uint8Array(iv));
buf.subarray(3 + exportedKey.byteLength + iv.byteLength).set(new Uint8Array(encryptedData));
buf.subarray(1).set(encodedPrivateMessage);
console.log("Sending encrypted message", buf);