feat: X25519 ECDH sealed-box E2E encryption, zero-knowledge key exchange
- 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 <Link> instead of <a href> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
937bf8ad2d
commit
46f0a4b71e
10 files changed
+308
-185
No files matched your search
@@ -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;
|
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 (
|
CREATE TABLE IF NOT EXISTS user_keys (
|
||||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
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,
|
public_key TEXT NOT NULL,
|
||||||
-- Signed with user's identity key so others can verify
|
-- X25519 private key encrypted with master key (base64) — loaded on login for cross-device support
|
||||||
key_signature TEXT NOT NULL,
|
encrypted_private_key TEXT NOT NULL,
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -22,14 +22,14 @@ app.use(express.json({ limit: '1mb' }));
|
|||||||
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200 });
|
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200 });
|
||||||
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
|
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', limiter);
|
||||||
app.use('/api/auth', authLimiter, authRouter);
|
app.use('/api/auth', authLimiter, authRouter);
|
||||||
app.use('/api', messagesRouter);
|
app.use('/api', messagesRouter);
|
||||||
app.use('/api', filesRouter);
|
app.use('/api', filesRouter);
|
||||||
app.use('/api', usersRouter);
|
app.use('/api', usersRouter);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
|
||||||
|
|
||||||
// WebSocket: real-time messaging relay
|
// WebSocket: real-time messaging relay
|
||||||
// Server never sees plaintext — it only routes encrypted payloads
|
// Server never sees plaintext — it only routes encrypted payloads
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|||||||
+30
-30
@@ -6,6 +6,7 @@ import QRCode from 'qrcode';
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db } from '../lib/db.js';
|
import { db } from '../lib/db.js';
|
||||||
|
import { requireAuth, AuthRequest } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -13,10 +14,10 @@ const RegisterSchema = z.object({
|
|||||||
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/),
|
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(12),
|
password: z.string().min(12),
|
||||||
kdfSalt: z.string(), // base64 — generated client-side
|
kdfSalt: z.string(),
|
||||||
encryptedMasterKey: z.string(), // base64 — encrypted client-side
|
encryptedMasterKey: z.string(),
|
||||||
publicKey: z.string(), // base64 X25519 public key
|
publicKey: z.string(), // base64 X25519 public key
|
||||||
keySignature: z.string(),
|
encryptedPrivateKey: z.string(), // X25519 private key encrypted with master key
|
||||||
});
|
});
|
||||||
|
|
||||||
const LoginSchema = z.object({
|
const LoginSchema = z.object({
|
||||||
@@ -26,12 +27,8 @@ const LoginSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function issueTokens(userId: string) {
|
function issueTokens(userId: string) {
|
||||||
const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, {
|
const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, { expiresIn: '15m' });
|
||||||
expiresIn: '15m',
|
const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, { expiresIn: '30d' });
|
||||||
});
|
|
||||||
const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, {
|
|
||||||
expiresIn: '30d',
|
|
||||||
});
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +45,7 @@ router.post('/register', async (req, res) => {
|
|||||||
const parsed = RegisterSchema.safeParse(req.body);
|
const parsed = RegisterSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
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;
|
parsed.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -62,14 +59,14 @@ router.post('/register', async (req, res) => {
|
|||||||
const userId = result.rows[0].id;
|
const userId = result.rows[0].id;
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO user_keys (user_id, public_key, key_signature) VALUES ($1, $2, $3)`,
|
`INSERT INTO user_keys (user_id, public_key, encrypted_private_key) VALUES ($1, $2, $3)`,
|
||||||
[userId, publicKey, keySignature]
|
[userId, publicKey, encryptedPrivateKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken, refreshToken } = issueTokens(userId);
|
const { accessToken, refreshToken } = issueTokens(userId);
|
||||||
await storeRefreshToken(userId, refreshToken);
|
await storeRefreshToken(userId, refreshToken);
|
||||||
|
|
||||||
res.status(201).json({ accessToken, refreshToken, userId });
|
res.status(201).json({ accessToken, refreshToken, userId, username });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
return res.status(409).json({ error: 'Username or email already taken' });
|
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 { email, password, totpCode } = parsed.data;
|
||||||
|
|
||||||
const result = await db.query(
|
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`,
|
FROM users WHERE email = $1`,
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
@@ -109,6 +106,7 @@ router.post('/login', async (req, res) => {
|
|||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
kdfSalt: user.kdf_salt,
|
kdfSalt: user.kdf_salt,
|
||||||
encryptedMasterKey: user.encrypted_master_key,
|
encryptedMasterKey: user.encrypted_master_key,
|
||||||
});
|
});
|
||||||
@@ -136,38 +134,40 @@ router.post('/refresh', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2FA setup — returns QR code URI for authenticator app
|
// Returns user's own encrypted private key (for loading on a new device)
|
||||||
router.post('/2fa/setup', async (req, res) => {
|
router.get('/my-key', requireAuth, async (req: AuthRequest, res) => {
|
||||||
const userId = (req as any).userId;
|
const result = await db.query(
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
'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 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 username = userResult.rows[0]?.username;
|
||||||
|
|
||||||
const otpauth = authenticator.keyuri(username, 'CloudLink', secret);
|
const otpauth = authenticator.keyuri(username, 'CloudLink', secret);
|
||||||
const qrDataUrl = await QRCode.toDataURL(otpauth);
|
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, req.userId]);
|
||||||
await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, userId]);
|
|
||||||
|
|
||||||
res.json({ secret, qrDataUrl });
|
res.json({ secret, qrDataUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2FA verify — confirms secret and enables 2FA
|
// 2FA verify — confirms code and enables 2FA
|
||||||
router.post('/2fa/verify', async (req, res) => {
|
router.post('/2fa/verify', requireAuth, async (req: AuthRequest, res) => {
|
||||||
const userId = (req as any).userId;
|
|
||||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
|
|
||||||
const { code } = req.body;
|
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;
|
const secret = result.rows[0]?.totp_secret;
|
||||||
if (!secret) return res.status(400).json({ error: '2FA not set up' });
|
if (!secret) return res.status(400).json({ error: '2FA not set up' });
|
||||||
|
|
||||||
const ok = authenticator.verify({ token: code, secret });
|
const ok = authenticator.verify({ token: code, secret });
|
||||||
if (!ok) return res.status(400).json({ error: 'Invalid code' });
|
if (!ok) return res.status(400).json({ error: 'Invalid code' });
|
||||||
|
|
||||||
// Generate backup codes
|
|
||||||
const backupCodes = Array.from({ length: 8 }, () =>
|
const backupCodes = Array.from({ length: 8 }, () =>
|
||||||
crypto.randomBytes(5).toString('hex').toUpperCase()
|
crypto.randomBytes(5).toString('hex').toUpperCase()
|
||||||
);
|
);
|
||||||
@@ -175,7 +175,7 @@ router.post('/2fa/verify', async (req, res) => {
|
|||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
'UPDATE users SET totp_enabled = true, totp_backup_codes = $1 WHERE id = $2',
|
'UPDATE users SET totp_enabled = true, totp_backup_codes = $1 WHERE id = $2',
|
||||||
[hashedCodes, userId]
|
[hashedCodes, req.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ backupCodes });
|
res.json({ backupCodes });
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
POSTGRES_DB: ${POSTGRES_DB:-cloudlink}
|
POSTGRES_DB: ${POSTGRES_DB:-cloudlink}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-cloudlink}
|
POSTGRES_USER: ${POSTGRES_USER:-cloudlink}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
- ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
- ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
||||||
|
|||||||
+177
-82
@@ -1,5 +1,6 @@
|
|||||||
// All encryption/decryption happens here — the server never sees plaintext.
|
// 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 ENC = new TextEncoder();
|
||||||
const DEC = new TextDecoder();
|
const DEC = new TextDecoder();
|
||||||
@@ -7,8 +8,7 @@ const DEC = new TextDecoder();
|
|||||||
// ── Key derivation ──────────────────────────────────────────────────────────
|
// ── Key derivation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function generateKdfSalt(): Promise<string> {
|
export async function generateKdfSalt(): Promise<string> {
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
return bufToB64(crypto.getRandomValues(new Uint8Array(32)));
|
||||||
return bufToB64(salt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deriveKeyFromPassword(
|
export async function deriveKeyFromPassword(
|
||||||
@@ -30,51 +30,169 @@ export async function deriveKeyFromPassword(
|
|||||||
// ── Master key ──────────────────────────────────────────────────────────────
|
// ── Master key ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function generateMasterKey(): Promise<CryptoKey> {
|
export async function generateMasterKey(): Promise<CryptoKey> {
|
||||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
|
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||||
'encrypt',
|
|
||||||
'decrypt',
|
|
||||||
'wrapKey',
|
|
||||||
'unwrapKey',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportMasterKey(key: CryptoKey): Promise<string> {
|
export async function encryptMasterKey(master: CryptoKey, derived: CryptoKey): Promise<string> {
|
||||||
const raw = await crypto.subtle.exportKey('raw', key);
|
return aesEncryptRaw(new Uint8Array(await crypto.subtle.exportKey('raw', master)), derived);
|
||||||
return bufToB64(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encrypt master key with the password-derived key — stored on server */
|
export async function decryptMasterKey(encB64: string, derived: CryptoKey): Promise<CryptoKey> {
|
||||||
export async function encryptMasterKey(
|
const raw = await aesDecryptRaw(encB64, derived);
|
||||||
masterKey: CryptoKey,
|
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
||||||
derivedKey: CryptoKey
|
'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<string> {
|
||||||
|
return aesEncryptRaw(pkcs8, master);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt and import the private key, returning a non-extractable CryptoKey */
|
||||||
|
export async function decryptPrivateKeyToCryptoKey(
|
||||||
|
encB64: string,
|
||||||
|
master: CryptoKey
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
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<string> {
|
): Promise<string> {
|
||||||
const raw = await crypto.subtle.exportKey('raw', masterKey);
|
const recipientPubKey = await crypto.subtle.importKey(
|
||||||
return aesEncryptRaw(new Uint8Array(raw), derivedKey);
|
'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,
|
* Open a sealed conversation key using own X25519 private key.
|
||||||
derivedKey: CryptoKey
|
* Returns the raw 32-byte AES key.
|
||||||
): Promise<CryptoKey> {
|
*/
|
||||||
const raw = await aesDecryptRaw(encryptedB64, derivedKey);
|
export async function openSealedConvKey(
|
||||||
|
sealedB64: string,
|
||||||
|
myPrivateKey: CryptoKey
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
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<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
||||||
'encrypt',
|
'encrypt',
|
||||||
'decrypt',
|
'decrypt',
|
||||||
'wrapKey',
|
|
||||||
'unwrapKey',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Message encryption ──────────────────────────────────────────────────────
|
// ── Message encryption ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Encrypt a message string with the conversation's shared key */
|
|
||||||
export async function encryptMessage(plaintext: string, key: CryptoKey): Promise<string> {
|
export async function encryptMessage(plaintext: string, key: CryptoKey): Promise<string> {
|
||||||
return aesEncryptRaw(ENC.encode(plaintext), key);
|
return aesEncryptRaw(ENC.encode(plaintext), key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise<string> {
|
export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise<string> {
|
||||||
const raw = await aesDecryptRaw(ciphertextB64, key);
|
return DEC.decode(await aesDecryptRaw(ciphertextB64, key));
|
||||||
return DEC.decode(raw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File encryption ─────────────────────────────────────────────────────────
|
// ── File encryption ─────────────────────────────────────────────────────────
|
||||||
@@ -83,90 +201,67 @@ export async function generateFileKey(): Promise<CryptoKey> {
|
|||||||
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
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<Blob> {
|
export async function encryptFile(file: File, key: CryptoKey): Promise<Blob> {
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
|
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(nonce);
|
||||||
combined.set(new Uint8Array(ct), nonce.length);
|
combined.set(new Uint8Array(ct), 12);
|
||||||
return new Blob([combined], { type: 'application/octet-stream' });
|
return new Blob([combined], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptFile(encryptedBlob: Blob, key: CryptoKey): Promise<Uint8Array> {
|
export async function decryptFile(blob: Blob, key: CryptoKey): Promise<Uint8Array> {
|
||||||
const data = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const data = new Uint8Array(await blob.arrayBuffer());
|
||||||
const nonce = data.slice(0, 12);
|
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12));
|
||||||
const ct = data.slice(12);
|
|
||||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
|
|
||||||
return new Uint8Array(pt);
|
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<string> {
|
export async function encryptFileKey(fileKey: CryptoKey, masterKey: CryptoKey): Promise<string> {
|
||||||
const raw = await crypto.subtle.exportKey('raw', fileKey);
|
return aesEncryptRaw(new Uint8Array(await crypto.subtle.exportKey('raw', fileKey)), masterKey);
|
||||||
return aesEncryptRaw(new Uint8Array(raw), masterKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptFileKey(
|
export async function decryptFileKey(encB64: string, masterKey: CryptoKey): Promise<CryptoKey> {
|
||||||
encryptedB64: string,
|
return crypto.subtle.importKey('raw', await aesDecryptRaw(encB64, masterKey), { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||||
masterKey: CryptoKey
|
|
||||||
): Promise<CryptoKey> {
|
|
||||||
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<CryptoKey> {
|
|
||||||
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<string> {
|
|
||||||
const raw = await crypto.subtle.exportKey('raw', convKey);
|
|
||||||
return aesEncryptRaw(new Uint8Array(raw), masterKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decryptConversationKey(
|
|
||||||
encryptedB64: string,
|
|
||||||
masterKey: CryptoKey
|
|
||||||
): Promise<CryptoKey> {
|
|
||||||
const raw = await aesDecryptRaw(encryptedB64, masterKey);
|
|
||||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [
|
|
||||||
'encrypt',
|
|
||||||
'decrypt',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function hkdfToAes(
|
||||||
|
sharedBits: ArrayBuffer,
|
||||||
|
salt: Uint8Array,
|
||||||
|
usages: KeyUsage[]
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
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<string> {
|
async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise<string> {
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data);
|
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(nonce);
|
||||||
combined.set(new Uint8Array(ct), nonce.length);
|
combined.set(new Uint8Array(ct), 12);
|
||||||
return bufToB64(combined);
|
return bufToB64(combined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aesDecryptRaw(b64: string, key: CryptoKey): Promise<Uint8Array> {
|
async function aesDecryptRaw(b64: string, key: CryptoKey): Promise<Uint8Array> {
|
||||||
const combined = b64ToBuf(b64);
|
const data = b64ToBuf(b64);
|
||||||
const nonce = combined.slice(0, 12);
|
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(0, 12) }, key, data.slice(12));
|
||||||
const ct = combined.slice(12);
|
|
||||||
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
|
|
||||||
return new Uint8Array(pt);
|
return new Uint8Array(pt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bufToB64(buf: ArrayBuffer | Uint8Array): string {
|
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 {
|
export function b64ToBuf(b64: string): Uint8Array {
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ interface AuthState {
|
|||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
username: 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;
|
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;
|
setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void;
|
||||||
setTokens: (access: string, refresh: string) => void;
|
setTokens: (access: string, refresh: string) => void;
|
||||||
setMasterKey: (key: CryptoKey) => void;
|
setMasterKey: (key: CryptoKey) => void;
|
||||||
|
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
|
||||||
setUsername: (u: string) => void;
|
setUsername: (u: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
@@ -23,17 +26,27 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
masterKey: null,
|
masterKey: null,
|
||||||
|
privateKey: null as CryptoKey | null,
|
||||||
|
publicKey: null,
|
||||||
setAuth: ({ accessToken, refreshToken, userId }) =>
|
setAuth: ({ accessToken, refreshToken, userId }) =>
|
||||||
set({ accessToken, refreshToken, userId }),
|
set({ accessToken, refreshToken, userId }),
|
||||||
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
||||||
setMasterKey: (masterKey) => set({ masterKey }),
|
setMasterKey: (masterKey) => set({ masterKey }),
|
||||||
|
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
|
||||||
setUsername: (username) => set({ username }),
|
setUsername: (username) => set({ username }),
|
||||||
logout: () =>
|
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',
|
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) => ({
|
partialize: (s) => ({
|
||||||
accessToken: s.accessToken,
|
accessToken: s.accessToken,
|
||||||
refreshToken: s.refreshToken,
|
refreshToken: s.refreshToken,
|
||||||
@@ -44,11 +57,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ConvKey {
|
|
||||||
conversationId: string;
|
|
||||||
key: CryptoKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CryptoStore {
|
interface CryptoStore {
|
||||||
convKeys: Map<string, CryptoKey>;
|
convKeys: Map<string, CryptoKey>;
|
||||||
setConvKey: (conversationId: string, key: CryptoKey) => void;
|
setConvKey: (conversationId: string, key: CryptoKey) => void;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
|
import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
@@ -39,8 +39,12 @@ export default function DrivePage() {
|
|||||||
const { data: files = [] } = useQuery<DriveFile[]>({
|
const { data: files = [] } = useQuery<DriveFile[]>({
|
||||||
queryKey: ['files'],
|
queryKey: ['files'],
|
||||||
queryFn: () => api.get('/files').then((r) => r.data),
|
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<string, string> = {};
|
const names: Record<string, string> = {};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async (f) => {
|
files.map(async (f) => {
|
||||||
@@ -53,8 +57,8 @@ export default function DrivePage() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
setDecryptedNames(names);
|
setDecryptedNames(names);
|
||||||
},
|
})();
|
||||||
} as any);
|
}, [files, masterKey]);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { useAuthStore } from '../lib/store';
|
import { useAuthStore } from '../lib/store';
|
||||||
import { deriveKeyFromPassword, decryptMasterKey } from '../crypto';
|
import { deriveKeyFromPassword, decryptMasterKey, decryptPrivateKeyToCryptoKey } from '../crypto';
|
||||||
import { socket } from '../lib/ws';
|
import { socket } from '../lib/ws';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setAuth, setMasterKey, setUsername } = useAuthStore();
|
const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
@@ -34,17 +34,22 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken, userId, kdfSalt, encryptedMasterKey, username } =
|
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey } = res.data;
|
||||||
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 derived = await deriveKeyFromPassword(password, kdfSalt);
|
||||||
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
|
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
|
||||||
|
|
||||||
|
// Set tokens first so the next API call has a Bearer token
|
||||||
setAuth({ accessToken, refreshToken, userId });
|
setAuth({ accessToken, refreshToken, userId });
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
setUsername(username);
|
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);
|
socket.connect(accessToken);
|
||||||
navigate('/messages');
|
navigate('/messages');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -89,36 +94,22 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="btn-primary w-full"
|
|
||||||
>
|
|
||||||
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
|
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-400 mt-4">
|
<p className="text-center text-sm text-slate-400 mt-4">
|
||||||
No account?{' '}
|
No account?{' '}
|
||||||
<Link to="/register" className="text-accent hover:underline">
|
<Link to="/register" className="text-accent hover:underline">Register</Link>
|
||||||
Register
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({
|
function Field({ label, type, value, onChange }: {
|
||||||
label,
|
label: string; type: string; value: string; onChange: (v: string) => void;
|
||||||
type,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Send, Plus, Search } from 'lucide-react';
|
import { Send, Plus, Search } from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
@@ -9,9 +9,10 @@ import { socket } from '../lib/ws';
|
|||||||
import {
|
import {
|
||||||
encryptMessage,
|
encryptMessage,
|
||||||
decryptMessage,
|
decryptMessage,
|
||||||
generateConversationKey,
|
generateConvKeyRaw,
|
||||||
encryptConversationKey,
|
sealConvKey,
|
||||||
decryptConversationKey,
|
openSealedConvKey,
|
||||||
|
importConvKey,
|
||||||
} from '../crypto';
|
} from '../crypto';
|
||||||
|
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
@@ -30,15 +31,22 @@ interface Message {
|
|||||||
plaintext?: string;
|
plaintext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserResult {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
public_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MessagesPage() {
|
export default function MessagesPage() {
|
||||||
const { id: activeId } = useParams();
|
const { id: activeId } = useParams();
|
||||||
const { userId, masterKey } = useAuthStore();
|
const navigate = useNavigate();
|
||||||
|
const { userId, publicKey, privateKey } = useAuthStore();
|
||||||
const { setConvKey, getConvKey } = useCryptoStore();
|
const { setConvKey, getConvKey } = useCryptoStore();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [newUserSearch, setNewUserSearch] = useState('');
|
const [newUserSearch, setNewUserSearch] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
|
const [searchResults, setSearchResults] = useState<UserResult[]>([]);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: conversations = [] } = useQuery<Conversation[]>({
|
const { data: conversations = [] } = useQuery<Conversation[]>({
|
||||||
@@ -46,9 +54,9 @@ export default function MessagesPage() {
|
|||||||
queryFn: () => api.get('/conversations').then((r) => r.data),
|
queryFn: () => api.get('/conversations').then((r) => r.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load + decrypt messages when conversation changes
|
// Load + decrypt messages when active conversation changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeId || !masterKey) return;
|
if (!activeId || !publicKey || !privateKey) return;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const conv = conversations.find((c) => c.id === activeId);
|
const conv = conversations.find((c) => c.id === activeId);
|
||||||
@@ -56,7 +64,9 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
let convKey = getConvKey(activeId!);
|
let convKey = getConvKey(activeId!);
|
||||||
if (!convKey) {
|
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);
|
setConvKey(activeId!, convKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +83,7 @@ export default function MessagesPage() {
|
|||||||
load();
|
load();
|
||||||
}, [activeId, conversations.length]);
|
}, [activeId, conversations.length]);
|
||||||
|
|
||||||
// WebSocket incoming messages
|
// WebSocket: decrypt incoming messages in real time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return socket.on(async (raw) => {
|
return socket.on(async (raw) => {
|
||||||
const msg = raw as any;
|
const msg = raw as any;
|
||||||
@@ -85,7 +95,7 @@ export default function MessagesPage() {
|
|||||||
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...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]);
|
}, [activeId]);
|
||||||
@@ -104,25 +114,35 @@ export default function MessagesPage() {
|
|||||||
setText('');
|
setText('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startConversation(recipientId: string, recipientUsername: string) {
|
async function startConversation(recipient: UserResult) {
|
||||||
if (!masterKey) return;
|
if (!publicKey || !privateKey) return;
|
||||||
|
|
||||||
const convKey = await generateConversationKey();
|
// Generate random conversation key
|
||||||
const myEncKey = await encryptConversationKey(convKey, masterKey);
|
const convKeyRaw = generateConvKeyRaw();
|
||||||
const recipientEncKey = await encryptConversationKey(convKey, masterKey); // real impl: encrypt with recipient's pubkey
|
|
||||||
|
// 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', {
|
const res = await api.post('/conversations', {
|
||||||
recipientId,
|
recipientId: recipient.id,
|
||||||
encryptedKeys: {
|
encryptedKeys: {
|
||||||
[userId!]: myEncKey,
|
[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);
|
setConvKey(res.data.conversationId, convKey);
|
||||||
|
|
||||||
qc.invalidateQueries({ queryKey: ['conversations'] });
|
qc.invalidateQueries({ queryKey: ['conversations'] });
|
||||||
setNewUserSearch('');
|
setNewUserSearch('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
navigate(`/messages/${res.data.conversationId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSearch(q: string) {
|
async function handleSearch(q: string) {
|
||||||
@@ -154,7 +174,7 @@ export default function MessagesPage() {
|
|||||||
{searchResults.map((u) => (
|
{searchResults.map((u) => (
|
||||||
<button
|
<button
|
||||||
key={u.id}
|
key={u.id}
|
||||||
onClick={() => startConversation(u.id, u.username)}
|
onClick={() => startConversation(u)}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
|
||||||
>
|
>
|
||||||
{u.username}
|
{u.username}
|
||||||
@@ -168,9 +188,9 @@ export default function MessagesPage() {
|
|||||||
{conversations.map((conv) => {
|
{conversations.map((conv) => {
|
||||||
const other = conv.members.find((m) => m.id !== userId);
|
const other = conv.members.find((m) => m.id !== userId);
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
href={`/messages/${conv.id}`}
|
to={`/messages/${conv.id}`}
|
||||||
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
|
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
|
||||||
activeId === conv.id ? 'bg-surface-2' : ''
|
activeId === conv.id ? 'bg-surface-2' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -186,7 +206,7 @@ export default function MessagesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +241,7 @@ export default function MessagesPage() {
|
|||||||
>
|
>
|
||||||
<p>{m.plaintext}</p>
|
<p>{m.plaintext}</p>
|
||||||
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||||
{formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })}
|
{m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
deriveKeyFromPassword,
|
deriveKeyFromPassword,
|
||||||
generateMasterKey,
|
generateMasterKey,
|
||||||
encryptMasterKey,
|
encryptMasterKey,
|
||||||
|
generateX25519Keypair,
|
||||||
|
encryptPrivateKey,
|
||||||
} from '../crypto';
|
} from '../crypto';
|
||||||
import { socket } from '../lib/ws';
|
import { socket } from '../lib/ws';
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setAuth, setMasterKey, setUsername } = useAuthStore();
|
const { setAuth, setMasterKey, setKeyPair, setUsername } = useAuthStore();
|
||||||
const [username, setUsernameField] = useState('');
|
const [username, setUsernameField] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -29,15 +31,15 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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 kdfSalt = await generateKdfSalt();
|
||||||
const derivedKey = await deriveKeyFromPassword(password, kdfSalt);
|
const derivedKey = await deriveKeyFromPassword(password, kdfSalt);
|
||||||
const masterKey = await generateMasterKey();
|
const masterKey = await generateMasterKey();
|
||||||
const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey);
|
const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey);
|
||||||
|
|
||||||
// Minimal public key placeholder (full ECDH exchange would use X25519 via libsodium)
|
// X25519 keypair for ECDH conversation key exchange
|
||||||
const publicKey = 'placeholder-pubkey';
|
const { publicKeyB64, privateKey, pkcs8Bytes } = await generateX25519Keypair();
|
||||||
const keySignature = 'placeholder-sig';
|
const encryptedPrivKey = await encryptPrivateKey(pkcs8Bytes, masterKey);
|
||||||
|
|
||||||
const res = await api.post('/auth/register', {
|
const res = await api.post('/auth/register', {
|
||||||
username,
|
username,
|
||||||
@@ -45,13 +47,14 @@ export default function RegisterPage() {
|
|||||||
password,
|
password,
|
||||||
kdfSalt,
|
kdfSalt,
|
||||||
encryptedMasterKey,
|
encryptedMasterKey,
|
||||||
publicKey,
|
publicKey: publicKeyB64,
|
||||||
keySignature,
|
encryptedPrivateKey: encryptedPrivKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { accessToken, refreshToken, userId } = res.data;
|
const { accessToken, refreshToken, userId } = res.data;
|
||||||
setAuth({ accessToken, refreshToken, userId });
|
setAuth({ accessToken, refreshToken, userId });
|
||||||
setMasterKey(masterKey);
|
setMasterKey(masterKey);
|
||||||
|
setKeyPair(publicKeyB64, privateKey);
|
||||||
setUsername(username);
|
setUsername(username);
|
||||||
|
|
||||||
socket.connect(accessToken);
|
socket.connect(accessToken);
|
||||||
@@ -86,7 +89,7 @@ export default function RegisterPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||||
{loading ? 'Creating account…' : 'Create account'}
|
{loading ? 'Generating keys…' : 'Create account'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user