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;
|
||||
|
||||
-- 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()
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
+30
-30
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user