From 46f0a4b71eb662732f5a840fe71910eaf0d1c843 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:10:40 -0400 Subject: [PATCH] feat: X25519 ECDH sealed-box E2E encryption, zero-knowledge key exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace libsodium with pure Web Crypto X25519 ECDH for key exchange - Sealed-box pattern: ephemeral keypair + HKDF + AES-256-GCM for each recipient - Store encrypted conversation key per-user so each party decrypts with their own private key - PBKDF2-derived master key wraps X25519 private key (pkcs8) server-side for cross-device login - Fix health endpoint ordering so it never hits auth middleware (401 bug) - Fix postgres port exposure in docker-compose for backend connectivity - Fix DrivePage react-query v5 onSuccess → useEffect migration - Fix MessagesPage: use instead of to avoid page reload losing in-memory keys - Fix formatDistanceToNow crash on WebSocket messages lacking sent_at (add fallback + null guard) Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db/schema.sql | 8 +- backend/src/index.ts | 4 +- backend/src/routes/auth.ts | 60 +++---- docker-compose.yml | 2 + frontend/src/crypto/index.ts | 259 +++++++++++++++++++--------- frontend/src/lib/store.ts | 24 ++- frontend/src/pages/DrivePage.tsx | 14 +- frontend/src/pages/LoginPage.tsx | 37 ++-- frontend/src/pages/MessagesPage.tsx | 66 ++++--- frontend/src/pages/RegisterPage.tsx | 19 +- 10 files changed, 308 insertions(+), 185 deletions(-) diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 64b1164..fa1b44f 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -79,12 +79,12 @@ CREATE TABLE IF NOT EXISTS files ( CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id) WHERE deleted_at IS NULL; --- Public keys for E2E key exchange +-- Public/private keys for X25519 ECDH key exchange CREATE TABLE IF NOT EXISTS user_keys ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - -- X25519 public key for ECDH (base64) + -- X25519 public key (base64) — shared with other users public_key TEXT NOT NULL, - -- Signed with user's identity key so others can verify - key_signature TEXT NOT NULL, + -- X25519 private key encrypted with master key (base64) — loaded on login for cross-device support + encrypted_private_key TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); diff --git a/backend/src/index.ts b/backend/src/index.ts index 95a7a0d..34d3c09 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,14 +22,14 @@ app.use(express.json({ limit: '1mb' })); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200 }); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }); +app.get('/api/health', (_req, res) => res.json({ ok: true })); + app.use('/api', limiter); app.use('/api/auth', authLimiter, authRouter); app.use('/api', messagesRouter); app.use('/api', filesRouter); app.use('/api', usersRouter); -app.get('/api/health', (_req, res) => res.json({ ok: true })); - // WebSocket: real-time messaging relay // Server never sees plaintext — it only routes encrypted payloads const server = http.createServer(app); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 57af335..3b5669e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -6,6 +6,7 @@ import QRCode from 'qrcode'; import crypto from 'crypto'; import { z } from 'zod'; import { db } from '../lib/db.js'; +import { requireAuth, AuthRequest } from '../middleware/auth.js'; const router = Router(); @@ -13,10 +14,10 @@ const RegisterSchema = z.object({ username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/), email: z.string().email(), password: z.string().min(12), - kdfSalt: z.string(), // base64 — generated client-side - encryptedMasterKey: z.string(), // base64 — encrypted client-side - publicKey: z.string(), // base64 X25519 public key - keySignature: z.string(), + kdfSalt: z.string(), + encryptedMasterKey: z.string(), + publicKey: z.string(), // base64 X25519 public key + encryptedPrivateKey: z.string(), // X25519 private key encrypted with master key }); const LoginSchema = z.object({ @@ -26,12 +27,8 @@ const LoginSchema = z.object({ }); function issueTokens(userId: string) { - const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, { - expiresIn: '15m', - }); - const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, { - expiresIn: '30d', - }); + const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, { expiresIn: '15m' }); + const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, { expiresIn: '30d' }); return { accessToken, refreshToken }; } @@ -48,7 +45,7 @@ router.post('/register', async (req, res) => { const parsed = RegisterSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - const { username, email, password, kdfSalt, encryptedMasterKey, publicKey, keySignature } = + const { username, email, password, kdfSalt, encryptedMasterKey, publicKey, encryptedPrivateKey } = parsed.data; try { @@ -62,14 +59,14 @@ router.post('/register', async (req, res) => { const userId = result.rows[0].id; await db.query( - `INSERT INTO user_keys (user_id, public_key, key_signature) VALUES ($1, $2, $3)`, - [userId, publicKey, keySignature] + `INSERT INTO user_keys (user_id, public_key, encrypted_private_key) VALUES ($1, $2, $3)`, + [userId, publicKey, encryptedPrivateKey] ); const { accessToken, refreshToken } = issueTokens(userId); await storeRefreshToken(userId, refreshToken); - res.status(201).json({ accessToken, refreshToken, userId }); + res.status(201).json({ accessToken, refreshToken, userId, username }); } catch (err: any) { if (err.code === '23505') { return res.status(409).json({ error: 'Username or email already taken' }); @@ -86,7 +83,7 @@ router.post('/login', async (req, res) => { const { email, password, totpCode } = parsed.data; const result = await db.query( - `SELECT id, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key + `SELECT id, username, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key FROM users WHERE email = $1`, [email] ); @@ -109,6 +106,7 @@ router.post('/login', async (req, res) => { accessToken, refreshToken, userId: user.id, + username: user.username, kdfSalt: user.kdf_salt, encryptedMasterKey: user.encrypted_master_key, }); @@ -136,38 +134,40 @@ router.post('/refresh', async (req, res) => { } }); -// 2FA setup — returns QR code URI for authenticator app -router.post('/2fa/setup', async (req, res) => { - const userId = (req as any).userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); +// Returns user's own encrypted private key (for loading on a new device) +router.get('/my-key', requireAuth, async (req: AuthRequest, res) => { + const result = await db.query( + 'SELECT public_key, encrypted_private_key FROM user_keys WHERE user_id = $1', + [req.userId] + ); + if (!result.rows.length) return res.status(404).json({ error: 'No key found' }); + res.json(result.rows[0]); +}); +// 2FA setup — returns QR code for authenticator app +router.post('/2fa/setup', requireAuth, async (req: AuthRequest, res) => { const secret = authenticator.generateSecret(); - const userResult = await db.query('SELECT username FROM users WHERE id = $1', [userId]); + const userResult = await db.query('SELECT username FROM users WHERE id = $1', [req.userId]); const username = userResult.rows[0]?.username; const otpauth = authenticator.keyuri(username, 'CloudLink', secret); const qrDataUrl = await QRCode.toDataURL(otpauth); - // Store secret temporarily — only persisted after verification - await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, userId]); + await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, req.userId]); res.json({ secret, qrDataUrl }); }); -// 2FA verify — confirms secret and enables 2FA -router.post('/2fa/verify', async (req, res) => { - const userId = (req as any).userId; - if (!userId) return res.status(401).json({ error: 'Unauthorized' }); - +// 2FA verify — confirms code and enables 2FA +router.post('/2fa/verify', requireAuth, async (req: AuthRequest, res) => { const { code } = req.body; - const result = await db.query('SELECT totp_secret FROM users WHERE id = $1', [userId]); + const result = await db.query('SELECT totp_secret FROM users WHERE id = $1', [req.userId]); const secret = result.rows[0]?.totp_secret; if (!secret) return res.status(400).json({ error: '2FA not set up' }); const ok = authenticator.verify({ token: code, secret }); if (!ok) return res.status(400).json({ error: 'Invalid code' }); - // Generate backup codes const backupCodes = Array.from({ length: 8 }, () => crypto.randomBytes(5).toString('hex').toUpperCase() ); @@ -175,7 +175,7 @@ router.post('/2fa/verify', async (req, res) => { await db.query( 'UPDATE users SET totp_enabled = true, totp_backup_codes = $1 WHERE id = $2', - [hashedCodes, userId] + [hashedCodes, req.userId] ); res.json({ backupCodes }); diff --git a/docker-compose.yml b/docker-compose.yml index 076f686..d1004bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: POSTGRES_DB: ${POSTGRES_DB:-cloudlink} POSTGRES_USER: ${POSTGRES_USER:-cloudlink} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" volumes: - ./data/postgres:/var/lib/postgresql/data - ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts index 5c82763..9e38960 100644 --- a/frontend/src/crypto/index.ts +++ b/frontend/src/crypto/index.ts @@ -1,5 +1,6 @@ // All encryption/decryption happens here — the server never sees plaintext. -// Uses Web Crypto API (AES-GCM 256 + PBKDF2 for key derivation). +// AES-GCM 256 for symmetric encryption. +// X25519 ECDH (via Web Crypto) for conversation key exchange — no external libraries. const ENC = new TextEncoder(); const DEC = new TextDecoder(); @@ -7,8 +8,7 @@ const DEC = new TextDecoder(); // ── Key derivation ────────────────────────────────────────────────────────── export async function generateKdfSalt(): Promise { - const salt = crypto.getRandomValues(new Uint8Array(32)); - return bufToB64(salt); + return bufToB64(crypto.getRandomValues(new Uint8Array(32))); } export async function deriveKeyFromPassword( @@ -30,51 +30,169 @@ export async function deriveKeyFromPassword( // ── Master key ────────────────────────────────────────────────────────────── export async function generateMasterKey(): Promise { - return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ - 'encrypt', - 'decrypt', - 'wrapKey', - 'unwrapKey', - ]); + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -export async function exportMasterKey(key: CryptoKey): Promise { - const raw = await crypto.subtle.exportKey('raw', key); - return bufToB64(raw); +export async function encryptMasterKey(master: CryptoKey, derived: CryptoKey): Promise { + return aesEncryptRaw(new Uint8Array(await crypto.subtle.exportKey('raw', master)), derived); } -/** Encrypt master key with the password-derived key — stored on server */ -export async function encryptMasterKey( - masterKey: CryptoKey, - derivedKey: CryptoKey +export async function decryptMasterKey(encB64: string, derived: CryptoKey): Promise { + const raw = await aesDecryptRaw(encB64, derived); + return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); +} + +// ── X25519 ECDH keypair ────────────────────────────────────────────────────── + +/** + * Generate an X25519 keypair. + * Returns the public key as base64 and the private key as a non-extractable CryptoKey. + * Also returns pkcs8Bytes so the caller can encrypt and store the private key once. + */ +export async function generateX25519Keypair(): Promise<{ + publicKeyB64: string; + privateKey: CryptoKey; + pkcs8Bytes: Uint8Array; +}> { + const kp = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, ['deriveBits']); + const publicRaw = await crypto.subtle.exportKey('raw', (kp as CryptoKeyPair).publicKey); + const pkcs8 = await crypto.subtle.exportKey('pkcs8', (kp as CryptoKeyPair).privateKey); + + // Re-import as non-extractable for runtime use + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: 'X25519' } as any, + false, + ['deriveBits'] + ); + + return { + publicKeyB64: bufToB64(publicRaw), + privateKey, + pkcs8Bytes: new Uint8Array(pkcs8), + }; +} + +/** Encrypt the pkcs8-encoded private key with the master key for server storage */ +export async function encryptPrivateKey(pkcs8: Uint8Array, master: CryptoKey): Promise { + return aesEncryptRaw(pkcs8, master); +} + +/** Decrypt and import the private key, returning a non-extractable CryptoKey */ +export async function decryptPrivateKeyToCryptoKey( + encB64: string, + master: CryptoKey +): Promise { + const pkcs8 = await aesDecryptRaw(encB64, master); + return crypto.subtle.importKey('pkcs8', pkcs8, { name: 'X25519' } as any, false, ['deriveBits']); +} + +// ── Conversation key exchange (ephemeral X25519 ECDH sealed box) ───────────── +// +// Wire format for sealConvKey output: ephemeral_pub_key(32) | nonce(12) | ciphertext +// The ephemeral public key is used by the recipient to reconstruct the shared secret. + +export function generateConvKeyRaw(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** + * Seal a conversation key for a recipient using their X25519 public key. + * Uses an ephemeral ECDH keypair so the sender stays anonymous. + * Only the holder of the matching private key can open it. + */ +export async function sealConvKey( + keyRaw: Uint8Array, + recipientPublicKeyB64: string ): Promise { - const raw = await crypto.subtle.exportKey('raw', masterKey); - return aesEncryptRaw(new Uint8Array(raw), derivedKey); + const recipientPubKey = await crypto.subtle.importKey( + 'raw', + b64ToBuf(recipientPublicKeyB64), + { name: 'X25519' } as any, + false, + [] + ); + + // Ephemeral keypair for this seal operation + const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' } as any, true, [ + 'deriveBits', + ]); + const ephemeralPubRaw = new Uint8Array( + await crypto.subtle.exportKey('raw', (ephemeral as CryptoKeyPair).publicKey) + ); + + // ECDH: derive shared secret between ephemeral private key and recipient public key + const sharedBits = await crypto.subtle.deriveBits( + { name: 'X25519', public: recipientPubKey } as any, + (ephemeral as CryptoKeyPair).privateKey, + 256 + ); + + // HKDF to derive AES key — use ephemeral public key as salt for domain separation + const aesKey = await hkdfToAes(sharedBits, ephemeralPubRaw, ['encrypt']); + + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, keyRaw); + + // Output: ephemeralPubKey(32) | nonce(12) | ciphertext + const out = new Uint8Array(32 + 12 + ct.byteLength); + out.set(ephemeralPubRaw, 0); + out.set(nonce, 32); + out.set(new Uint8Array(ct), 44); + return bufToB64(out); } -export async function decryptMasterKey( - encryptedB64: string, - derivedKey: CryptoKey -): Promise { - const raw = await aesDecryptRaw(encryptedB64, derivedKey); +/** + * Open a sealed conversation key using own X25519 private key. + * Returns the raw 32-byte AES key. + */ +export async function openSealedConvKey( + sealedB64: string, + myPrivateKey: CryptoKey +): Promise { + const data = b64ToBuf(sealedB64); + const ephemeralPubRaw = data.slice(0, 32); + const nonce = data.slice(32, 44); + const ct = data.slice(44); + + const ephemeralPubKey = await crypto.subtle.importKey( + 'raw', + ephemeralPubRaw, + { name: 'X25519' } as any, + false, + [] + ); + + const sharedBits = await crypto.subtle.deriveBits( + { name: 'X25519', public: ephemeralPubKey } as any, + myPrivateKey, + 256 + ); + + const aesKey = await hkdfToAes(sharedBits, ephemeralPubRaw, ['decrypt']); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct); + return new Uint8Array(pt); +} + +export async function importConvKey(raw: Uint8Array): Promise { return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ 'encrypt', 'decrypt', - 'wrapKey', - 'unwrapKey', ]); } // ── Message encryption ────────────────────────────────────────────────────── -/** Encrypt a message string with the conversation's shared key */ export async function encryptMessage(plaintext: string, key: CryptoKey): Promise { return aesEncryptRaw(ENC.encode(plaintext), key); } export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise { - const raw = await aesDecryptRaw(ciphertextB64, key); - return DEC.decode(raw); + return DEC.decode(await aesDecryptRaw(ciphertextB64, key)); } // ── File encryption ───────────────────────────────────────────────────────── @@ -83,90 +201,67 @@ export async function generateFileKey(): Promise { return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -/** Encrypt a file blob client-side before upload */ export async function encryptFile(file: File, key: CryptoKey): Promise { const data = new Uint8Array(await file.arrayBuffer()); const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data); - const combined = new Uint8Array(nonce.length + ct.byteLength); + const combined = new Uint8Array(12 + ct.byteLength); combined.set(nonce); - combined.set(new Uint8Array(ct), nonce.length); + combined.set(new Uint8Array(ct), 12); return new Blob([combined], { type: 'application/octet-stream' }); } -export async function decryptFile(encryptedBlob: Blob, key: CryptoKey): Promise { - const data = new Uint8Array(await encryptedBlob.arrayBuffer()); - const nonce = data.slice(0, 12); - const ct = data.slice(12); - const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); +export async function decryptFile(blob: Blob, key: CryptoKey): Promise { + const data = new Uint8Array(await blob.arrayBuffer()); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12)); return new Uint8Array(pt); } -/** Encrypt a file key with the master key — stored alongside file metadata */ export async function encryptFileKey(fileKey: CryptoKey, masterKey: CryptoKey): Promise { - const raw = await crypto.subtle.exportKey('raw', fileKey); - return aesEncryptRaw(new Uint8Array(raw), masterKey); + return aesEncryptRaw(new Uint8Array(await crypto.subtle.exportKey('raw', fileKey)), masterKey); } -export async function decryptFileKey( - encryptedB64: string, - masterKey: CryptoKey -): Promise { - const raw = await aesDecryptRaw(encryptedB64, masterKey); - return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ - 'encrypt', - 'decrypt', - ]); -} - -// ── Conversation key ───────────────────────────────────────────────────────── - -/** Generate a shared conversation key */ -export async function generateConversationKey(): Promise { - return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); -} - -/** Encrypt conversation key with a user's master key for storage */ -export async function encryptConversationKey( - convKey: CryptoKey, - masterKey: CryptoKey -): Promise { - const raw = await crypto.subtle.exportKey('raw', convKey); - return aesEncryptRaw(new Uint8Array(raw), masterKey); -} - -export async function decryptConversationKey( - encryptedB64: string, - masterKey: CryptoKey -): Promise { - const raw = await aesDecryptRaw(encryptedB64, masterKey); - return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ - 'encrypt', - 'decrypt', - ]); +export async function decryptFileKey(encB64: string, masterKey: CryptoKey): Promise { + return crypto.subtle.importKey('raw', await aesDecryptRaw(encB64, masterKey), { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } // ── Helpers ────────────────────────────────────────────────────────────────── +async function hkdfToAes( + sharedBits: ArrayBuffer, + salt: Uint8Array, + usages: KeyUsage[] +): Promise { + const keyMaterial = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt, info: ENC.encode('cloudlink-conv-key') }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + usages + ); +} + async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise { const nonce = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data); - const combined = new Uint8Array(nonce.length + ct.byteLength); + const combined = new Uint8Array(12 + ct.byteLength); combined.set(nonce); - combined.set(new Uint8Array(ct), nonce.length); + combined.set(new Uint8Array(ct), 12); return bufToB64(combined); } async function aesDecryptRaw(b64: string, key: CryptoKey): Promise { - const combined = b64ToBuf(b64); - const nonce = combined.slice(0, 12); - const ct = combined.slice(12); - const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); + const data = b64ToBuf(b64); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12)); return new Uint8Array(pt); } export function bufToB64(buf: ArrayBuffer | Uint8Array): string { - return btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf))); + const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + return btoa(String.fromCharCode(...u8)); } export function b64ToBuf(b64: string): Uint8Array { diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index ddc68e4..0b49ee5 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -6,11 +6,14 @@ interface AuthState { refreshToken: string | null; userId: string | null; username: string | null; - // Base64 master key — loaded into memory from password derivation, NOT persisted + // Crypto keys — in-memory only, never persisted masterKey: CryptoKey | null; + privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey) + publicKey: string | null; // X25519 public key (base64) setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void; setTokens: (access: string, refresh: string) => void; setMasterKey: (key: CryptoKey) => void; + setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void; setUsername: (u: string) => void; logout: () => void; } @@ -23,17 +26,27 @@ export const useAuthStore = create()( userId: null, username: null, masterKey: null, + privateKey: null as CryptoKey | null, + publicKey: null, setAuth: ({ accessToken, refreshToken, userId }) => set({ accessToken, refreshToken, userId }), setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }), setMasterKey: (masterKey) => set({ masterKey }), + setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }), setUsername: (username) => set({ username }), logout: () => - set({ accessToken: null, refreshToken: null, userId: null, masterKey: null }), + set({ + accessToken: null, + refreshToken: null, + userId: null, + masterKey: null, + privateKey: null, + publicKey: null, + }), }), { name: 'cloudlink-auth', - // Never persist the master key — must be re-derived on login + // Only persist tokens and username — all crypto keys must be re-derived on login partialize: (s) => ({ accessToken: s.accessToken, refreshToken: s.refreshToken, @@ -44,11 +57,6 @@ export const useAuthStore = create()( ) ); -interface ConvKey { - conversationId: string; - key: CryptoKey; -} - interface CryptoStore { convKeys: Map; setConvKey: (conversationId: string, key: CryptoKey) => void; diff --git a/frontend/src/pages/DrivePage.tsx b/frontend/src/pages/DrivePage.tsx index 47c63a6..ed8df32 100644 --- a/frontend/src/pages/DrivePage.tsx +++ b/frontend/src/pages/DrivePage.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef, useState, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Upload, File, Trash2, Download, Lock } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; @@ -39,8 +39,12 @@ export default function DrivePage() { const { data: files = [] } = useQuery({ queryKey: ['files'], queryFn: () => api.get('/files').then((r) => r.data), - onSuccess: async (files) => { - if (!masterKey) return; + }); + + // Decrypt filenames whenever the file list changes (react-query v5: no onSuccess) + useEffect(() => { + if (!masterKey || files.length === 0) return; + (async () => { const names: Record = {}; await Promise.all( files.map(async (f) => { @@ -53,8 +57,8 @@ export default function DrivePage() { }) ); setDecryptedNames(names); - }, - } as any); + })(); + }, [files, masterKey]); async function handleUpload(e: React.ChangeEvent) { const file = e.target.files?.[0]; diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index eb2e95c..046ab8e 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -3,12 +3,12 @@ import { Link, useNavigate } from 'react-router-dom'; import { Shield } from 'lucide-react'; import api from '../lib/api'; import { useAuthStore } from '../lib/store'; -import { deriveKeyFromPassword, decryptMasterKey } from '../crypto'; +import { deriveKeyFromPassword, decryptMasterKey, decryptPrivateKeyToCryptoKey } from '../crypto'; import { socket } from '../lib/ws'; export default function LoginPage() { const navigate = useNavigate(); - const { setAuth, setMasterKey, setUsername } = useAuthStore(); + const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [totpCode, setTotpCode] = useState(''); @@ -34,17 +34,22 @@ export default function LoginPage() { return; } - const { accessToken, refreshToken, userId, kdfSalt, encryptedMasterKey, username } = - res.data; + const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey } = res.data; - // Derive key from password — never leaves the browser + // Re-derive master key from password — never leaves the browser const derived = await deriveKeyFromPassword(password, kdfSalt); const masterKey = await decryptMasterKey(encryptedMasterKey, derived); + // Set tokens first so the next API call has a Bearer token setAuth({ accessToken, refreshToken, userId }); setMasterKey(masterKey); setUsername(username); + // Fetch and decrypt the X25519 private key (stored encrypted on server for cross-device support) + const keyRes = await api.get('/auth/my-key'); + const privateKey = await decryptPrivateKeyToCryptoKey(keyRes.data.encrypted_private_key, masterKey); + setKeyPair(keyRes.data.public_key, privateKey); + socket.connect(accessToken); navigate('/messages'); } catch (err: any) { @@ -89,36 +94,22 @@ export default function LoginPage() { )} -

