420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
|
|
import { ws } from '$stores/websocketStore';
|
|
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 KeyPackage, type PrivateKeyPackage, type Proposal } from 'ts-mls';
|
|
import { WebSocketWebRtcMessageType } from '$types/websocket';
|
|
|
|
export class WebRTCPeer {
|
|
private peer: RTCPeerConnection | null = null;
|
|
private dataChannel: RTCDataChannel | null = null;
|
|
private isInitiator: boolean;
|
|
private roomId: string;
|
|
private callbacks: WebRTCPeerCallbacks;
|
|
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" },
|
|
{ urls: "stun:stun.l.google.com:5349" },
|
|
{ urls: "stun:stun1.l.google.com:3478" },
|
|
{ urls: "stun:stun1.l.google.com:5349" },
|
|
];
|
|
|
|
constructor(roomId: string, isInitiator: boolean, callbacks: WebRTCPeerCallbacks) {
|
|
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) {
|
|
ws.send({
|
|
type: WebSocketWebRtcMessageType.ICE_CANDIDATE,
|
|
data: {
|
|
roomId: this.roomId,
|
|
candidate: candidate,
|
|
},
|
|
})
|
|
}
|
|
|
|
public async initialize() {
|
|
if (!browser) throw new Error("Cannot initialize WebRTCPeer in non-browser environment");
|
|
|
|
// dont initialize twice
|
|
if (this.peer) return;
|
|
|
|
console.log("Initializing peer");
|
|
this.peer = new RTCPeerConnection({
|
|
iceServers: this.iceServers,
|
|
});
|
|
|
|
this.peer.onicecandidateerror = (event) => {
|
|
console.error("ICE candidate error:", event);
|
|
}
|
|
|
|
// 1. Initialize ICE candidates
|
|
this.peer.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
this.sendIceCandidate(event.candidate);
|
|
}
|
|
}
|
|
|
|
this.peer.oniceconnectionstatechange = () => {
|
|
console.log('ICE connection state changed to:', this.peer?.iceConnectionState);
|
|
if (this.peer?.iceConnectionState === 'connected' || this.peer?.iceConnectionState === 'completed') {
|
|
this.callbacks.onConnected();
|
|
} else if (this.peer?.iceConnectionState === 'failed') {
|
|
this.callbacks.onError('ICE connection failed');
|
|
}
|
|
};
|
|
|
|
this.peer.onnegotiationneeded = () => {
|
|
this.callbacks.onNegotiationNeeded();
|
|
};
|
|
|
|
// 2. Create data channel
|
|
if (this.isInitiator) {
|
|
this.dataChannel = this.peer.createDataChannel('data-channel');
|
|
this.setupDataChannelEvents(this.dataChannel);
|
|
console.log('created data channel');
|
|
} else {
|
|
this.peer.ondatachannel = (event) => {
|
|
this.dataChannel = event.channel;
|
|
this.setupDataChannelEvents(this.dataChannel);
|
|
console.log('received data channel');
|
|
};
|
|
}
|
|
}
|
|
|
|
private setupDataChannelEvents(channel: RTCDataChannel) {
|
|
channel.binaryType = "arraybuffer";
|
|
|
|
channel.onopen = async () => {
|
|
this.callbacks.onDataChannelStateChange(true);
|
|
|
|
try {
|
|
if (this.isInitiator) {
|
|
await this.generateKeyPair();
|
|
|
|
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);
|
|
this.callbacks.onError(e);
|
|
}
|
|
};
|
|
|
|
channel.onmessage = async (event: MessageEvent<ArrayBuffer>) => {
|
|
console.log('data channel message:', event.data);
|
|
// 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);
|
|
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.GROUP_OPEN) {
|
|
await this.generateKeyPair();
|
|
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,
|
|
}
|
|
}
|
|
|
|
const commitResult = await createCommit(
|
|
this.clientState,
|
|
emptyPskIndex,
|
|
true,
|
|
[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;
|
|
this.callbacks.onKeyExchangeDone();
|
|
|
|
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,
|
|
);
|
|
|
|
console.log("Joined group", this.clientState);
|
|
this.encyptionReady = true;
|
|
this.callbacks.onKeyExchangeDone();
|
|
return;
|
|
}
|
|
|
|
if (encrypted) {
|
|
if (!this.cipherSuite) throw new Error("Cipher suite not set");
|
|
if (!this.clientState) throw new Error("Client state not set");
|
|
|
|
const decodedPrivateMessage = decodeMlsMessage(data, 0)![0];
|
|
if (decodedPrivateMessage.wireformat != "mls_private_message") throw new Error("Invalid private message");
|
|
|
|
const processMessageResult = await processPrivateMessage(
|
|
this.clientState,
|
|
decodedPrivateMessage.privateMessage,
|
|
emptyPskIndex,
|
|
this.cipherSuite,
|
|
);
|
|
|
|
this.clientState = processMessageResult.newState;
|
|
|
|
if (processMessageResult.kind === "newState") throw new Error("Expected application message");
|
|
|
|
data = new Uint8Array(processMessageResult.message);
|
|
}
|
|
|
|
let message = {
|
|
type: type as WebRTCPacketType,
|
|
data: data.buffer,
|
|
};
|
|
|
|
this.callbacks.onMessage(message, this);
|
|
};
|
|
|
|
channel.onclose = () => {
|
|
this.callbacks.onDataChannelStateChange(false);
|
|
|
|
};
|
|
|
|
channel.onerror = (error) => {
|
|
console.error('data channel error:', error);
|
|
this.callbacks.onError(error);
|
|
};
|
|
}
|
|
|
|
// SDP exchange
|
|
public async createOffer() {
|
|
if (!this.peer) throw new Error('Peer not initialized');
|
|
|
|
try {
|
|
const offer = await this.peer.createOffer()
|
|
|
|
console.log("Sending offer", offer);
|
|
|
|
await this.peer.setLocalDescription(offer)
|
|
|
|
ws.send({
|
|
type: WebSocketWebRtcMessageType.OFFER,
|
|
data: {
|
|
roomId: this.roomId,
|
|
sdp: offer,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.info('Error creating offer:', error);
|
|
// should trigger re-negotiation
|
|
}
|
|
}
|
|
|
|
// both peers call this to set the remote SDP
|
|
public async setRemoteDescription(sdp: RTCSessionDescriptionInit) {
|
|
if (!this.peer) throw new Error('Peer not initialized');
|
|
|
|
try {
|
|
await this.peer.setRemoteDescription(sdp);
|
|
} catch (error) {
|
|
console.error('Error setting remote description:', error);
|
|
this.callbacks.onError(error);
|
|
}
|
|
}
|
|
|
|
public async createAnswer() {
|
|
if (!this.peer) throw new Error('Peer not initialized');
|
|
|
|
try {
|
|
const answer = await this.peer.createAnswer();
|
|
await this.peer.setLocalDescription(answer);
|
|
|
|
console.log("Sending answer", answer);
|
|
|
|
ws.send({
|
|
type: WebSocketWebRtcMessageType.ANSWER,
|
|
data: {
|
|
roomId: this.roomId,
|
|
sdp: answer,
|
|
},
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error creating answer:', error);
|
|
this.callbacks.onError(error);
|
|
}
|
|
}
|
|
|
|
public async addIceCandidate(candidate: RTCIceCandidateInit) {
|
|
if (!this.peer) throw new Error('Peer not initialized');
|
|
|
|
try {
|
|
await this.peer.addIceCandidate(new RTCIceCandidate(candidate));
|
|
} catch (error) {
|
|
console.error('Error adding ICE candidate:', error);
|
|
this.callbacks.onError(error);
|
|
}
|
|
}
|
|
|
|
private async generateKeyPair() {
|
|
try {
|
|
console.log("getting cipher suite");
|
|
this.cipherSuite = await getCiphersuiteImpl(getCiphersuiteFromName("MLS_256_XWING_CHACHA20POLY1305_SHA512_Ed25519"))
|
|
|
|
console.log("generating credential");
|
|
|
|
// 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");
|
|
const keyPackageMessage = encodeMlsMessage({
|
|
keyPackage: this.keyPackage.publicPackage,
|
|
wireformat: "mls_key_package",
|
|
version: "mls10",
|
|
});
|
|
|
|
const keyPackageMessageBuf = new ArrayBuffer(keyPackageMessage.byteLength);
|
|
new Uint8Array(keyPackageMessageBuf).set(keyPackageMessage);
|
|
|
|
this.send(keyPackageMessageBuf, WebRTCPacketType.KEY_PACKAGE);
|
|
}
|
|
|
|
public async send(data: ArrayBufferLike, type: WebRTCPacketType) {
|
|
console.log("Sending message of type", type, "with data", data);
|
|
|
|
if (!this.dataChannel || this.dataChannel.readyState !== 'open') throw new Error('Data channel not initialized');
|
|
|
|
// console.log(this.keys)
|
|
let header = (type & 0x7F);
|
|
|
|
console.log(this.encyptionReady);
|
|
|
|
// the key exchange is done, encrypt the message
|
|
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);
|
|
|
|
const createMessageResult = await createApplicationMessage(
|
|
this.clientState,
|
|
new Uint8Array(data),
|
|
this.cipherSuite,
|
|
);
|
|
|
|
this.clientState = createMessageResult.newState;
|
|
|
|
const encodedPrivateMessage = encodeMlsMessage({
|
|
privateMessage: createMessageResult.privateMessage,
|
|
wireformat: "mls_private_message",
|
|
version: "mls10",
|
|
});
|
|
|
|
header |= 1 << 7;
|
|
|
|
let buf = new Uint8Array(encodedPrivateMessage.byteLength + 1);
|
|
buf[0] = header;
|
|
buf.subarray(1).set(encodedPrivateMessage);
|
|
|
|
console.log("Sending encrypted message", buf);
|
|
|
|
this.dataChannel.send(buf.buffer);
|
|
} else {
|
|
console.log("Sending unencrypted message", data);
|
|
// the key exchange is not done yet, send the message unencrypted
|
|
|
|
let buf = new Uint8Array(data.byteLength + 1);
|
|
buf[0] = header;
|
|
buf.subarray(1).set(new Uint8Array(data));
|
|
|
|
this.dataChannel.send(buf.buffer);
|
|
}
|
|
}
|
|
|
|
public close() {
|
|
if (this.dataChannel) {
|
|
this.dataChannel.close();
|
|
}
|
|
|
|
if (this.peer) {
|
|
this.peer.close();
|
|
}
|
|
|
|
this.peer = null;
|
|
this.dataChannel = null;
|
|
}
|
|
} |