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() {
)}
-
+
);
})}
@@ -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() {
- {loading ? 'Creating account…' : 'Create account'}
+ {loading ? 'Generating keys…' : 'Create account'}