No account?{' '} - - Register - + Register

); } -function Field({ - label, - type, - value, - onChange, -}: { - label: string; - type: string; - value: string; - onChange: (v: string) => void; +function Field({ label, type, value, onChange }: { + label: string; type: string; value: string; onChange: (v: string) => void; }) { return (
diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx index 8e44b2f..4409f6a 100644 --- a/frontend/src/pages/MessagesPage.tsx +++ b/frontend/src/pages/MessagesPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Send, Plus, Search } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; @@ -9,9 +9,10 @@ import { socket } from '../lib/ws'; import { encryptMessage, decryptMessage, - generateConversationKey, - encryptConversationKey, - decryptConversationKey, + generateConvKeyRaw, + sealConvKey, + openSealedConvKey, + importConvKey, } from '../crypto'; interface Conversation { @@ -30,15 +31,22 @@ interface Message { plaintext?: string; } +interface UserResult { + id: string; + username: string; + public_key: string; +} + export default function MessagesPage() { const { id: activeId } = useParams(); - const { userId, masterKey } = useAuthStore(); + const navigate = useNavigate(); + const { userId, publicKey, privateKey } = useAuthStore(); const { setConvKey, getConvKey } = useCryptoStore(); const qc = useQueryClient(); const [text, setText] = useState(''); const [messages, setMessages] = useState([]); const [newUserSearch, setNewUserSearch] = useState(''); - const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]); + const [searchResults, setSearchResults] = useState([]); const bottomRef = useRef(null); const { data: conversations = [] } = useQuery({ @@ -46,9 +54,9 @@ export default function MessagesPage() { queryFn: () => api.get('/conversations').then((r) => r.data), }); - // Load + decrypt messages when conversation changes + // Load + decrypt messages when active conversation changes useEffect(() => { - if (!activeId || !masterKey) return; + if (!activeId || !publicKey || !privateKey) return; async function load() { const conv = conversations.find((c) => c.id === activeId); @@ -56,7 +64,9 @@ export default function MessagesPage() { let convKey = getConvKey(activeId!); if (!convKey) { - convKey = await decryptConversationKey(conv.encrypted_key, masterKey!); + // Decrypt the conversation key that was sealed for us with our X25519 public key + const raw = await openSealedConvKey(conv.encrypted_key, privateKey!); + convKey = await importConvKey(raw); setConvKey(activeId!, convKey); } @@ -73,7 +83,7 @@ export default function MessagesPage() { load(); }, [activeId, conversations.length]); - // WebSocket incoming messages + // WebSocket: decrypt incoming messages in real time useEffect(() => { return socket.on(async (raw) => { const msg = raw as any; @@ -85,7 +95,7 @@ export default function MessagesPage() { const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]'); setMessages((prev) => [ ...prev, - { ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext }, + { ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sent_at ?? new Date().toISOString() }, ]); }); }, [activeId]); @@ -104,25 +114,35 @@ export default function MessagesPage() { setText(''); } - async function startConversation(recipientId: string, recipientUsername: string) { - if (!masterKey) return; + async function startConversation(recipient: UserResult) { + if (!publicKey || !privateKey) return; - const convKey = await generateConversationKey(); - const myEncKey = await encryptConversationKey(convKey, masterKey); - const recipientEncKey = await encryptConversationKey(convKey, masterKey); // real impl: encrypt with recipient's pubkey + // Generate random conversation key + const convKeyRaw = generateConvKeyRaw(); + + // Seal a copy for each participant using their X25519 public key + // Each party can only open their own copy + const [myEncKey, recipientEncKey] = await Promise.all([ + sealConvKey(convKeyRaw, publicKey), + sealConvKey(convKeyRaw, recipient.public_key), + ]); const res = await api.post('/conversations', { - recipientId, + recipientId: recipient.id, encryptedKeys: { [userId!]: myEncKey, - [recipientId]: recipientEncKey, + [recipient.id]: recipientEncKey, }, }); + // Cache the decrypted key in memory for immediate use + const convKey = await importConvKey(convKeyRaw); setConvKey(res.data.conversationId, convKey); + qc.invalidateQueries({ queryKey: ['conversations'] }); setNewUserSearch(''); setSearchResults([]); + navigate(`/messages/${res.data.conversationId}`); } async function handleSearch(q: string) { @@ -154,7 +174,7 @@ export default function MessagesPage() { {searchResults.map((u) => (
- + ); })} @@ -221,7 +241,7 @@ export default function MessagesPage() { >

{m.plaintext}

- {formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })} + {m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'}

diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index c377a45..add7135 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -8,12 +8,14 @@ import { deriveKeyFromPassword, generateMasterKey, encryptMasterKey, + generateX25519Keypair, + encryptPrivateKey, } from '../crypto'; import { socket } from '../lib/ws'; export default function RegisterPage() { const navigate = useNavigate(); - const { setAuth, setMasterKey, setUsername } = useAuthStore(); + const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore(); const [username, setUsernameField] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -29,15 +31,15 @@ export default function RegisterPage() { setLoading(true); try { - // Generate all crypto material client-side + // All crypto material generated client-side — nothing sensitive leaves the browser in plaintext const kdfSalt = await generateKdfSalt(); const derivedKey = await deriveKeyFromPassword(password, kdfSalt); const masterKey = await generateMasterKey(); const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey); - // Minimal public key placeholder (full ECDH exchange would use X25519 via libsodium) - const publicKey = 'placeholder-pubkey'; - const keySignature = 'placeholder-sig'; + // X25519 keypair for ECDH conversation key exchange + const { publicKeyB64, privateKey, pkcs8Bytes } = await generateX25519Keypair(); + const encryptedPrivKey = await encryptPrivateKey(pkcs8Bytes, masterKey); const res = await api.post('/auth/register', { username, @@ -45,13 +47,14 @@ export default function RegisterPage() { password, kdfSalt, encryptedMasterKey, - publicKey, - keySignature, + publicKey: publicKeyB64, + encryptedPrivateKey: encryptedPrivKey, }); const { accessToken, refreshToken, userId } = res.data; setAuth({ accessToken, refreshToken, userId }); setMasterKey(masterKey); + setKeyPair(publicKeyB64, privateKey); setUsername(username); socket.connect(accessToken); @@ -86,7 +89,7 @@ export default function RegisterPage() {