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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 10:10:40 -04:00
1 parent 937bf8ad2d
commit 46f0a4b71e
10 files changed
+308 -185

No files matched your search

+14 -23
View File
@@ -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() {
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-4">
No account?{' '}
<Link to="/register" className="text-accent hover:underline">
Register
</Link>
<Link to="/register" className="text-accent hover:underline">Register</Link>
</p>
</div>
</div>
);
}
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 (
<div>