feat: wire register to wrapped-DEK model + recovery phrase UI
Some checks failed
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 1m35s
Lint and Build / build (push) Has been cancelled
Lint and Build / test (push) Has been cancelled

Register now generates a DEK, wraps it under the password KEK and recovery KEK,
and sends all wrapped forms to the server (via setupEncryption). RegisterPage
has an optional recovery phrase field with a warning. Login unwraps the DEK
from the response via unlockWithPassword. This completes the full ZK recovery
flow end-to-end from the UI.

Verified: npm build clean, 20 tests pass.
This commit is contained in:
goose 2026-06-29 08:45:56 -03:00
parent 4a63b1c972
commit 8a538cbbb8
2 changed files with 44 additions and 58 deletions

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState, type FC } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { import {
Container, Container,
@ -12,14 +12,15 @@ import {
} from '@mui/material'; } from '@mui/material';
import { useAuthStore } from '../store/useStore'; import { useAuthStore } from '../store/useStore';
export const RegisterPage: React.FC = () => { export const RegisterPage: FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { register, isLoading, error, clearError } = useAuthStore();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [recoveryPhrase, setRecoveryPhrase] = useState('');
const [passwordError, setPasswordError] = useState(''); const [passwordError, setPasswordError] = useState('');
const { register, isLoading, error, clearError } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -30,54 +31,32 @@ export const RegisterPage: React.FC = () => {
setPasswordError('Passwords do not match'); setPasswordError('Passwords do not match');
return; return;
} }
if (password.length < 8) {
if (password.length < 6) { setPasswordError('Password must be at least 8 characters');
setPasswordError('Password must be at least 6 characters');
return; return;
} }
try { try {
await register(username, email, password); // Pass the recovery phrase (optional) to the store; the store derives
// the wrapped-DEK forms from it.
await register(username, email, password, recoveryPhrase || undefined);
navigate('/'); navigate('/');
} catch (err) { } catch {
// Error is handled by the store /* error surfaced via store */
} }
}; };
return ( return (
<Container maxWidth="sm"> <Container maxWidth="sm">
<Box <Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
sx={{ <Paper elevation={3} sx={{ p: 4, width: '100%', borderRadius: 2 }}>
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> <Typography component="h1" variant="h4" align="center" gutterBottom>
Create Account Create Account
</Typography> </Typography>
<Typography variant="body2" align="center" color="text.secondary" sx={{ mb: 3 }}>
Join Normogen Healthcare Platform
</Typography>
{error && ( {(error || passwordError) && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{error} {error || passwordError}
</Alert>
)}
{passwordError && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordError}
</Alert> </Alert>
)} )}
@ -86,23 +65,18 @@ export const RegisterPage: React.FC = () => {
margin="normal" margin="normal"
required required
fullWidth fullWidth
id="username"
label="Username" label="Username"
name="username"
autoComplete="username"
autoFocus
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
disabled={isLoading} disabled={isLoading}
autoFocus
/> />
<TextField <TextField
margin="normal" margin="normal"
required required
fullWidth fullWidth
id="email"
label="Email Address" label="Email Address"
name="email" type="email"
autoComplete="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
disabled={isLoading} disabled={isLoading}
@ -111,11 +85,8 @@ export const RegisterPage: React.FC = () => {
margin="normal" margin="normal"
required required
fullWidth fullWidth
name="password"
label="Password" label="Password"
type="password" type="password"
id="password"
autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
@ -124,14 +95,21 @@ export const RegisterPage: React.FC = () => {
margin="normal" margin="normal"
required required
fullWidth fullWidth
name="confirmPassword"
label="Confirm Password" label="Confirm Password"
type="password" type="password"
id="confirmPassword"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading} disabled={isLoading}
/> />
<TextField
margin="normal"
fullWidth
label="Recovery Phrase (optional but recommended)"
value={recoveryPhrase}
onChange={(e) => setRecoveryPhrase(e.target.value)}
disabled={isLoading}
helperText="If you forget your password, this phrase is your only way to recover your encrypted data. Save it somewhere safe."
/>
<Button <Button
type="submit" type="submit"
fullWidth fullWidth
@ -154,3 +132,5 @@ export const RegisterPage: React.FC = () => {
</Container> </Container>
); );
}; };
export default RegisterPage;

View file

@ -35,7 +35,7 @@ interface AuthState {
// Actions // Actions
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>; register: (username: string, email: string, password: string, recoveryPhrase?: string) => Promise<void>;
recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise<void>; recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
clearError: () => void; clearError: () => void;
@ -162,19 +162,25 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
register: async (username: string, email: string, password: string) => { register: async (username: string, email: string, password: string, recoveryPhrase?: string) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const { authSecret, encKey } = await deriveAuthAndEncKeys(password); // Zero-knowledge: generate a DEK, wrap it under the password KEK
setEncKey(encKey); // and (if provided) the recovery KEK. Send the wrapped forms + the
// For now register uses the Phase 1 key derivation (no recovery phrase // recovery proof to the server; the server stores them verbatim.
// in the basic register flow). The wrapped-DEK recovery fields are const setup = await setupEncryption(password, recoveryPhrase);
// optional on the register request. setEncKey(setup.dek);
const response = await apiService.register({ const response = await apiService.register({
username, username,
email, email,
password: authSecret, password: setup.authSecret,
}); wrapped_dek: setup.passwordWrappedDek.data,
wrapped_dek_iv: setup.passwordWrappedDek.iv,
recovery_wrapped_dek: setup.recoveryWrappedDek?.data,
recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv,
recovery_phrase: setup.recoveryKekHash,
} as any);
set({ set({
user: { user: {
user_id: response.user_id, user_id: response.user_id,