feat: zero-knowledge recovery (Phase 2) — wrapped-DEK model

Introduces a wrapped-DEK recovery model so a forgotten password doesn't lose
all encrypted data. The encryption key becomes a random DEK (not derived from
the password); the DEK is wrapped under both a password-derived KEK and a
recovery-phrase-derived KEK, and both wrapped forms are stored on the server.

Crypto (crypto/keys.ts):
- DEK generation (random AES-256-GCM), KEK derivation (PBKDF2 password/recovery),
  wrapDek/unwrapDek/rewrapDek.
- setupEncryption(password, recoveryPhrase?) — generates a DEK, wraps under both
  KEKs, returns wrapped forms + recovery proof.
- unlockWithPassword(password, wrappedDek) — derives password KEK, unwraps DEK.
- unlockWithRecovery(phrase, wrappedDek) — derives recovery KEK, unwraps DEK.

Backend:
- User model: wrapped_dek, wrapped_dek_iv, recovery_wrapped_dek,
  recovery_wrapped_dek_iv fields.
- RegisterRequest accepts wrapped-DEK fields; stored verbatim.
- AuthResponse returns wrapped_dek + wrapped_dek_iv (for login unwrapping).
- New GET /api/auth/recovery-info?email= — returns recovery-wrapped DEK.
- RecoverPasswordRequest gains new_wrapped_dek + new_wrapped_dek_iv.
- change-password also accepts + stores re-wrapped DEK.

Frontend:
- Auth store: login unwraps DEK from response; new recover() action fetches
  recovery-wrapped DEK, unwraps with phrase, re-wraps under new password.
- RecoveryPage (new): email + recovery phrase + new password flow.
- LoginPage: 'Forgot password? Recover' link. App.tsx: /recover route.

Verified: backend 21 tests, 0 warnings; frontend build clean, 20 tests.
This commit is contained in:
goose 2026-06-29 03:34:24 -03:00
parent 62ef1abb84
commit 7a641dec00
13 changed files with 528 additions and 67 deletions

View file

@ -103,6 +103,11 @@ export const LoginPage: React.FC = () => {
Don't have an account? Sign Up
</Typography>
</Link>
<Link to="/recover">
<Typography variant="body2" sx={{ mt: 1 }}>
Forgot password? Recover
</Typography>
</Link>
</Box>
</Box>
</Paper>

View file

@ -0,0 +1,125 @@
import { useState, type FC } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Container,
Paper,
TextField,
Button,
Typography,
Box,
Alert,
CircularProgress,
} from '@mui/material';
import { useAuthStore } from '../store/useStore';
export const RecoveryPage: FC = () => {
const navigate = useNavigate();
const { recover, isLoading, error, clearError } = useAuthStore();
const [email, setEmail] = useState('');
const [recoveryPhrase, setRecoveryPhrase] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [localError, setLocalError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
setLocalError('');
if (newPassword !== confirmPassword) {
setLocalError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setLocalError('Password must be at least 8 characters');
return;
}
try {
await recover(email, recoveryPhrase, newPassword);
navigate('/login', { replace: true });
} catch {
/* error surfaced via store */
}
};
return (
<Container maxWidth="sm">
<Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Paper elevation={3} sx={{ p: 4, width: '100%', borderRadius: 2 }}>
<Typography component="h1" variant="h4" align="center" gutterBottom>
Recover Account
</Typography>
<Typography variant="body2" align="center" color="text.secondary" sx={{ mb: 2 }}>
Enter your recovery phrase to reset your password and regain access to your encrypted data.
</Typography>
{(error || localError) && (
<Alert severity="error" sx={{ mb: 2 }}>
{error || localError}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<TextField
margin="normal"
required
fullWidth
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
label="Recovery Phrase"
value={recoveryPhrase}
onChange={(e) => setRecoveryPhrase(e.target.value)}
disabled={isLoading}
/>
<TextField
margin="normal"
required
fullWidth
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={isLoading}
/>
<TextField
margin="normal"
required
fullWidth
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={isLoading}
>
{isLoading ? <CircularProgress size={24} /> : 'Recover Account'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Link to="/login">
<Typography variant="body2">Back to Sign In</Typography>
</Link>
</Box>
</Box>
</Paper>
</Box>
</Container>
);
};
export default RecoveryPage;