tenant->identifier(); if (!$tenantId) { return false; } $enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId); return $enrollment !== null && ($enrollment['verified'] ?? false) === true; } /** * Begin TOTP enrollment - generates secret, provisioning URI, and recovery codes * * @param string $userId User identifier * @param array $config Configuration options (issuer, account_name) * @return array{success: bool, error?: string, error_code?: string, enrollment?: array} */ public function beginEnrollment(string $userId, array $config = []): array { $tenantId = $this->tenant->identifier(); if (!$tenantId) { return [ 'success' => false, 'error' => 'Invalid tenant context', 'error_code' => 'internal_error', ]; } // Check if already enrolled if ($this->isEnrolled($userId)) { return [ 'success' => false, 'error' => 'TOTP is already enrolled for this user', 'error_code' => 'already_enrolled', ]; } // Generate new secret $secret = $this->generateSecret(); // Generate recovery codes $recoveryCodes = $this->generateRecoveryCodes(); // Store pending enrollment (not yet verified) $this->enrollmentStore->storePendingEnrollment($tenantId, $userId, $secret, $recoveryCodes); // Generate provisioning URI for QR code $issuer = $config['issuer'] ?? 'Ktrix'; $accountName = $config['account_name'] ?? $userId; $provisioningUri = $this->generateProvisioningUri($secret, $issuer, $accountName); // Generate QR code data URL (empty for client-side generation) $qrCodeDataUrl = $this->generateQrCodeDataUrl($provisioningUri); return [ 'success' => true, 'enrollment' => [ 'secret' => $secret, 'provisioning_uri' => $provisioningUri, 'qr_code' => $qrCodeDataUrl, 'recovery_codes' => $recoveryCodes, ], ]; } /** * Complete TOTP enrollment by verifying the initial code * * @param string $userId User identifier * @param string $code Verification code from authenticator app * @return array{success: bool, error?: string, error_code?: string} */ public function completeEnrollment(string $userId, string $code): array { $tenantId = $this->tenant->identifier(); if (!$tenantId) { return [ 'success' => false, 'error' => 'Invalid tenant context', 'error_code' => 'internal_error', ]; } // Get pending enrollment $enrollment = $this->enrollmentStore->getPendingEnrollment($tenantId, $userId); if (!$enrollment) { return [ 'success' => false, 'error' => 'No pending TOTP enrollment found', 'error_code' => 'no_pending_enrollment', ]; } // Verify the code if (!$this->verifyCode($enrollment['secret'], $code)) { return [ 'success' => false, 'error' => 'Invalid verification code', 'error_code' => 'invalid_code', ]; } // Mark enrollment as verified $this->enrollmentStore->confirmEnrollment($tenantId, $userId); return ['success' => true]; } /** * Remove TOTP enrollment for a user * * @param string $userId User identifier * @return array{success: bool, error?: string, error_code?: string} */ public function removeEnrollment(string $userId): array { $tenantId = $this->tenant->identifier(); if (!$tenantId) { return [ 'success' => false, 'error' => 'Invalid tenant context', 'error_code' => 'internal_error', ]; } if (!$this->isEnrolled($userId)) { return [ 'success' => false, 'error' => 'TOTP is not enrolled for this user', 'error_code' => 'not_enrolled', ]; } $this->enrollmentStore->removeEnrollment($tenantId, $userId); return ['success' => true]; } /** * Verify a TOTP code for an enrolled user * * @param string $userId User identifier * @param string $code Verification code * @return array{success: bool, error?: string, error_code?: string, used_recovery_code?: bool} */ public function verifyCode(string $userId, string $code): array { $tenantId = $this->tenant->identifier(); if (!$tenantId) { return [ 'success' => false, 'error' => 'Invalid tenant context', 'error_code' => 'internal_error', ]; } // Get enrollment $enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId); if (!$enrollment) { return [ 'success' => false, 'error' => 'TOTP is not enrolled for this user', 'error_code' => 'not_enrolled', ]; } // Check if it's a recovery code if ($this->verifyRecoveryCode($tenantId, $userId, $code)) { return [ 'success' => true, 'used_recovery_code' => true, ]; } // Verify TOTP code if (!$this->verifyTotpCode($enrollment['secret'], $code)) { return [ 'success' => false, 'error' => 'Invalid verification code', 'error_code' => 'invalid_code', ]; } return ['success' => true]; } /** * Get the user's enrollment secret (for provider verification) */ public function getEnrollmentSecret(string $userId): ?string { $tenantId = $this->tenant->identifier(); if (!$tenantId) { return null; } $enrollment = $this->enrollmentStore->getEnrollment($tenantId, $userId); return $enrollment['secret'] ?? null; } /** * Verify a TOTP code against a secret (for provider use) */ public function verifyTotpCode(string $secret, string $code, int $window = 1): bool { $code = preg_replace('/\s+/', '', $code); if (strlen($code) !== self::DEFAULT_DIGITS) { return false; } $timestamp = time(); $counter = (int) floor($timestamp / self::DEFAULT_PERIOD); for ($i = -$window; $i <= $window; $i++) { $expectedCode = $this->generateTotp($secret, $counter + $i); if (hash_equals($expectedCode, $code)) { return true; } } return false; } /** * Verify a recovery code */ public function verifyRecoveryCode(string $tenantId, string $userId, string $code): bool { $code = strtoupper(preg_replace('/\s+/', '', $code)); $validCodes = $this->enrollmentStore->getRecoveryCodes($tenantId, $userId); foreach ($validCodes as $index => $storedCode) { if (hash_equals($storedCode, $code)) { $this->enrollmentStore->markRecoveryCodeUsed($tenantId, $userId, $index); return true; } } return false; } // ========================================================================= // Private Helper Methods // ========================================================================= /** * Generate a cryptographically secure secret */ private function generateSecret(): string { $bytes = random_bytes(self::SECRET_LENGTH); return $this->base32Encode($bytes); } /** * Generate a provisioning URI for QR codes (otpauth:// format) */ private function generateProvisioningUri(string $secret, string $issuer, string $accountName): string { $params = http_build_query([ 'secret' => $secret, 'issuer' => $issuer, 'algorithm' => strtoupper(self::DEFAULT_ALGORITHM), 'digits' => self::DEFAULT_DIGITS, 'period' => self::DEFAULT_PERIOD, ]); $label = rawurlencode($issuer) . ':' . rawurlencode($accountName); return "otpauth://totp/{$label}?{$params}"; } /** * Generate QR code as data URL */ private function generateQrCodeDataUrl(string $uri): string { // Return empty for client-side QR generation return ''; } /** * Generate recovery codes */ private function generateRecoveryCodes(int $count = 10): array { $codes = []; for ($i = 0; $i < $count; $i++) { $part1 = strtoupper(bin2hex(random_bytes(2))); $part2 = strtoupper(bin2hex(random_bytes(2))); $codes[] = "{$part1}-{$part2}"; } return $codes; } /** * Generate TOTP code for a given counter */ private function generateTotp(string $secret, int $counter): string { $secretBytes = $this->base32Decode($secret); $counterBytes = pack('J', $counter); $hash = hash_hmac(self::DEFAULT_ALGORITHM, $counterBytes, $secretBytes, true); $offset = ord($hash[strlen($hash) - 1]) & 0x0F; $binary = (ord($hash[$offset]) & 0x7F) << 24 | ord($hash[$offset + 1]) << 16 | ord($hash[$offset + 2]) << 8 | ord($hash[$offset + 3]); $otp = $binary % (10 ** self::DEFAULT_DIGITS); return str_pad((string) $otp, self::DEFAULT_DIGITS, '0', STR_PAD_LEFT); } /** * Base32 encode (RFC 4648) */ private function base32Encode(string $data): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $encoded = ''; $bitLen = 0; $val = 0; $len = strlen($data); for ($i = 0; $i < $len; $i++) { $val = ($val << 8) | ord($data[$i]); $bitLen += 8; while ($bitLen >= 5) { $bitLen -= 5; $encoded .= $alphabet[($val >> $bitLen) & 0x1F]; } } if ($bitLen > 0) { $encoded .= $alphabet[($val << (5 - $bitLen)) & 0x1F]; } return $encoded; } /** * Base32 decode (RFC 4648) */ private function base32Decode(string $data): string { $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; $data = strtoupper(str_replace('=', '', $data)); $decoded = ''; $val = 0; $bitLen = 0; $len = strlen($data); for ($i = 0; $i < $len; $i++) { $pos = strpos($alphabet, $data[$i]); if ($pos === false) continue; $val = ($val << 5) | $pos; $bitLen += 5; if ($bitLen >= 8) { $bitLen -= 8; $decoded .= chr(($val >> $bitLen) & 0xFF); } } return $decoded; } }