From 07506aa2eb97f99eba08ed5e8a5e5bd7f445f0ab Mon Sep 17 00:00:00 2001 From: Klesti Selimaj Date: Fri, 28 Aug 2026 14:13:45 +0200 Subject: [PATCH] Crypto handshakce and encryption/decryption --- package-lock.json | 35 +++++++++++++-- package.json | 2 + src/app/app.ts | 15 ++++++- src/app/server.ts | 2 + src/app/ws.ts | 108 ++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 149 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb4d3bc..e552de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@fontsource-variable/geist": "^5.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.3.0", "@scure/base": "^2.3.0", @@ -1306,6 +1308,33 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/ed25519": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", @@ -1316,9 +1345,9 @@ } }, "node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" diff --git a/package.json b/package.json index 0eb2196..da53b41 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@fontsource-variable/geist": "^5.3.0", + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", "@noble/ed25519": "^3.1.0", "@noble/hashes": "^2.3.0", "@scure/base": "^2.3.0", diff --git a/src/app/app.ts b/src/app/app.ts index 39b1115..2d354c3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -134,8 +134,19 @@ export default class Enclave

{ if (this.server.websocket) { this.server.websocket.send({ method: "Meta", ...account.meta }); - this.server.websocket.websocket.onmessage = (msg) => { - this.onMessage(JSON.parse(msg.data)); + this.server.websocket.websocket.onmessage = async (msg) => { + let buffer: ArrayBuffer; + if (msg.data instanceof Blob) { + buffer = await msg.data.arrayBuffer(); + } else { + buffer = msg.data as ArrayBuffer; + } + + const encrypted = new Uint8Array(buffer); + const plaintext = this.server?.websocket?.decrypt(encrypted); + const data = JSON.parse(new TextDecoder().decode(plaintext)); + + this.onMessage(data); }; } diff --git a/src/app/server.ts b/src/app/server.ts index c4e20f5..ca67ec3 100644 --- a/src/app/server.ts +++ b/src/app/server.ts @@ -4,6 +4,7 @@ import EnclaveWebSocket from "./ws"; import { sha512 } from "@noble/hashes/sha2.js"; import { getWSUrl } from "@/lib/serverList"; import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types"; +import { ed25519 } from "@noble/curves/ed25519.js"; ed.hashes.sha512 = sha512; @@ -60,6 +61,7 @@ export default class EnclaveServer { ) { this.websocket = new EnclaveWebSocket( getWSUrl(this.hostname, this.isSecure), + ed25519.utils.toMontgomerySecret(clientSecretKey), ); const publicKeyString = base58.encode(clientPublicKey); diff --git a/src/app/ws.ts b/src/app/ws.ts index 9962d7b..fd34459 100644 --- a/src/app/ws.ts +++ b/src/app/ws.ts @@ -1,46 +1,138 @@ import { invoke } from "@tauri-apps/api/core"; +import { x25519 } from "@noble/curves/ed25519.js"; +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; import { ClientMethod, ServerMethod } from "./protocol"; /** * A protocol-aware wrapper around the browser `WebSocket`. * - * Exposes typed protocol methods (`ServerMethod` / `ClientMethod`) rather - * than raw WebSocket messages. Has no concept of channels, messages, or - * app state — `EnclaveServer` builds on top of this to add those. + * Handles the x25519 key exchange handshake and ChaCha20-Poly1305 + * encryption/decryption of every message after it. Exposes typed protocol + * methods (`ServerMethod` / `ClientMethod`) rather than raw WebSocket + * messages. Has no concept of channels, messages, or app state — + * `EnclaveServer` builds on top of this to add those. */ export default class EnclaveWebSocket { public websocket: WebSocket; onOpenQueue: Array<() => void>; - public constructor(hostname: string) { + private sendCounter = 0n; + private sharedSecret: Uint8Array | null = null; + + private readonly handshakeReady: Promise; + private resolveHandshake!: () => void; + + public constructor( + hostname: string, + private readonly myX25519PrivateKey: Uint8Array, + ) { this.onOpenQueue = new Array(); + this.handshakeReady = new Promise((resolve) => { + this.resolveHandshake = resolve; + }); + this.websocket = new WebSocket(hostname); + this.websocket.binaryType = "arraybuffer"; + this.websocket.onclose = () => { invoke("disconnect_from_vc"); }; + this.websocket.onopen = () => { this.onOpenQueue.forEach((fun) => fun()); }; + + this.websocket.onmessage = (msg) => this.handleHandshakeMessage(msg); + } + + private handleHandshakeMessage(msg: MessageEvent) { + const serverPubkey = new Uint8Array(msg.data as ArrayBuffer); + + this.sharedSecret = x25519.getSharedSecret( + this.myX25519PrivateKey, + serverPubkey, + ); + + const myPublicKey = x25519.getPublicKey(this.myX25519PrivateKey); + this.websocket.send(myPublicKey); + + this.websocket.onmessage = null; + this.resolveHandshake(); + } + + private nextSendNonce(): Uint8Array { + const nonce = new Uint8Array(12); + const view = new DataView(nonce.buffer); + view.setBigUint64(0, this.sendCounter, false); // Big-endian 64-bit counter + nonce[11] |= 0b1000_0000; // Client-to-server direction flag bit + this.sendCounter += 1n; + return nonce; + } + + public encrypt(plaintext: Uint8Array): Uint8Array { + if (!this.sharedSecret) throw new Error("Handshake not complete"); + + const nonce = this.nextSendNonce(); + const cipher = chacha20poly1305(this.sharedSecret, nonce); + const ciphertext = cipher.encrypt(plaintext); + + // Prepend nonce (12 bytes) + ciphertext + const out = new Uint8Array(nonce.length + ciphertext.length); + out.set(nonce, 0); + out.set(ciphertext, nonce.length); + return out; + } + + public decrypt(data: Uint8Array): Uint8Array { + if (!this.sharedSecret) throw new Error("Handshake not complete"); + if (data.length < 12) { + throw new Error("Message too short to contain a nonce"); + } + + const nonce = new Uint8Array(data.subarray(0, 12)); + const ciphertext = new Uint8Array(data.subarray(12)); + + const cipher = chacha20poly1305(this.sharedSecret, nonce); + return cipher.decrypt(ciphertext); } public async send(method: ServerMethod) { + await this.handshakeReady; + + const send = () => { + const plaintext = new TextEncoder().encode(JSON.stringify(method)); + const encrypted = this.encrypt(plaintext); + this.websocket.send(encrypted); + }; + if (this.websocket.readyState !== WebSocket.OPEN) { return new Promise((ok) => { this.onOpenQueue.push(() => { - this.websocket.send(JSON.stringify(method)); + send(); ok(); }); }); } - this.websocket.send(JSON.stringify(method)); + send(); } public async read(): Promise { + await this.handshakeReady; + return new Promise((ok) => { - this.websocket.onmessage = (msg) => { - const data = JSON.parse(msg.data); + this.websocket.onmessage = async (msg) => { + let buffer: ArrayBuffer; + if (msg.data instanceof Blob) { + buffer = await msg.data.arrayBuffer(); + } else { + buffer = msg.data as ArrayBuffer; + } + + const encrypted = new Uint8Array(buffer); + const plaintext = this.decrypt(encrypted); + const data = JSON.parse(new TextDecoder().decode(plaintext)); ok(data); this.websocket.onmessage = null; };