Crypto handshakce and encryption/decryption
This commit is contained in:
Generated
+32
-3
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/curves": "^2.4.0",
|
||||||
"@noble/ed25519": "^3.1.0",
|
"@noble/ed25519": "^3.1.0",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@scure/base": "^2.3.0",
|
"@scure/base": "^2.3.0",
|
||||||
@@ -1306,6 +1308,33 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@noble/ed25519": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz",
|
||||||
@@ -1316,9 +1345,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||||
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
|
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.0"
|
"node": ">= 20.19.0"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/curves": "^2.4.0",
|
||||||
"@noble/ed25519": "^3.1.0",
|
"@noble/ed25519": "^3.1.0",
|
||||||
"@noble/hashes": "^2.3.0",
|
"@noble/hashes": "^2.3.0",
|
||||||
"@scure/base": "^2.3.0",
|
"@scure/base": "^2.3.0",
|
||||||
|
|||||||
+13
-2
@@ -134,8 +134,19 @@ export default class Enclave<P = Page> {
|
|||||||
|
|
||||||
if (this.server.websocket) {
|
if (this.server.websocket) {
|
||||||
this.server.websocket.send({ method: "Meta", ...account.meta });
|
this.server.websocket.send({ method: "Meta", ...account.meta });
|
||||||
this.server.websocket.websocket.onmessage = (msg) => {
|
this.server.websocket.websocket.onmessage = async (msg) => {
|
||||||
this.onMessage(JSON.parse(msg.data));
|
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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import EnclaveWebSocket from "./ws";
|
|||||||
import { sha512 } from "@noble/hashes/sha2.js";
|
import { sha512 } from "@noble/hashes/sha2.js";
|
||||||
import { getWSUrl } from "@/lib/serverList";
|
import { getWSUrl } from "@/lib/serverList";
|
||||||
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
import { ClientMeta, ServerMeta, StoredMessage } from "@/lib/types";
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||||
|
|
||||||
ed.hashes.sha512 = sha512;
|
ed.hashes.sha512 = sha512;
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export default class EnclaveServer {
|
|||||||
) {
|
) {
|
||||||
this.websocket = new EnclaveWebSocket(
|
this.websocket = new EnclaveWebSocket(
|
||||||
getWSUrl(this.hostname, this.isSecure),
|
getWSUrl(this.hostname, this.isSecure),
|
||||||
|
ed25519.utils.toMontgomerySecret(clientSecretKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
const publicKeyString = base58.encode(clientPublicKey);
|
const publicKeyString = base58.encode(clientPublicKey);
|
||||||
|
|||||||
+100
-8
@@ -1,46 +1,138 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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";
|
import { ClientMethod, ServerMethod } from "./protocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A protocol-aware wrapper around the browser `WebSocket`.
|
* A protocol-aware wrapper around the browser `WebSocket`.
|
||||||
*
|
*
|
||||||
* Exposes typed protocol methods (`ServerMethod` / `ClientMethod`) rather
|
* Handles the x25519 key exchange handshake and ChaCha20-Poly1305
|
||||||
* than raw WebSocket messages. Has no concept of channels, messages, or
|
* encryption/decryption of every message after it. Exposes typed protocol
|
||||||
* app state — `EnclaveServer` builds on top of this to add those.
|
* 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 {
|
export default class EnclaveWebSocket {
|
||||||
public websocket: WebSocket;
|
public websocket: WebSocket;
|
||||||
onOpenQueue: Array<() => void>;
|
onOpenQueue: Array<() => void>;
|
||||||
|
|
||||||
public constructor(hostname: string) {
|
private sendCounter = 0n;
|
||||||
|
private sharedSecret: Uint8Array | null = null;
|
||||||
|
|
||||||
|
private readonly handshakeReady: Promise<void>;
|
||||||
|
private resolveHandshake!: () => void;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
hostname: string,
|
||||||
|
private readonly myX25519PrivateKey: Uint8Array,
|
||||||
|
) {
|
||||||
this.onOpenQueue = new Array();
|
this.onOpenQueue = new Array();
|
||||||
|
|
||||||
|
this.handshakeReady = new Promise((resolve) => {
|
||||||
|
this.resolveHandshake = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
this.websocket = new WebSocket(hostname);
|
this.websocket = new WebSocket(hostname);
|
||||||
|
this.websocket.binaryType = "arraybuffer";
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
this.websocket.onclose = () => {
|
||||||
invoke("disconnect_from_vc");
|
invoke("disconnect_from_vc");
|
||||||
};
|
};
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
this.websocket.onopen = () => {
|
||||||
this.onOpenQueue.forEach((fun) => fun());
|
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) {
|
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) {
|
if (this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
return new Promise<void>((ok) => {
|
return new Promise<void>((ok) => {
|
||||||
this.onOpenQueue.push(() => {
|
this.onOpenQueue.push(() => {
|
||||||
this.websocket.send(JSON.stringify(method));
|
send();
|
||||||
ok();
|
ok();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.websocket.send(JSON.stringify(method));
|
send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(): Promise<ClientMethod> {
|
public async read(): Promise<ClientMethod> {
|
||||||
|
await this.handshakeReady;
|
||||||
|
|
||||||
return new Promise((ok) => {
|
return new Promise((ok) => {
|
||||||
this.websocket.onmessage = (msg) => {
|
this.websocket.onmessage = async (msg) => {
|
||||||
const data = JSON.parse(msg.data);
|
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);
|
ok(data);
|
||||||
this.websocket.onmessage = null;
|
this.websocket.onmessage = null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user