Refactor the code

This commit is contained in:
2025-08-25 18:16:47 +03:00
Unverified
parent bddf34de2d
commit 2151a0b4a1
14 changed files with 220 additions and 170 deletions
+17 -1
View File
@@ -1,7 +1,8 @@
import { API_BASE_URL } from "../core/config";
import { showLogin } from "../navigation";
import type { Headers, User } from "../core/types";
import type { Headers, User, WebSocketMessage } from "../core/types";
import { clearAlerts } from "./auth";
import { request } from "../websocket";
/**
* Current authenticated user information
@@ -23,6 +24,21 @@ export let authToken: string | null = null;
export function setUser(token: string, user: User) {
authToken = token
currentUser = user
try {
const payload: WebSocketMessage = {
type: "ping",
credentials: {
scheme: "Bearer",
credentials: authToken
},
data: {}
}
request(payload).then(() => {
console.log("Ping succeeded")
})
} catch {}
}
/**
+20 -14
View File
@@ -2,18 +2,12 @@ import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "./api";
import { generateX25519KeyPair } from "../crypto/asymmetric";
import { encryptBackupWithPassword, decryptBackupWithPassword, encodeBlob, decodeBlob } from "../crypto/backup";
import { b64, ub64 } from "../utils/utils";
import type { BackupBlob, UploadPublicKeyRequest } from "../core/types";
let currentPublicKey: Uint8Array | null = null;
let currentPrivateKey: Uint8Array | null = null;
function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
function ub64(s: string): Uint8Array {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
async function fetchPublicKey(): Promise<Uint8Array | null> {
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers: getAuthHeaders(true) });
if (!res.ok) return null;
@@ -23,25 +17,37 @@ async function fetchPublicKey(): Promise<Uint8Array | null> {
}
async function uploadPublicKey(publicKey: Uint8Array): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify({ publicKey: b64(publicKey) })
body: JSON.stringify(payload)
});
}
async function fetchBackupBlob(): Promise<string | null> {
const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", headers: getAuthHeaders(true) });
if (!res.ok) return null;
const data = await res.json();
return data?.blob ?? null;
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET",
headers: getAuthHeaders(true)
});
if (res.ok) {
const response: BackupBlob = await res.json();
return response.blob;
} else {
return null;
}
}
async function uploadBackupBlob(blobJson: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson }
await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify({ blob: blobJson })
body: JSON.stringify(payload)
});
}
+8 -17
View File
@@ -6,15 +6,14 @@
*/
import { API_BASE_URL } from "../core/config";
import { websocket } from "../websocket";
import { request } from "../websocket";
import type { Message, Messages, WebSocketMessage } from "../core/types";
import { formatTime } from "../utils/utils";
import { show as showContextMenu } from "./contextMenu";
import { show as showUserProfileDialog } from "./profileDialog";
import defaultAvatar from "../resources/images/default-avatar.png";
import { authToken, currentUser, getAuthHeaders } from "../auth/api";
import { ChatPanelController, PublicChatPanel } from "./panel";
import type { Tabs } from "mdui/components/tabs";
import { PublicChatPanel } from "./panel";
/**
* Adds a new message to the chat interface
@@ -156,12 +155,12 @@ export function loadMessages(): void {
/**
* Sends a message via WebSocket
*/
export function sendMessage(): void {
export async function sendMessage(): Promise<void> {
const input = document.querySelector('.message-input') as HTMLInputElement;
const message = input.value.trim();
if (message) {
const payload: WebSocketMessage = {
const response = await request({
data: {
content: message
},
@@ -170,20 +169,12 @@ export function sendMessage(): void {
credentials: authToken!
},
type: "sendMessage"
}
})
let callback: ((e: MessageEvent) => void) | null = null
callback = (e) => {
websocket.removeEventListener("message", callback!);
const response: WebSocketMessage = JSON.parse(e.data)
console.log(response)
if (!response.error) {
input.value = "";
}
console.log(response)
if (!response.error) {
input.value = "";
}
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
}
}
+30 -54
View File
@@ -5,8 +5,8 @@
* @version 1.0.0
*/
import { websocket } from "../websocket";
import type { Message, WebSocketMessage } from "../core/types";
import { request } from "../websocket";
import type { Message } from "../core/types";
import { showSuccess, showError } from "../utils/notification";
import { delay, id } from "../utils/utils";
import type { Dialog } from "mdui/components/dialog";
@@ -184,7 +184,7 @@ function hideEditDialog(): void {
* Saves the edited message
* @private
*/
function saveEdit(): void {
async function saveEdit(): Promise<void> {
if (!currentMessage) return;
const textField = editDialog.querySelector('#edit-message-input') as TextField;
@@ -195,7 +195,7 @@ function saveEdit(): void {
return;
}
const payload: WebSocketMessage = {
const response = await request({
type: "editMessage",
data: {
message_id: currentMessage.id,
@@ -205,22 +205,14 @@ function saveEdit(): void {
scheme: "Bearer",
credentials: authToken!
}
};
});
let callback: ((e: MessageEvent) => void) | null = null;
callback = (e) => {
websocket.removeEventListener("message", callback!);
const response: WebSocketMessage = JSON.parse(e.data);
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message edited successfully');
hideEditDialog();
}
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message edited successfully');
hideEditDialog();
}
}
/**
@@ -257,7 +249,7 @@ function hideReplyDialog(): void {
* Sends the reply message
* @private
*/
function sendReply(): void {
async function sendReply(): Promise<void> {
if (!currentMessage) return;
const textField = replyDialog.querySelector('#reply-message-input') as TextField;
@@ -268,7 +260,7 @@ function sendReply(): void {
return;
}
const payload: WebSocketMessage = {
const response = await request({
type: "replyMessage",
data: {
content: content,
@@ -278,25 +270,17 @@ function sendReply(): void {
scheme: "Bearer",
credentials: authToken!
}
};
});
let callback: ((e: MessageEvent) => void) | null = null;
callback = (e) => {
websocket.removeEventListener("message", callback!);
const response: WebSocketMessage = JSON.parse(e.data);
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Reply sent successfully');
hideReplyDialog();
if (textField) {
textField.value = '';
}
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Reply sent successfully');
hideReplyDialog();
if (textField) {
textField.value = '';
}
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
}
}
/**
@@ -304,12 +288,12 @@ function sendReply(): void {
* @param {Message} message - The message to delete
* @private
*/
function deleteMessage(message: Message): void {
async function deleteMessage(message: Message): Promise<void> {
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const payload: WebSocketMessage = {
const response = await request({
type: "deleteMessage",
data: {
message_id: message.id
@@ -318,21 +302,13 @@ function deleteMessage(message: Message): void {
scheme: "Bearer",
credentials: authToken!
}
};
});
let callback: ((e: MessageEvent) => void) | null = null;
callback = (e) => {
websocket.removeEventListener("message", callback!);
const response: WebSocketMessage = JSON.parse(e.data);
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message deleted successfully');
}
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
if (response.error) {
showError(response.error.detail);
} else {
showSuccess('Message deleted successfully');
}
}
init();
+71 -53
View File
@@ -1,21 +1,14 @@
import { API_BASE_URL } from "../core/config";
import { getAuthHeaders } from "../auth/api";
import { DmPanel, ChatPanelController } from "./panel";
import { authToken, getAuthHeaders } from "../auth/api";
import { DmPanel } from "./panel";
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
import { randomBytes } from "../crypto/kdf";
import { getCurrentKeys } from "../auth/crypto";
import { websocket } from "../websocket";
import type { WebSocketMessage } from "../core/types";
import { request, websocket } from "../websocket";
import type { FetchDMResponse, SendDMRequest, WebSocketMessage } from "../core/types";
import type { Tabs } from "mdui/components/tabs";
function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
function ub64(s: string): Uint8Array {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
import { b64, ub64 } from "../utils/utils";
export async function sendDm(recipientId: number, recipientPublicKeyB64: string, plaintext: string): Promise<void> {
const keys = getCurrentKeys();
@@ -27,11 +20,12 @@ export async function sendDm(recipientId: number, recipientPublicKeyB64: string,
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
await fetch(`${API_BASE_URL}/dm/send`, {
method: "POST",
headers: getAuthHeaders(true),
body: JSON.stringify({
recipientId,
recipientId: recipientId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
@@ -56,19 +50,30 @@ export interface DmEnvelope {
export async function fetchDm(since?: number): Promise<DmEnvelope[]> {
const url = new URL(`${API_BASE_URL}/dm/fetch`);
if (since) url.searchParams.set("since", String(since));
const res = await fetch(url, { headers: getAuthHeaders(true) });
if (!res.ok) return [];
const data = await res.json();
return data.messages ?? [];
const response = await fetch(url, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data: FetchDMResponse = await response.json();
return data.messages ?? [];
} else {
return [];
}
}
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
// Obtain the key
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
}
@@ -95,54 +100,66 @@ async function loadUsers() {
// WebSocket realtime send
const keys = getCurrentKeys();
if (!keys) return;
// Encryption key
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(activeDm.publicKey));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(text));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: WebSocketMessage = {
const payload: SendDMRequest = {
recipientId: activeDm.userId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}
request({
type: "dmSend",
credentials: { scheme: "Bearer", credentials: (await import("../auth/api")).authToken! },
data: {
recipientId: activeDm.userId,
iv: b64(encMsg.iv),
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}
};
websocket.send(JSON.stringify(payload));
credentials: {
scheme: "Bearer",
credentials: authToken!
},
data: payload
});
}
},
async () => {
// Load DM history for the active conversation
if (!activeDm?.publicKey) return;
const res = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, { headers: getAuthHeaders(true) });
if (!res.ok) return;
const data = await res.json();
const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!;
container.innerHTML = "";
for (const env of messages) {
try {
const text = await decryptDm(env, activeDm.publicKey);
const div = document.createElement("div");
const isAuthor = env.senderId !== activeDm.userId;
div.className = `message ${isAuthor ? "sent" : "received"}`;
const inner = document.createElement("div");
inner.className = "message-inner";
const content = document.createElement("div");
content.className = "message-content";
content.textContent = text;
inner.appendChild(content);
div.appendChild(inner);
container.appendChild(div);
} catch {}
const response = await fetch(`${API_BASE_URL}/dm/history/${activeDm.userId}`, {
headers: getAuthHeaders(true)
});
if (response.ok) {
const data = await response.json();
const messages: DmEnvelope[] = data.messages || [];
const container = document.getElementById("chat-messages")!;
container.innerHTML = "";
for (const env of messages) {
try {
const text = await decryptDm(env, activeDm.publicKey);
const div = document.createElement("div");
const isAuthor = env.senderId !== activeDm.userId;
div.className = `message ${isAuthor ? "sent" : "received"}`;
const inner = document.createElement("div");
inner.className = "message-inner";
const content = document.createElement("div");
content.className = "message-content";
content.textContent = text;
inner.appendChild(content);
div.appendChild(inner);
container.appendChild(div);
} catch {}
}
container.scrollTop = container.scrollHeight;
}
container.scrollTop = container.scrollHeight;
}
);
}
@@ -182,9 +199,10 @@ init();
// realtime incoming DMs
websocket.addEventListener("message", async (e) => {
try {
const msg = JSON.parse((e as MessageEvent).data);
if (msg?.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
const msg: WebSocketMessage = JSON.parse((e as MessageEvent).data);
if (msg.type === "dmNew" && activeDm && (msg.data.senderId === activeDm.userId || msg.data.recipientId === activeDm.userId)) {
const plaintext = await decryptDm(msg.data, activeDm.publicKey!);
const container = document.getElementById("chat-messages")!;
const div = document.createElement("div");
const isAuthor = msg.data.senderId !== activeDm.userId;
+2 -10
View File
@@ -1,6 +1,6 @@
import { authToken, currentUser } from "../auth/api";
import type { WebSocketMessage } from "../core/types";
import { websocket } from "../websocket";
import { request } from "../websocket";
import { loadMessages } from "./chat";
const titleEl = document.getElementById("chat-name")!;
@@ -73,15 +73,7 @@ export class PublicChatPanel extends ChatPanelController {
credentials: { scheme: "Bearer", credentials: authToken! },
type: "sendMessage"
};
await new Promise<void>((resolve) => {
let callback: ((e: MessageEvent) => void) | null = null;
callback = (e) => {
websocket.removeEventListener("message", callback!);
resolve();
};
websocket.addEventListener("message", callback);
websocket.send(JSON.stringify(payload));
});
await request(payload);
}
protected loadMessages(): void {
+23
View File
@@ -5,6 +5,8 @@
* @version 1.0.0
*/
import type { DmEnvelope } from "../chat/dm";
/**
* HTTP headers object type
* @typedef {Object.<string, string>} Headers
@@ -136,6 +138,19 @@ export interface RegisterRequest {
confirm_password: string;
}
export interface UploadPublicKeyRequest {
publicKey: string;
}
export interface SendDMRequest {
recipientId: number;
iv: string;
ciphertext: string;
salt: string;
iv2: string;
wrappedMk: string;
}
// Responses
/**
@@ -149,6 +164,14 @@ export interface LoginResponse {
token: string;
}
export interface BackupBlob {
blob: string;
}
export interface FetchDMResponse {
messages: DmEnvelope[]
}
// ---------------
// WebSocket types
// ---------------
+1 -3
View File
@@ -17,7 +17,5 @@ export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8
}
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer, salt, info, 32);
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
}
+10 -7
View File
@@ -3,9 +3,10 @@ export async function importPassword(password: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
}
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterations = 210_000): Promise<CryptoKey> {
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
@@ -13,9 +14,13 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array, iterat
);
}
export async function hkdfExtractAndExpand(inputKeyMaterial: ArrayBuffer, salt: Uint8Array, info: Uint8Array, length = 32): Promise<Uint8Array> {
const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, { name: "HKDF" }, false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, ikmKey, length * 8);
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
return new Uint8Array(bits);
}
@@ -24,5 +29,3 @@ export function randomBytes(length: number): Uint8Array {
crypto.getRandomValues(out);
return out;
}
+10 -6
View File
@@ -3,17 +3,21 @@ export interface AesGcmCiphertext {
ciphertext: Uint8Array;
}
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array): Promise<AesGcmCiphertext> {
export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | ArrayBuffer): Promise<AesGcmCiphertext> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
const plaintextBuffer = plaintext instanceof Uint8Array ? plaintext.buffer as ArrayBuffer : plaintext;
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintextBuffer);
return { iv, ciphertext: new Uint8Array(ct) };
}
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array> {
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
const ivBuffer = iv instanceof Uint8Array ? iv.buffer as ArrayBuffer : iv;
const ciphertextBuffer = ciphertext instanceof Uint8Array ? ciphertext.buffer as ArrayBuffer : ciphertext;
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer }, key, ciphertextBuffer);
return new Uint8Array(pt);
}
export async function importAesGcmKey(rawKey: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
const keyBuffer = rawKey instanceof Uint8Array ? rawKey.buffer as ArrayBuffer : rawKey;
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
}
-2
View File
@@ -2,5 +2,3 @@ declare module "tweetnacl" {
const nacl: any;
export default nacl;
}
+9
View File
@@ -32,6 +32,15 @@ export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
export function ub64(s: string): Uint8Array {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
export function id<T extends Element = HTMLElement>(id: string): T {
return document.getElementById(id) as unknown as T
}
+15
View File
@@ -7,6 +7,7 @@
import { handleWebSocketMessage } from "./chat/chat";
import { API_WS_BASE_URL } from "./core/config";
import type { WebSocketMessage } from "./core/types";
import { delay } from "./utils/utils";
/**
@@ -29,6 +30,20 @@ function create(): WebSocket {
*/
export let websocket: WebSocket = create();
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> {
return new Promise((resolve, reject) => {
let listener: ((e: MessageEvent) => void) | null = null;
listener = (e) => {
resolve(JSON.parse(e.data));
websocket.removeEventListener("message", listener!);
}
websocket.addEventListener("message", listener);
websocket.send(JSON.stringify(payload))
setTimeout(() => reject("Request timed out"), 10000);
})
}
/**
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
* If it fails, tries again in an endless loop until the connection is established
+1
View File
@@ -14,6 +14,7 @@
"noEmit": true,
/* Linting */
"strict": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true