diff --git a/backend/src/app.rs b/backend/src/app.rs index 7a6ca1f..63b86a5 100644 --- a/backend/src/app.rs +++ b/backend/src/app.rs @@ -34,7 +34,8 @@ pub fn build_app(state: AppState) -> Router { .route( "/api/auth/recover-password", post(handlers::recover_password), - ); + ) + .route("/api/auth/recovery-info", get(handlers::recovery_info)); // Build protected routes (auth required) let protected_routes = Router::new() diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs index 66e8a25..cbe8836 100644 --- a/backend/src/handlers/auth.rs +++ b/backend/src/handlers/auth.rs @@ -1,4 +1,7 @@ -use axum::{extract::Extension, extract::State, http::StatusCode, response::IntoResponse, Json}; +use axum::{ + extract::Extension, extract::Query, extract::State, http::StatusCode, response::IntoResponse, + Json, +}; use mongodb::bson::oid::ObjectId; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -16,8 +19,14 @@ pub struct RegisterRequest { pub username: String, #[validate(length(min = 8))] pub password: String, - /// Optional recovery phrase for password recovery + /// Optional recovery phrase hash (for server-side verification) pub recovery_phrase: Option, + /// Wrapped DEK (base64 ciphertext) — encrypted under the password KEK + pub wrapped_dek: Option, + pub wrapped_dek_iv: Option, + /// Wrapped DEK (base64 ciphertext) — encrypted under the recovery KEK + pub recovery_wrapped_dek: Option, + pub recovery_wrapped_dek_iv: Option, } #[derive(Debug, Serialize)] @@ -27,6 +36,10 @@ pub struct AuthResponse { pub user_id: String, pub email: String, pub username: String, + /// Wrapped DEK + IV (for the client to unwrap at login). Absent if the + /// user has no wrapped DEK (legacy/pre-recovery accounts). + pub wrapped_dek: Option, + pub wrapped_dek_iv: Option, } pub async fn register( @@ -70,11 +83,11 @@ pub async fn register( } // Create new user - let user = match User::new( + let mut user = match User::new( req.email.clone(), req.username.clone(), req.password, - req.recovery_phrase, + req.recovery_phrase.clone(), ) { Ok(u) => u, Err(e) => { @@ -89,6 +102,13 @@ pub async fn register( } }; + // Store the wrapped-DEK fields (zero-knowledge: server stores verbatim). + user.wrapped_dek = req.wrapped_dek; + user.wrapped_dek_iv = req.wrapped_dek_iv; + user.recovery_wrapped_dek = req.recovery_wrapped_dek.clone(); + user.recovery_wrapped_dek_iv = req.recovery_wrapped_dek_iv.clone(); + user.recovery_enabled = req.recovery_wrapped_dek.is_some(); + // Get token_version before saving let token_version = user.token_version; @@ -179,8 +199,10 @@ pub async fn register( token, refresh_token, user_id: user_id.to_string(), - email: user.email, - username: user.username, + email: user.email.clone(), + username: user.username.clone(), + wrapped_dek: user.wrapped_dek.clone(), + wrapped_dek_iv: user.wrapped_dek_iv.clone(), }; (StatusCode::CREATED, Json(response)).into_response() @@ -387,8 +409,10 @@ pub async fn login( token, refresh_token, user_id: user_id.to_string(), - email: user.email, - username: user.username, + email: user.email.clone(), + username: user.username.clone(), + wrapped_dek: user.wrapped_dek.clone(), + wrapped_dek_iv: user.wrapped_dek_iv.clone(), }; (StatusCode::OK, Json(response)).into_response() @@ -398,10 +422,61 @@ pub async fn login( pub struct RecoverPasswordRequest { #[validate(email)] pub email: String, + /// The recovery-phrase-derived proof (base64) — the server verifies it + /// against the stored recovery_phrase_hash. #[validate(length(min = 1))] pub recovery_phrase: String, + /// The new auth secret (base64) — the server hashes it as the new password. #[validate(length(min = 8))] pub new_password: String, + /// The DEK re-wrapped under the new password KEK (base64 ciphertext + IV). + pub new_wrapped_dek: Option, + pub new_wrapped_dek_iv: Option, +} + +/// GET /api/auth/recovery-info?email=... — returns the recovery-wrapped DEK so +/// the client can unwrap it with the recovery phrase. Public (the wrapped DEK +/// is useless without the recovery phrase). +#[derive(Debug, Deserialize)] +pub struct RecoveryInfoQuery { + pub email: String, +} + +pub async fn recovery_info( + State(state): State, + Query(query): Query, +) -> impl IntoResponse { + let user = match state.db.find_user_by_email(&query.email).await { + Ok(Some(u)) => u, + Ok(None) => { + // Don't reveal whether the email exists — return disabled. + return ( + StatusCode::OK, + Json(serde_json::json!({ + "recovery_enabled": false, + })), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Recovery info lookup failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "database error" })), + ) + .into_response(); + } + }; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "recovery_enabled": user.recovery_enabled, + "recovery_wrapped_dek": user.recovery_wrapped_dek, + "recovery_wrapped_dek_iv": user.recovery_wrapped_dek_iv, + })), + ) + .into_response() } pub async fn recover_password( @@ -468,9 +543,14 @@ pub async fn recover_password( } } - // Update password + // Update password + store the new wrapped DEK (re-wrapped client-side under + // the new password KEK). The server never sees the DEK itself. match user.update_password(req.new_password) { - Ok(_) => {} + Ok(_) => { + // Store the re-wrapped DEK so the new password can unlock it. + user.wrapped_dek = req.new_wrapped_dek; + user.wrapped_dek_iv = req.new_wrapped_dek_iv; + } Err(e) => { tracing::error!("Failed to update password: {}", e); return ( diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 417a445..632369f 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -14,7 +14,7 @@ pub mod users; pub use appointments::{ create_appointment, delete_appointment, get_appointment, list_appointments, update_appointment, }; -pub use auth::{login, logout, recover_password, refresh, register}; +pub use auth::{login, logout, recover_password, recovery_info, refresh, register}; pub use health::{health_check, ready_check}; pub use health_stats::{ create_health_stat, delete_health_stat, get_health_stat, get_health_trends, list_health_stats, diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 664331b..0dc0660 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -187,6 +187,10 @@ pub struct ChangePasswordRequest { pub current_password: String, #[validate(length(min = 8))] pub new_password: String, + /// Re-wrapped DEK under the new password KEK (zero-knowledge: the client + /// re-wraps the DEK before sending). + pub new_wrapped_dek: Option, + pub new_wrapped_dek_iv: Option, } pub async fn change_password( @@ -268,7 +272,11 @@ pub async fn change_password( // Update password (this also bumps token_version in memory) match user.update_password(req.new_password) { - Ok(_) => {} + Ok(_) => { + // Store the re-wrapped DEK (zero-knowledge: client re-wrapped it). + user.wrapped_dek = req.new_wrapped_dek; + user.wrapped_dek_iv = req.new_wrapped_dek_iv; + } Err(e) => { tracing::error!("Failed to hash new password: {}", e); return ( diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 53b9cf8..74de914 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -23,6 +23,20 @@ pub struct User { /// Whether password recovery is enabled for this user pub recovery_enabled: bool, + /// Wrapped DEK (data encryption key) — the DEK encrypted under the password + /// KEK. Base64 ciphertext. The server stores this verbatim; only the client + /// can unwrap it. (Phase 2 zero-knowledge recovery.) + #[serde(skip_serializing_if = "Option::is_none")] + pub wrapped_dek: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wrapped_dek_iv: Option, + + /// Wrapped DEK under the recovery KEK — for password recovery. + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_wrapped_dek: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recovery_wrapped_dek_iv: Option, + /// Token version for invalidating all tokens on password change pub token_version: i32, @@ -75,6 +89,10 @@ impl User { password_hash, recovery_phrase_hash, recovery_enabled, + wrapped_dek: None, + wrapped_dek_iv: None, + recovery_wrapped_dek: None, + recovery_wrapped_dek_iv: None, token_version: 0, created_at: now, last_active: now, diff --git a/web/normogen-web/src/App.tsx b/web/normogen-web/src/App.tsx index 0864a43..652fa2a 100644 --- a/web/normogen-web/src/App.tsx +++ b/web/normogen-web/src/App.tsx @@ -1,6 +1,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 { Dashboard } from './pages/Dashboard'; import { ProtectedRoute } from './components/common/ProtectedRoute'; @@ -11,6 +12,7 @@ function App() { {/* Public routes */} } /> } /> + } /> {/* Protected routes */} { - const passwordKey = await crypto.subtle.importKey( +// --------------------------------------------------------------------------- +// Low-level: PBKDF2 derivation helpers +// --------------------------------------------------------------------------- + +async function pbkdf2DeriveBits(password: string, salt: string): Promise { + const key = await crypto.subtle.importKey( 'raw', - encoder.encode(password), + encoder.encode(password) as BufferSource, 'PBKDF2', false, ['deriveBits'], ); - - // Auth secret: 32 raw bytes, base64-encoded for transport. - const authBits = await crypto.subtle.deriveBits( - { name: 'PBKDF2', salt: encoder.encode(AUTH_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, - passwordKey, + return crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: encoder.encode(salt) as BufferSource, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + key, KEY_BITS, ); - const authSecret = base64(new Uint8Array(authBits)); - - // Encryption key: importable AES-GCM key, non-extractable. - const encBits = await crypto.subtle.deriveBits( - { name: 'PBKDF2', salt: encoder.encode(ENC_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, - passwordKey, - KEY_BITS, - ); - const encKey = await crypto.subtle.importKey( - 'raw', - encBits, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'], - ); - - return { authSecret, encKey }; } function base64(bytes: Uint8Array): string { @@ -67,32 +52,182 @@ function base64(bytes: Uint8Array): string { return btoa(bin); } +/** Derive the auth secret (base64) from the password — sent to the server. */ +export async function deriveAuthSecret(password: string): Promise { + const bits = await pbkdf2DeriveBits(password, AUTH_SALT); + return base64(new Uint8Array(bits)); +} + +/** Derive a KEK (extractable AES-GCM key) from a password or recovery phrase. */ +async function deriveKEK(secret: string, salt: string): Promise { + const bits = await pbkdf2DeriveBits(secret, salt); + return crypto.subtle.importKey( + 'raw', + bits, + { name: 'AES-GCM' }, + true, // extractable so we can export for wrapping + ['encrypt', 'decrypt'], + ); +} + // --------------------------------------------------------------------------- -// In-memory encryption-key store. -// -// The encKey lives only in memory for the lifetime of the authenticated -// session (the tab). It is deliberately NOT persisted — losing the tab or -// closing the browser requires re-entering the password to re-derive it. +// DEK generation + wrapping +// --------------------------------------------------------------------------- + +/** Generate a random AES-256-GCM DEK (extractable for wrapping). */ +export async function generateDek(): Promise { + return crypto.subtle.generateKey( + { name: 'AES-GCM', length: KEY_BITS }, + true, // extractable so we can export raw bytes for wrapping + ['encrypt', 'decrypt'], + ); +} + +/** Export a DEK to raw bytes, encrypt (wrap) it under a KEK, return base64. */ +export async function wrapDek(dek: CryptoKey, kek: CryptoKey): Promise { + const rawDek = await crypto.subtle.exportKey('raw', dek); + return encrypt( + new TextDecoder().decode(new Uint8Array(rawDek)), + kek, + ); +} + +/** Decrypt (unwrap) a wrapped DEK and import as a non-extractable AES-GCM key. */ +export async function unwrapDek(payload: CipherPayload, kek: CryptoKey): Promise { + const rawDekB64 = await decrypt(payload, kek); + // The wrapped DEK was encrypted as a base64 string of the raw key bytes. + const rawDek = Uint8Array.from(atob(rawDekB64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + 'raw', + rawDek as BufferSource, + { name: 'AES-GCM' }, + false, // non-extractable in the session (can't be re-exported) + ['encrypt', 'decrypt'], + ); +} + +/** Re-wrap a DEK under a new KEK (for password change / post-recovery). */ +export async function rewrapDek( + dek: CryptoKey, + newSecret: string, + salt: string = PASSWORD_KEK_SALT, +): Promise { + const newKek = await deriveKEK(newSecret, salt); + return wrapDek(dek, newKek); +} + +// --------------------------------------------------------------------------- +// High-level flows +// --------------------------------------------------------------------------- + +export interface EncryptionSetup { + authSecret: string; + dek: CryptoKey; + passwordWrappedDek: CipherPayload; + recoveryWrappedDek?: CipherPayload; + recoveryKekHash?: string; // base64 hash of the recovery KEK proof (for server verification) +} + +/** + * Full setup at registration: derive auth secret, generate a DEK, wrap it under + * the password KEK and (optionally) the recovery-phrase KEK. + */ +export async function setupEncryption( + password: string, + recoveryPhrase?: string, +): Promise { + const authSecret = await deriveAuthSecret(password); + const dek = await generateDek(); + + const passwordKek = await deriveKEK(password, PASSWORD_KEK_SALT); + const passwordWrappedDek = await wrapDek(dek, passwordKek); + + let recoveryWrappedDek: CipherPayload | undefined; + let recoveryKekHash: string | undefined; + + if (recoveryPhrase) { + const recoveryKek = await deriveKEK(recoveryPhrase, RECOVERY_KEK_SALT); + recoveryWrappedDek = await wrapDek(dek, recoveryKek); + // A proof the server can verify: derive a separate value from the recovery + // phrase and hash it. The server stores this hash; at recovery time the + // client sends the derived value and the server PBKDF2-verifies it. + // We send the recovery auth proof (base64 of a PBKDF2 derivation). + recoveryKekHash = await deriveAuthSecret(recoveryPhrase); // reuse the auth derivation as the proof + } + + return { authSecret, dek, passwordWrappedDek, recoveryWrappedDek, recoveryKekHash }; +} + +export interface UnlockResult { + authSecret: string; + dek: CryptoKey; +} + +/** + * At login: derive the auth secret and unwrap the DEK from the password-wrapped form. + */ +export async function unlockWithPassword( + password: string, + passwordWrappedDek: CipherPayload, +): Promise { + const authSecret = await deriveAuthSecret(password); + const passwordKek = await deriveKEK(password, PASSWORD_KEK_SALT); + const dek = await unwrapDek(passwordWrappedDek, passwordKek); + return { authSecret, dek }; +} + +/** + * At recovery: unwrap the DEK from the recovery-wrapped form using the recovery phrase. + */ +export async function unlockWithRecovery( + recoveryPhrase: string, + recoveryWrappedDek: CipherPayload, +): Promise { + const recoveryKek = await deriveKEK(recoveryPhrase, RECOVERY_KEK_SALT); + return unwrapDek(recoveryWrappedDek, recoveryKek); +} + +// --------------------------------------------------------------------------- +// In-memory key store (unchanged from Phase 1) // --------------------------------------------------------------------------- let currentEncKey: CryptoKey | null = null; -/** Set the session encryption key (called on login/register). */ export function setEncKey(key: CryptoKey): void { currentEncKey = key; } -/** Get the session encryption key, or null if not authenticated. */ export function getEncKey(): CryptoKey | null { return currentEncKey; } -/** Clear the session encryption key (called on logout). */ export function clearEncKey(): void { currentEncKey = null; } -/** True if a session encryption key is available (i.e. the user can encrypt/decrypt). */ export function hasEncKey(): boolean { return currentEncKey !== null; } + +// --------------------------------------------------------------------------- +// Backward compat: the old deriveAuthAndEncKeys (Phase 1 direct-from-password +// enc key). Kept for tests; new code uses setupEncryption/unlockWithPassword. +// --------------------------------------------------------------------------- + +export async function deriveAuthAndEncKeys(password: string): Promise<{ + authSecret: string; + encKey: CryptoKey; +}> { + const authSecret = await deriveAuthSecret(password); + // Phase 1 derived the enc key directly from the password. For backward compat + // with tests that don't have a wrapped DEK, derive it the old way. + const encBits = await pbkdf2DeriveBits(password, 'normogen-enc-v1'); + const encKey = await crypto.subtle.importKey( + 'raw', + encBits, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); + return { authSecret, encKey }; +} diff --git a/web/normogen-web/src/pages/LoginPage.tsx b/web/normogen-web/src/pages/LoginPage.tsx index afba118..4d04138 100644 --- a/web/normogen-web/src/pages/LoginPage.tsx +++ b/web/normogen-web/src/pages/LoginPage.tsx @@ -103,6 +103,11 @@ export const LoginPage: React.FC = () => { Don't have an account? Sign Up + + + Forgot password? Recover + + diff --git a/web/normogen-web/src/pages/RecoveryPage.tsx b/web/normogen-web/src/pages/RecoveryPage.tsx new file mode 100644 index 0000000..ee4a07b --- /dev/null +++ b/web/normogen-web/src/pages/RecoveryPage.tsx @@ -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 ( + + + + + Recover Account + + + Enter your recovery phrase to reset your password and regain access to your encrypted data. + + + {(error || localError) && ( + + {error || localError} + + )} + + + setEmail(e.target.value)} + disabled={isLoading} + autoFocus + /> + setRecoveryPhrase(e.target.value)} + disabled={isLoading} + /> + setNewPassword(e.target.value)} + disabled={isLoading} + /> + setConfirmPassword(e.target.value)} + disabled={isLoading} + /> + + + + Back to Sign In + + + + + + + ); +}; + +export default RecoveryPage; diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index ca490d0..eb8f6be 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -215,6 +215,23 @@ class ApiService { return response.data; } + // ---- Recovery (zero-knowledge) ---- + + async getRecoveryInfo(email: string): Promise<{ recovery_enabled: boolean; recovery_wrapped_dek?: string; recovery_wrapped_dek_iv?: string }> { + const response = await this.client.get('/auth/recovery-info', { params: { email } }); + return response.data; + } + + async recoverPassword(email: string, recoveryProof: string, newAuthSecret: string, newWrappedDek?: string, newWrappedDekIv?: string): Promise { + await this.client.post('/auth/recover-password', { + email, + recovery_phrase: recoveryProof, + new_password: newAuthSecret, + new_wrapped_dek: newWrappedDek, + new_wrapped_dek_iv: newWrappedDekIv, + }); + } + // ---- Profile (zero-knowledge: opaque encrypted name) ---- async getProfile(): Promise { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index 3e19a04..4d460fa 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -2,11 +2,16 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { deriveAuthAndEncKeys, + setupEncryption, + unlockWithPassword, + unlockWithRecovery, + rewrapDek, setEncKey, clearEncKey, getEncKey, encryptJson, decryptJson, + type CipherPayload, } from '../crypto'; import { User, @@ -31,6 +36,7 @@ interface AuthState { // Actions login: (email: string, password: string) => Promise; register: (username: string, email: string, password: string) => Promise; + recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise; logout: () => Promise; clearError: () => void; loadUser: () => Promise; @@ -118,11 +124,22 @@ export const useAuthStore = create()( login: async (email: string, password: string) => { set({ isLoading: true, error: null }); try { - // Zero-knowledge: derive auth secret + encryption key from password. + // Zero-knowledge: derive auth secret (sent to server) + encryption key. const { authSecret, encKey } = await deriveAuthAndEncKeys(password); setEncKey(encKey); - // Send the derived auth secret (not the raw password) to the server. const response = await apiService.login(email, authSecret); + // If the server returned a wrapped DEK, unwrap it with the password. + if (response.wrapped_dek && response.wrapped_dek_iv) { + try { + const { dek } = await unlockWithPassword(password, { + data: response.wrapped_dek, + iv: response.wrapped_dek_iv, + }); + setEncKey(dek); + } catch { + // Fallback to the PBKDF2-derived key (Phase 1 compat). + } + } set({ user: { user_id: response.user_id, @@ -150,6 +167,9 @@ export const useAuthStore = create()( 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. const response = await apiService.register({ username, email, @@ -176,6 +196,43 @@ export const useAuthStore = create()( } }, + recover: async (email: string, recoveryPhrase: string, newPassword: string) => { + set({ isLoading: true, error: null }); + try { + // 1. Fetch the recovery-wrapped DEK from the server. + const info = await apiService.getRecoveryInfo(email); + if (!info.recovery_enabled || !info.recovery_wrapped_dek || !info.recovery_wrapped_dek_iv) { + throw new Error('Recovery is not enabled for this account'); + } + // 2. Unwrap the DEK using the recovery phrase. + const dek = await unlockWithRecovery(recoveryPhrase, { + data: info.recovery_wrapped_dek, + iv: info.recovery_wrapped_dek_iv, + }); + // 3. Re-wrap the DEK under the new password. + const newWrapped = await rewrapDek(dek, newPassword); + // 4. Derive the new auth secret + recovery proof. + const newAuthSecret = (await deriveAuthAndEncKeys(newPassword)).authSecret; + const recoveryProof = (await deriveAuthAndEncKeys(recoveryPhrase)).authSecret; + // 5. Send the recovery request. + await apiService.recoverPassword( + email, + recoveryProof, + newAuthSecret, + newWrapped.data, + newWrapped.iv, + ); + set({ isLoading: false }); + } catch (error: any) { + clearEncKey(); + set({ + error: error.message || 'Recovery failed', + isLoading: false, + }); + throw error; + } + }, + logout: async () => { clearEncKey(); await apiService.logout(); diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 62918ad..cb52fc7 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -40,6 +40,8 @@ export interface AuthTokens { user_id: string; username: string; email?: string; + wrapped_dek?: string; + wrapped_dek_iv?: string; } export interface LoginRequest {