getSettings(); $file = getenv('HCR_MFA_KEY_FILE'); if ($file === false) $file = (string) ($settings->MfaKeyFile ?? ''); if ((string) $settings->Environment === 'Development' && $file !== '') { $resolved = realpath($file); $root = realpath(__DIR__ . '/..'); $normalize = static fn($path) => strtolower(str_replace('\\', '/', $path)); if ($resolved === false || !is_file($resolved) || !is_readable($resolved) || filesize($resolved) > 128 || str_starts_with($normalize($resolved), $normalize($root) . '/')) throw new RuntimeException('Development MFA key must be a readable file outside the application directory.'); $encoded = trim(file_get_contents($resolved)); } } $key = base64_decode($encoded === false ? '' : $encoded, true); if ($key === false || strlen($key) !== 32) throw new RuntimeException('Configure HCR_MFA_KEY with a base64-encoded random 32-byte key.'); return $key; } public function sealSecret(string $secret, int $user): string { $iv = random_bytes(12); $tag = ''; $cipher = openssl_encrypt($secret, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag, (string) $user); if ($cipher === false) throw new RuntimeException('Cannot protect authenticator secret.'); return base64_encode($iv . $tag . $cipher); } private function decrypt(string $cipher, int $user): string { $raw = base64_decode($cipher, true); if ($raw === false || strlen($raw) < 29) throw new RuntimeException('Invalid authenticator secret.'); $secret = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), (string) $user); if ($secret === false) throw new RuntimeException('Cannot unlock authenticator secret.');return $secret; } public function enrolled(int $user): bool { $stmt = $this->connect()->prepare('SELECT 1 FROM user_mfa WHERE user_serial=?'); $stmt->execute([$user]); return (bool) $stmt->fetchColumn(); } public function required(int $user): bool { return (new TenantAccess())->managesAny() || $this->enrolled($user); } public function verified(int $user): bool { return ($_SESSION['mfa_user'] ?? 0) === $user; } private function limited(int $user): void { $auth = new AuthService(); if (!$auth->allowAttempt('mfa-account', (string) $user, 10, 300) || !$auth->allowAttempt('mfa-ip', $_SERVER['REMOTE_ADDR'] ?? '', 40, 300)) throw new AppException('Too many authentication attempts. Try again later.', 429); } private static function counter(string $secret, string $code, int $last): ?int { if (!preg_match('/\A[0-9]{6}\z/', $code)) return null; $otp = TOTP::createFromSecret($secret, new MfaClock()); $now = intdiv(time(), 30); foreach ([$now, $now - 1, $now + 1] as $counter) if ($counter > $last && hash_equals($otp->at($counter * 30), $code)) return $counter; return null; } /** All factor mutations use the account-then-factor lock order. */ private function account(PDO $db,int $user,string $password): ?array { $stmt=$db->prepare('SELECT user_serial,user_email,user_password_hash,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE'); $stmt->execute([$user]);$account=$stmt->fetch(); if(!$account||$account['user_status']!=='active'||($_SESSION[self::USER_SERIAL]??0)!==$user ||($_SESSION['auth_version']??-1)!==(int)$account['user_auth_version'] ||!password_verify($password,$account['user_password_hash']))return null; return $account; } private function factor(PDO $db,int $user): ?array { $stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);return $stmt->fetch()?:null; } private function consume(PDO $db,int $user,array $factor,string $code): ?string { $code=trim($code);$counter=self::counter($this->decrypt($factor['secret_cipher'],$user),$code,(int)$factor['last_counter']); if($counter!==null){ $stmt=$db->prepare('UPDATE user_mfa SET last_counter=? WHERE user_serial=?');$stmt->execute([$counter,$user]);return 'totp'; } $code=strtolower($code);if(!preg_match('/\A[a-f0-9]{32}\z/',$code))return null; $stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=? AND code_hash=?');$stmt->execute([$user,hash('sha256',$code)]); return $stmt->rowCount()===1?'recovery':null; } protected function notify(array $account,string $event): void { if(!(new MfaSecurityNotifications())->send($account,$event))$_SESSION['mfa_notification_warning']=true; } public function verify(int $user,string $code,string $password): bool { $this->limited($user); $result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){ $account=$this->account($db,$user,$password);if(!$account)return null; $factor=$this->factor($db,$user);if(!$factor)return null; $kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null; Audit::required($db,$user,$kind==='recovery'?'used MFA recovery code':'verified authenticator'); return ['account'=>$account,'kind'=>$kind]; }); if(!$result)return false; if($result['kind']==='recovery')$this->notify($result['account'],'recovery_used'); return true; } private function recoveryCodes(PDO $db,int $user): array { $stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]); $stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$codes=[]; for($i=0;$i<10;$i++){$code=bin2hex(random_bytes(16));$codes[]=$code;$stmt->execute([$user,hash('sha256',$code)]);} return $codes; } public function enroll(int $user,string $secret,string $code,string $password): array { $this->limited($user);$pending=$_SESSION['mfa_pending']??[]; if(($pending['user']??0)!==$user||($pending['expires']??0)connect(),function(PDO $db)use($user,$secret,$counter,$password){ $account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403); if($this->factor($db,$user))throw new AppException('Authenticator is already enrolled.',409); $stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher,last_counter) VALUES(?,?,?)');$stmt->execute([$user,$this->sealSecret($secret,$user),$counter]); $codes=$this->recoveryCodes($db,$user); $stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]); Audit::required($db,$user,'enrolled authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1]; }); unset($_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);$_SESSION['auth_version']=$result['version']; $this->notify($result['account'],'enrolled');return $result['codes']; } /** Prove the old factor, but keep it and the remaining recovery codes active. */ public function beginReplacement(int $user,string $code,string $password): bool { $this->limited($user); $result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){ $account=$this->account($db,$user,$password);if(!$account)return null; $factor=$this->factor($db,$user);if(!$factor)return null; $kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null; if($kind==='recovery')Audit::required($db,$user,'used MFA recovery code'); Audit::required($db,$user,'requested authenticator replacement'); return ['account'=>$account,'kind'=>$kind,'factor'=>hash('sha256',$factor['secret_cipher'])]; }); if(!$result)return false; $_SESSION['mfa_replacement']=['user'=>$user,'version'=>(int)$result['account']['user_auth_version'],'factor'=>$result['factor'], 'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600]; $this->mark($user); $this->notify($result['account'],$result['kind']==='recovery'?'replacement_started_recovery':'replacement_started');return true; } private function replacement(int $user): ?array { $pending=$_SESSION['mfa_replacement']??null; if(!$pending)return null; if(($pending['user']??0)!==$user||($pending['version']??-1)!==($_SESSION['auth_version']??-2)||($pending['expires']??0)limited($user);$pending=$this->replacement($user); if(!$pending)throw new AppException('Replacement setup expired. Your existing authenticator is still active.',409); $counter=self::counter($pending['secret'],trim($code),-1);if($counter===null)throw new AppException('Enter the code from your new authenticator.'); $result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$password,$pending,$counter){ $account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403); $factor=$this->factor($db,$user); if(!$factor||!hash_equals($pending['factor'],hash('sha256',$factor['secret_cipher'])))throw new AppException('Authenticator changed. Start replacement again.',409); $stmt=$db->prepare('UPDATE user_mfa SET secret_cipher=?,last_counter=? WHERE user_serial=?');$stmt->execute([$this->sealSecret($pending['secret'],$user),$counter,$user]); $codes=$this->recoveryCodes($db,$user); $stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]); Audit::required($db,$user,'replaced authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1]; }); unset($_SESSION['mfa_replacement'],$_SESSION['mfa_pending']);$_SESSION['auth_version']=$result['version'];$this->mark($user); $this->notify($result['account'],'replaced');return $result['codes']; } private function mark(int $user): void { session_regenerate_id(true);$_SESSION['mfa_user']=$user;$_SESSION['recent_auth']=time();$_SESSION['csrf_token']=bin2hex(random_bytes(32)); } public function gate(array $user,string $action): bool { $id=(int)$user['user_serial']; if($action!=='security'&&(!$this->required($id)||$this->verified($id)))return false; if(!empty($_SESSION[self::AJAX])&&$action!=='security')throw new AppException('Complete administrator authentication from the home page.',403); $this->page($user);return true; } public function page(array $user): void { $id=(int)$user['user_serial'];$error='';$codes=[];$enrolled=$this->enrolled($id); if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){ $this->requireCsrf(); try{ $action=$_POST['factor_action']??''; if($action==='cancelReplacement'){ unset($_SESSION['mfa_replacement']);header('Location: main.php?Xaction=security',true,303);return; }elseif($action==='confirmReplacement'){ $codes=$this->completeReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??''); }elseif(!$enrolled){ $codes=$this->enroll($id,$_SESSION['mfa_pending']['secret']??'',$_POST['mfa_code']??'',$_POST['current_password']??'');$enrolled=true;$this->mark($id); }elseif($action==='replace'){ if(!$this->beginReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403); header('Location: main.php?Xaction=security',true,303);return; }else{ if(!$this->verify($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403); $this->mark($id);header('Location: main.php',true,303);return; } }catch(AppException $errorObject){http_response_code($errorObject->status);$error=$errorObject->getMessage();} } $pending=$this->replacement($id);$h=static fn($value)=>htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8'); $html='

