diff --git a/web/normogen-web/src/App.tsx b/web/normogen-web/src/App.tsx index 652fa2a..9b9fae1 100644 --- a/web/normogen-web/src/App.tsx +++ b/web/normogen-web/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { LoginPage } from './pages/LoginPage'; import { RegisterPage } from './pages/RegisterPage'; import { RecoveryPage } from './pages/RecoveryPage'; +import { UnlockPage } from './pages/UnlockPage'; import { Dashboard } from './pages/Dashboard'; import { ProtectedRoute } from './components/common/ProtectedRoute'; @@ -13,6 +14,7 @@ function App() { } /> } /> } /> + } /> {/* Protected routes */} = ({ children }) => { if (isLoading) { return ( -

Loading...

@@ -26,5 +27,12 @@ export const ProtectedRoute: React.FC = ({ children }) => { return ; } + // Zero-knowledge: the user is authenticated (JWT valid) but the in-memory + // encryption key is gone (page reload). Redirect to the unlock screen to + // re-derive it without a full re-login. + if (!hasEncKey()) { + return ; + } + return <>{children}; }; diff --git a/web/normogen-web/src/pages/UnlockPage.tsx b/web/normogen-web/src/pages/UnlockPage.tsx new file mode 100644 index 0000000..2752138 --- /dev/null +++ b/web/normogen-web/src/pages/UnlockPage.tsx @@ -0,0 +1,127 @@ +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 { Lock as LockIcon } from '@mui/icons-material'; +import { useAuthStore } from '../store/useStore'; +import { unlockWithPassword, deriveAuthAndEncKeys, setEncKey } from '../crypto'; + +export const UnlockPage: FC = () => { + const navigate = useNavigate(); + const { wrapped_dek, wrapped_dek_iv, user } = useAuthStore(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [unlocking, setUnlocking] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setUnlocking(true); + + try { + if (wrapped_dek && wrapped_dek_iv) { + // Wrapped-DEK model: unwrap the DEK using the password. + const { dek } = await unlockWithPassword(password, { + data: wrapped_dek, + iv: wrapped_dek_iv, + }); + setEncKey(dek); + } else { + // Phase 1 compat: derive the key directly from the password. + const { encKey } = await deriveAuthAndEncKeys(password); + setEncKey(encKey); + } + navigate('/', { replace: true }); + } catch { + setError('Incorrect password. Please try again.'); + } finally { + setUnlocking(false); + } + }; + + return ( + + + + + + Unlock + + + {user?.username + ? `Welcome back, ${user.username}. Enter your password to decrypt your data.` + : 'Enter your password to decrypt your data.'} + + + {error && ( + + {error} + + )} + + + setPassword(e.target.value)} + disabled={unlocking} + /> + + + + Sign in with a different account + + + + Forgot password? + + + + + + + + ); +}; + +export default UnlockPage; diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 32b1fe2..1bcfda5 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -32,7 +32,12 @@ interface AuthState { isAuthenticated: boolean; isLoading: boolean; error: string | null; - + // Persisted wrapped DEK — safe to store (AES-GCM ciphertext, useless without + // the password). Used by the unlock screen to re-derive the in-memory DEK + // on page reload without a full re-login. + wrapped_dek: string | null; + wrapped_dek_iv: string | null; + // Actions login: (email: string, password: string) => Promise; register: (username: string, email: string, password: string, recoveryPhrase?: string) => Promise; @@ -120,6 +125,8 @@ export const useAuthStore = create()( isAuthenticated: false, isLoading: false, error: null, + wrapped_dek: null, + wrapped_dek_iv: null, login: async (email: string, password: string) => { set({ isLoading: true, error: null }); @@ -150,6 +157,8 @@ export const useAuthStore = create()( token: response.token, isAuthenticated: true, isLoading: false, + wrapped_dek: response.wrapped_dek ?? null, + wrapped_dek_iv: response.wrapped_dek_iv ?? null, }); } catch (error: any) { clearEncKey(); @@ -191,6 +200,8 @@ export const useAuthStore = create()( token: response.token, isAuthenticated: true, isLoading: false, + wrapped_dek: setup.passwordWrappedDek.data, + wrapped_dek_iv: setup.passwordWrappedDek.iv, }); } catch (error: any) { set({ @@ -228,7 +239,8 @@ export const useAuthStore = create()( newWrapped.data, newWrapped.iv, ); - set({ isLoading: false }); + // Persist the new wrapped DEK so unlock works with the new password. + set({ isLoading: false, wrapped_dek: newWrapped.data, wrapped_dek_iv: newWrapped.iv }); } catch (error: any) { clearEncKey(); set({ @@ -247,6 +259,8 @@ export const useAuthStore = create()( token: null, isAuthenticated: false, error: null, + wrapped_dek: null, + wrapped_dek_iv: null, }); }, @@ -279,6 +293,8 @@ export const useAuthStore = create()( token: state.token, user: state.user, isAuthenticated: state.isAuthenticated, + wrapped_dek: state.wrapped_dek, + wrapped_dek_iv: state.wrapped_dek_iv, }), } )