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.
125 lines
3.7 KiB
TypeScript
125 lines
3.7 KiB
TypeScript
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;
|