Files
the-other-dude/frontend/src/lib/auth.ts
Cog 57e754bb27 fix: implement vault key decryption on login + fix token refresh via cookie
Three bugs fixed:

1. Phase 30 (auth.ts): After SRP login the encrypted_key_set was returned
   from the server but the vault key and RSA private key were never unwrapped
   with the AUK. keyStore.getVaultKey() was always null, causing Tier 1
   config-backup diffs to crash with a TypeError.
   Fix: unwrap vault key and private key using crypto.subtle.unwrapKey after
   successful SRP verification. Non-fatal: warns to console if decryption
   fails so login always succeeds.

2. Token refresh (auth.py): The /refresh endpoint required refresh_token in
   the request body, but the frontend never stored or sent it. After the 15-
   minute access token TTL, all authenticated API calls would fail silently
   because the interceptor sent an empty body and received 422 (not 401),
   so the retry loop never fired.
   Fix: login/srpVerify now set an httpOnly refresh_token cookie scoped to
   /api/auth/refresh. The refresh endpoint now accepts the token from either
   cookie (preferred) or body (legacy). Logout clears both cookies.
   RefreshRequest.refresh_token is now Optional to allow empty-body calls.

3. Silent token rotation: the /refresh endpoint now also rotates the refresh
   token cookie on each use (issues a fresh token), reducing the window for
   stolen refresh token replay.
2026-03-12 14:05:40 -05:00

302 lines
10 KiB
TypeScript

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<void>
srpLogin: (email: string, password: string, secretKeyInput?: string) => Promise<void>
logout: () => Promise<void>
checkAuth: () => Promise<void>
clearError: () => void
clearNeedsSecretKey: () => void
completeUpgrade: () => Promise<void>
cancelUpgrade: () => void
}
export const useAuth = create<AuthState>((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)
}