switch to MLS for real, secure E2EE
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user