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:
parent
62ef1abb84
commit
7a641dec00
13 changed files with 528 additions and 67 deletions
125
web/normogen-web/src/pages/RecoveryPage.tsx
Normal file
125
web/normogen-web/src/pages/RecoveryPage.tsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue