import { create } from 'zustand' import { authApi, type UserMe } from './api' import { keyStore } from './crypto/keyStore' import { deriveKeysInWorker } from './crypto/keys' import { SRPClient } from './crypto/srp' import { parseSecretKey } from './crypto/secretKey' import { assertWebCryptoAvailable } from './crypto/registration' import { getAuthErrorMessage } from './errors' interface AuthState { user: UserMe | null isAuthenticated: boolean isLoading: boolean error: string | null needsSecretKey: boolean // True when SRP user on new device needs Secret Key isDerivingKeys: boolean // True during PBKDF2 computation isUpgrading: boolean // True when legacy bcrypt user is upgrading to SRP pendingUpgradeEmail: string | null // Email of user being upgraded pendingUpgradePassword: string | null // Password of user being upgraded (for SRP derivation) login: (email: string, password: string) => Promise srpLogin: (email: string, password: string, secretKeyInput?: string) => Promise logout: () => Promise checkAuth: () => Promise clearError: () => void clearNeedsSecretKey: () => void completeUpgrade: () => Promise cancelUpgrade: () => void } export const useAuth = create((set, get) => ({ user: null, isAuthenticated: false, isLoading: true, error: null, needsSecretKey: false, isDerivingKeys: false, isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null, login: async (email: string, password: string) => { set({ isLoading: true, error: null }) try { const result = await authApi.login({ email, password }) // Check if this is a legacy bcrypt user needing SRP upgrade if (result.auth_upgrade_required) { // Only show upgrade dialog if Web Crypto is available (requires HTTPS or localhost). // If not, skip upgrade and proceed with bcrypt session — upgrade happens next HTTPS visit. const hasCrypto = typeof crypto !== 'undefined' && !!crypto.subtle if (hasCrypto) { set({ isLoading: false, isUpgrading: true, pendingUpgradeEmail: email, pendingUpgradePassword: password, }) return } // Fall through to complete login without SRP upgrade } const user = await authApi.me() set({ user, isAuthenticated: true, isLoading: false, error: null }) } catch (err: unknown) { // Check if 409 srp_required -- redirect to SRP flow const axiosErr = err as { response?: { status?: number; data?: { detail?: string } } } if ( axiosErr?.response?.status === 409 && axiosErr?.response?.data?.detail === 'srp_required' ) { // User has SRP auth -- try SRP flow return get().srpLogin(email, password) } const message = getAuthErrorMessage(err) set({ isLoading: false, isAuthenticated: false, user: null, error: message, }) throw new Error(message) } }, srpLogin: async (email: string, password: string, secretKeyInput?: string) => { set({ isLoading: true, isDerivingKeys: true, error: null }) try { // 0. Verify Web Crypto API is available (requires HTTPS or localhost) assertWebCryptoAvailable() // 1. Get Secret Key (from IndexedDB or user input) let secretKeyBytes: Uint8Array | null = await keyStore.getSecretKey(email) if (!secretKeyBytes && secretKeyInput) { secretKeyBytes = parseSecretKey(secretKeyInput) if (!secretKeyBytes) { set({ error: 'Invalid Secret Key format', isLoading: false, isDerivingKeys: false }) return } } if (!secretKeyBytes) { set({ needsSecretKey: true, isLoading: false, isDerivingKeys: false }) return } // 2. SRP Step 1: init (returns salt, B, session_id, AND key derivation salts) const { salt, server_public, session_id, pbkdf2_salt, hkdf_salt } = await authApi.srpInit(email) // 3. Decode base64 salts returned by /srp/init from user_key_sets const pbkdf2SaltBytes = Uint8Array.from(atob(pbkdf2_salt), (c) => c.charCodeAt(0)) const hkdfSaltBytes = Uint8Array.from(atob(hkdf_salt), (c) => c.charCodeAt(0)) // 4. Derive keys in Web Worker (PBKDF2 650K iterations) const { auk, srpX } = await deriveKeysInWorker({ masterPassword: password, secretKeyBytes, email, accountId: email, // Use email as accountId for key derivation pbkdf2Salt: pbkdf2SaltBytes, hkdfSalt: hkdfSaltBytes, }) set({ isDerivingKeys: false }) // 5. SRP handshake const srpClient = new SRPClient(email) const { clientProof } = await srpClient.computeSession(srpX, salt, server_public) // 6. SRP Step 2: verify const result = await authApi.srpVerify({ email, session_id, client_public: srpClient.getPublicEphemeral(), client_proof: clientProof, }) // 7. Verify server proof M2 const serverValid = await srpClient.verifyServerProof(result.server_proof) if (!serverValid) { throw new Error('Server authentication failed') } // 8. Store AUK and unlock key set keyStore.setAUK(auk) // Decrypt encrypted_key_set with AUK to get vault key + RSA private key. // Non-fatal: if decryption fails (e.g. corrupted key set, wrong AUK) we log // a warning and continue. Server-side Transit encryption still works; only // Tier 1 (client-side) encrypted data will be inaccessible until re-auth. if (result.encrypted_key_set) { const ks = result.encrypted_key_set try { const b64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) // Unwrap vault key (AES-256-GCM) using AUK const vaultKey = await crypto.subtle.unwrapKey( 'raw', b64(ks.encrypted_vault_key), auk, { name: 'AES-GCM', iv: b64(ks.vault_key_nonce) }, { name: 'AES-GCM', length: 256 }, false, // non-extractable ['encrypt', 'decrypt'], ) keyStore.setVaultKey(vaultKey) // Unwrap RSA-OAEP private key using AUK const privateKey = await crypto.subtle.unwrapKey( 'pkcs8', b64(ks.encrypted_private_key), auk, { name: 'AES-GCM', iv: b64(ks.private_key_nonce) }, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, // non-extractable ['decrypt'], ) keyStore.setPrivateKey(privateKey) } catch (e) { console.warn('[auth] key set decryption failed (Tier 1 data will be inaccessible):', e) } } // 9. Store Secret Key in IndexedDB for future logins on this device await keyStore.storeSecretKey(email, secretKeyBytes) // 10. Fetch user profile const user = await authApi.me() set({ user, isAuthenticated: true, isLoading: false, needsSecretKey: false }) } catch (err) { keyStore.clearAll() const axErr = err as { response?: { status?: number; data?: { detail?: string } } } const detail = axErr?.response?.data?.detail ?? '' let message: string if (axErr?.response?.status === 401) { // SRP proof failed — wrong password, wrong Secret Key, or stale credentials. // If the user didn't manually provide a key, the stored IndexedDB key is wrong // (e.g. server was rebuilt, user re-enrolled). Show the Secret Key field so they // can enter their current key from their Emergency Kit. if (!secretKeyInput) { set({ needsSecretKey: true, isLoading: false, isDerivingKeys: false, error: 'This device has an outdated Secret Key. Please enter your current Secret Key from your Emergency Kit.' }) return } message = 'Sign in failed. Check your password and Secret Key. If you lost your Secret Key, use "Forgot password?" to reset your account and get a new one.' } else if (detail.includes('initialization failed')) { message = 'Authentication setup failed. Please try again or reset your password.' } else { message = getAuthErrorMessage(err) } set({ isLoading: false, isDerivingKeys: false, error: message }) throw err } }, completeUpgrade: async () => { // Called after SRP registration completes during upgrade flow. // The user already has a valid session cookie from the bcrypt login, // so just fetch the profile to complete authentication. A full SRP // login will happen naturally on their next session. set({ isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null }) try { const user = await authApi.me() set({ user, isAuthenticated: true, isLoading: false, error: null }) } catch (err) { set({ isLoading: false, error: getAuthErrorMessage(err) }) throw err } }, cancelUpgrade: () => { set({ isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null, isLoading: false, }) }, logout: async () => { keyStore.clearAll() set({ isLoading: true }) try { await authApi.logout() } catch { // ignore logout errors } finally { set({ user: null, isAuthenticated: false, isLoading: false, error: null, needsSecretKey: false, isDerivingKeys: false, isUpgrading: false, pendingUpgradeEmail: null, pendingUpgradePassword: null, }) } }, checkAuth: async () => { set({ isLoading: true }) try { const user = await authApi.me() set({ user, isAuthenticated: true, isLoading: false, error: null }) } catch { set({ user: null, isAuthenticated: false, isLoading: false, error: null }) } }, clearError: () => set({ error: null }), clearNeedsSecretKey: () => set({ needsSecretKey: false }), })) // Role helpers export function isSuperAdmin(user: UserMe | null): boolean { return user?.role === 'super_admin' } export function isTenantAdmin(user: UserMe | null): boolean { return user?.role === 'tenant_admin' || user?.role === 'super_admin' } export function isOperator(user: UserMe | null): boolean { return ( user?.role === 'operator' || user?.role === 'tenant_admin' || user?.role === 'super_admin' ) } export function canWrite(user: UserMe | null): boolean { return isOperator(user) } export function canDelete(user: UserMe | null): boolean { return isTenantAdmin(user) }