Account security

'.$h($error).'

'.MfaSecurityNotifications::notice(); if($codes){ $html.='

Save your recovery codes

These single-use codes are shown only now. Store them securely away from your authenticator. You will need one if your device is lost.

'.implode("\n",$codes).'
I have saved my recovery codes'; }else{ if(!$enrolled||$pending){ if(!$pending){ $this->key();$initial=$_SESSION['mfa_pending']??[]; if(($initial['user']??0)!==$id||($initial['expires']??0)$id,'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600]; } $secret=$pending['secret']??$_SESSION['mfa_pending']['secret'];$qr=MfaSetupQr::dataUri($secret,$user['user_email']); if($pending)$html.='

Confirm your new authenticator

Your existing authenticator remains active until you confirm this replacement. This setup expires after ten minutes.

'; $html.='

Scan this QR code with your authenticator app, then enter its six-digit code below.

Scan to set up your authenticator

If you cannot scan it, use this setup key:

'.$h($secret).'

Account: '.$h($user['user_email']).'. Six digits, 30-second period, SHA-1.

'; }else $html.='

Enter your password and an authenticator code or a single-use recovery code.

'; $html.='
'; if($pending)$html.=''; $html.=''; if($enrolled&&!$pending)$html.='

Replacement requires your password and a current authenticator or unused recovery code. Your old factor remains active until the new one is confirmed.

'; $html.='
'; if($pending)$html.='
'; } $this->displayContent($html.'
'); } public function requireRecent(): void { if(($_SESSION['recent_auth']??0)