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 {
Container,
@ -12,14 +12,15 @@ import {
} from '@mui/material';
import { useAuthStore } from '../store/useStore';
export const RegisterPage: React.FC = () => {
export const RegisterPage: FC = () => {
const navigate = useNavigate();
const { register, isLoading, error, clearError } = useAuthStore();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [recoveryPhrase, setRecoveryPhrase] = useState('');
const [passwordError, setPasswordError] = useState('');
const { register, isLoading, error, clearError } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -30,54 +31,32 @@ export const RegisterPage: React.FC = () => {
setPasswordError('Passwords do not match');
return;
}
if (password.length < 6) {
setPasswordError('Password must be at least 6 characters');
if (password.length < 8) {
setPasswordError('Password must be at least 8 characters');
return;
}
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('/');
} catch (err) {
// Error is handled by the store
} 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,
}}
>
<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>
Create Account
</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 }}>
{error}
</Alert>
)}
{passwordError && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordError}
{error || passwordError}
</Alert>
)}
@ -86,23 +65,18 @@ export const RegisterPage: React.FC = () => {
margin="normal"
required
fullWidth
id="username"
label="Username"
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={isLoading}
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
id="email"
label="Email Address"
name="email"
autoComplete="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
@ -111,11 +85,8 @@ export const RegisterPage: React.FC = () => {
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
@ -124,14 +95,21 @@ export const RegisterPage: React.FC = () => {
margin="normal"
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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
type="submit"
fullWidth
@ -154,3 +132,5 @@ export const RegisterPage: React.FC = () => {
</Container>
);
};
export default RegisterPage;

View file

@ -35,7 +35,7 @@ interface AuthState {
// Actions
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>;
logout: () => Promise<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 });
try {
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
setEncKey(encKey);
// For now register uses the Phase 1 key derivation (no recovery phrase
// in the basic register flow). The wrapped-DEK recovery fields are
// optional on the register request.
// Zero-knowledge: generate a DEK, wrap it under the password KEK
// and (if provided) the recovery KEK. Send the wrapped forms + the
// recovery proof to the server; the server stores them verbatim.
const setup = await setupEncryption(password, recoveryPhrase);
setEncKey(setup.dek);
const response = await apiService.register({
username,
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({
user: {
user_id: response.user_id,