diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4499037..446387c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ tests/ApiTest.php tests/DatabaseTest.php tests/FunctionTest.php + tests/HmacSessionStrategyTest.php tests/ModelTest.php tests/SignatureTest.php diff --git a/src/Core/Api.php b/src/Core/Api.php index 8991c40..f48ed30 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -20,8 +20,9 @@ class Api /** * @var \Kyte\Core\ModelObject The KyteAPIKey model object. + * Public to allow AuthStrategy implementations to populate during auth. */ - private $key = null; + public $key = null; /** * * @var \Kyte\Core\ModelObject The KyteAccount model object. @@ -52,15 +53,17 @@ class Api * The API signature. * * @var string|null + * Public to allow AuthStrategy implementations to populate during auth. */ - private $signature = null; - + public $signature = null; + /** * The UTC date. * * @var mixed|null + * Public to allow AuthStrategy implementations to populate during auth. */ - private $utcDate = null; + public $utcDate = null; /** * The HTTP request model. @@ -171,8 +174,17 @@ class Api 'CHECK_SYNTAX_ON_IMPORT' => false, 'STRICT_TYPING' => true, 'KYTE_USE_SNS' => false, + 'AUTH_STRATEGY_DISPATCHER' => 'off', ]; + /** + * Auth strategy selected for the current request (set by validateRequest + * when AUTH_STRATEGY_DISPATCHER != 'off'). Null on the legacy path. + * + * @var \Kyte\Core\Auth\AuthStrategy|null + */ + public $authStrategy = null; + /** * Model definition cache * @@ -935,16 +947,39 @@ private function validateRequest() error_log(print_r($this->data, true)); } - if (IS_PRIVATE) { - $this->signature = isset($_SERVER['HTTP_X_KYTE_SIGNATURE']) ? $_SERVER['HTTP_X_KYTE_SIGNATURE'] : null; - if (!$this->signature) { + // Shadow mode snapshots the pre-auth response so the new strategy + // can be re-run from the same starting state after legacy completes. + $shadowEntryResponse = null; + if (AUTH_STRATEGY_DISPATCHER === 'shadow') { + $shadowEntryResponse = $this->response; + } + + if (AUTH_STRATEGY_DISPATCHER === 'on') { + // New strategy-dispatcher path. Functionally equivalent to the + // legacy branch below when HmacSessionStrategy matches. + $this->authStrategy = \Kyte\Core\Auth\AuthDispatcher::buildDefault()->select(); + if (VERBOSE_LOG > 0) { + error_log('auth: strategy_selected=' . ($this->authStrategy ? $this->authStrategy->name() : 'null')); + } + if ($this->authStrategy === null) { return false; } - } + $this->authStrategy->preAuth($this); + if (!$this->account) { + return false; + } + } else { + if (IS_PRIVATE) { + $this->signature = isset($_SERVER['HTTP_X_KYTE_SIGNATURE']) ? $_SERVER['HTTP_X_KYTE_SIGNATURE'] : null; + if (!$this->signature) { + return false; + } + } - $this->parseIdentityString(isset($_SERVER['HTTP_X_KYTE_IDENTITY']) ? $_SERVER['HTTP_X_KYTE_IDENTITY'] : null); - if (!$this->account) { - return false; + $this->parseIdentityString(isset($_SERVER['HTTP_X_KYTE_IDENTITY']) ? $_SERVER['HTTP_X_KYTE_IDENTITY'] : null); + if (!$this->account) { + return false; + } } // set page size @@ -1005,11 +1040,19 @@ private function validateRequest() // default is always public. // this can be bypassed for public APIs but is highly discouraged - if (IS_PRIVATE) { + if (AUTH_STRATEGY_DISPATCHER === 'on') { + $this->authStrategy->verify($this); + } elseif (IS_PRIVATE) { // VERIFY SIGNATURE $this->verifySignature(); } + // Shadow: legacy auth is fully applied at this point. Re-run the + // new dispatcher against a reset state and log any divergence. + if (AUTH_STRATEGY_DISPATCHER === 'shadow') { + \Kyte\Core\Auth\AuthShadowHarness::runAndCompare($this, $shadowEntryResponse); + } + return true; } diff --git a/src/Core/Auth/AuthDispatcher.php b/src/Core/Auth/AuthDispatcher.php new file mode 100644 index 0000000..85dd34a --- /dev/null +++ b/src/Core/Auth/AuthDispatcher.php @@ -0,0 +1,54 @@ +strategies = $strategies; + } + + /** + * Returns the first strategy whose matches() returns true, or null. + */ + public function select(): ?AuthStrategy + { + foreach ($this->strategies as $strategy) { + if ($strategy->matches()) { + return $strategy; + } + } + return null; + } + + /** + * Convenience constructor wiring the default strategy stack for the + * current migration state. Phase 1: Hmac only. Phase 2 adds McpToken. + * Phase 3 adds JwtSession. + */ + public static function buildDefault(): self + { + return new self([ + new HmacSessionStrategy(), + ]); + } +} diff --git a/src/Core/Auth/AuthShadowHarness.php b/src/Core/Auth/AuthShadowHarness.php new file mode 100644 index 0000000..e6ac46f --- /dev/null +++ b/src/Core/Auth/AuthShadowHarness.php @@ -0,0 +1,140 @@ +response as it was BEFORE legacy + * auth ran — so the new path starts from the same initial state. + */ + public static function runAndCompare(Api $api, array $entryResponse): void + { + $legacyFingerprint = self::fingerprint($api); + + $legacyKey = $api->key; + $legacyAccount = $api->account; + $legacyUser = $api->user; + $legacyResponse = $api->response; + $legacySignature = $api->signature; + $legacyUtcDate = $api->utcDate; + + // Reset to the state Api::route() leaves us in just before validateRequest. + $api->key = new ModelObject(KyteAPIKey); + $api->account = new ModelObject(KyteAccount); + $api->user = null; + $api->signature = null; + $api->utcDate = null; + $api->response = $entryResponse; + + $strategy = null; + try { + $strategy = AuthDispatcher::buildDefault()->select(); + if ($strategy !== null) { + $strategy->preAuth($api); + $strategy->verify($api); + } + $newFingerprint = self::fingerprint($api); + + $diff = self::diff($legacyFingerprint, $newFingerprint); + if (!empty($diff)) { + self::logDiff($diff, $strategy); + } + } catch (\Throwable $e) { + self::logException($e, $strategy); + } finally { + $api->key = $legacyKey; + $api->account = $legacyAccount; + $api->user = $legacyUser; + $api->response = $legacyResponse; + $api->signature = $legacySignature; + $api->utcDate = $legacyUtcDate; + } + } + + private static function fingerprint(Api $api): array + { + return [ + 'key_id' => ($api->key && isset($api->key->id)) ? $api->key->id : null, + 'account_id' => ($api->account && isset($api->account->id)) ? $api->account->id : null, + 'user_id' => ($api->user && isset($api->user->id)) ? $api->user->id : null, + 'session' => $api->response['session'] ?? null, + 'token' => $api->response['token'] ?? null, + 'uid' => $api->response['uid'] ?? null, + 'name' => $api->response['name'] ?? null, + 'email' => $api->response['email'] ?? null, + 'signature' => $api->signature, + 'utc_epoch' => $api->utcDate ? $api->utcDate->format('U') : null, + ]; + } + + private static function diff(array $legacy, array $new): array + { + $out = []; + foreach ($legacy as $k => $v) { + $n = $new[$k] ?? null; + if ($v !== $n) { + $out[$k] = ['legacy' => $v, 'new' => $n]; + } + } + return $out; + } + + private static function logDiff(array $diff, ?AuthStrategy $strategy): void + { + try { + \Kyte\Core\ActivityLogger::getInstance()->log( + 'AUTH_SHADOW_DIFF', + 'AuthShadowHarness', + 'strategy', + $strategy ? $strategy->name() : 'null', + null, + 0, + 'shadow', + json_encode($diff) + ); + } catch (\Throwable $e) { + error_log('AuthShadowHarness: failed to log diff - ' . $e->getMessage()); + } + } + + private static function logException(\Throwable $e, ?AuthStrategy $strategy): void + { + try { + \Kyte\Core\ActivityLogger::getInstance()->log( + 'AUTH_SHADOW_EXCEPTION', + 'AuthShadowHarness', + 'strategy', + $strategy ? $strategy->name() : 'null', + null, + 0, + 'shadow', + get_class($e) . ': ' . $e->getMessage() + ); + } catch (\Throwable $inner) { + error_log('AuthShadowHarness: failed to log exception - ' . $inner->getMessage()); + } + } +} diff --git a/src/Core/Auth/AuthStrategy.php b/src/Core/Auth/AuthStrategy.php new file mode 100644 index 0000000..6684c6c --- /dev/null +++ b/src/Core/Auth/AuthStrategy.php @@ -0,0 +1,46 @@ +key, $api->account, $api->user, $api->session, + * and the identity-related $api->response slots. + * 2. (caller performs URL parsing + response hydration) + * 3. verify() — final signature/token check. May be a no-op. + * + * See docs/design/kyte-mcp-and-auth-migration.md section 3.5. + */ +interface AuthStrategy +{ + /** + * Does this strategy claim responsibility for the current request? + * Inspects headers and per-install config. Pure; no side effects. + */ + public function matches(): bool; + + /** + * Phase 1: identity parse + user/account/session lookup. + * Populates Api state. Throws SessionException / Exception on failure. + */ + public function preAuth(Api $api): void; + + /** + * Phase 2: final signature or token verification. + * Called after URL parsing + response hydration. May be a no-op + * (e.g. identity-only mode). Throws SessionException on failure. + */ + public function verify(Api $api): void; + + /** + * Short label for logging and telemetry (e.g. "hmac_session"). + */ + public function name(): string; +} diff --git a/src/Core/Auth/HmacSessionStrategy.php b/src/Core/Auth/HmacSessionStrategy.php new file mode 100644 index 0000000..3d7ce44 --- /dev/null +++ b/src/Core/Auth/HmacSessionStrategy.php @@ -0,0 +1,153 @@ +signature = $_SERVER['HTTP_X_KYTE_SIGNATURE'] ?? null; + } + + $raw = $_SERVER['HTTP_X_KYTE_IDENTITY'] ?? null; + $identity = explode('%', base64_decode(urldecode($raw))); + + if (count($identity) != 4) { + throw new SessionException("[ERROR] Invalid identity string: {$api->request}."); + } + + $api->utcDate = new \DateTime($identity[2], new \DateTimeZone('UTC')); + + if (time() > $api->utcDate->format('U') + SIGNATURE_TIMEOUT) { + throw new SessionException("API request has expired."); + } + + if (!isset($identity[0])) { + throw new \Exception("API key is required."); + } + + if (!$api->key->retrieve('public_key', $identity[0])) { + throw new \Exception("API key not found."); + } + + if (!$api->account->retrieve('number', $identity[3])) { + throw new \Exception("[ERROR] Unable to find account for {$identity[3]}."); + } + + $identity[1] = $identity[1] == 'undefined' ? "0" : $identity[1]; + $api->response['session'] = $identity[1]; + + if ($identity[1] != "0") { + $session_ret = $api->session->validate($identity[1]); + $api->response['session'] = $session_ret['session']->sessionToken; + $api->response['token'] = $session_ret['session']->txToken; + + $api->user = $session_ret['user']; + $api->response['uid'] = $api->user->id; + $api->response['name'] = $api->user->name; + $api->response['email'] = $api->user->email; + + if ($api->appId === null && $api->user->kyte_account != $api->account->id) { + if (!$api->account->retrieve('id', $api->user->kyte_account)) { + throw new \Exception("Unable to find account associated with the user"); + } + } + + $finalLanguage = APP_LANG; + if ($api->account && isset($api->account->default_language) && !empty($api->account->default_language)) { + $finalLanguage = $api->account->default_language; + } + if ($api->app && isset($api->app->language) && !empty($api->app->language)) { + $finalLanguage = $api->app->language; + } + if ($api->user && isset($api->user->language) && !empty($api->user->language)) { + $finalLanguage = $api->user->language; + } + \Kyte\Util\I18n::setLanguage($finalLanguage); + } + } + + /** + * Mirrors Api::verifySignature. No-op in IS_PRIVATE=false mode. + */ + public function verify(Api $api): void + { + if (!IS_PRIVATE) { + return; + } + + $token = $api->response['token']; + $secretKey = $api->key->secret_key; + $identifier = $api->key->identifier; + + $hash1 = hash_hmac('SHA256', $token, $secretKey, true); + $hash1str = hash_hmac('SHA256', $token, $secretKey, false); + + if (VERBOSE_LOG > 0) { + error_log("hash1 " . hash_hmac('SHA256', $token, $secretKey)); + } + + $hash2 = hash_hmac('SHA256', $identifier, $hash1, true); + $hash2str = hash_hmac('SHA256', $identifier, $hash1, false); + + if (VERBOSE_LOG > 0) { + error_log("hash2 " . hash_hmac('SHA256', $identifier, $hash1)); + } + + $calculated = hash_hmac('SHA256', $api->utcDate->format('U'), $hash2); + + if (VERBOSE_LOG > 0) { + error_log("hash3 $calculated"); + error_log("epoch " . $api->utcDate->format('U')); + } + + if ($calculated != $api->signature) { + throw new SessionException( + "Calculated signature does not match provided signature.\n" . + "Calculated: $hash1str $hash2str $calculated\n" . + "Provided: " . $api->signature + ); + } + } +} diff --git a/tests/HmacSessionStrategyTest.php b/tests/HmacSessionStrategyTest.php new file mode 100644 index 0000000..8557214 --- /dev/null +++ b/tests/HmacSessionStrategyTest.php @@ -0,0 +1,206 @@ +api = new \Kyte\Core\Api(); + $this->strategy = new \Kyte\Core\Auth\HmacSessionStrategy(); + + \Kyte\Core\DBI::createTable(KyteAPIKey); + \Kyte\Core\DBI::createTable(KyteAccount); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAPIKey` WHERE public_key = '" . self::FIXED_PUBLIC_KEY . "'"); + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::FIXED_ACCOUNT . "'"); + + $account = new \Kyte\Core\ModelObject(KyteAccount); + $account->create([ + 'name' => 'HmacStrategy Test Account', + 'number' => self::FIXED_ACCOUNT, + ]); + + $apiKey = new \Kyte\Core\ModelObject(KyteAPIKey); + $apiKey->create([ + 'identifier' => self::FIXED_IDENTIFIER, + 'public_key' => self::FIXED_PUBLIC_KEY, + 'secret_key' => self::FIXED_SECRET, + 'epoch' => 0, + 'kyte_account' => $account->id, + ]); + + // Match the preconditions Api::route() establishes before validateRequest runs. + $this->api->key = new \Kyte\Core\ModelObject(KyteAPIKey); + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->response = ['session' => '0', 'token' => '0', 'uid' => '0']; + + $_SERVER = []; + } + + private function identityHeader(string $publicKey, string $session, int $epoch, string $account): string + { + $date = gmdate('D, d M Y H:i:s', $epoch) . ' GMT'; + return urlencode(base64_encode($publicKey . '%' . $session . '%' . $date . '%' . $account)); + } + + private function signatureFor(string $token, string $secret, string $identifier, int $epoch): string + { + $h1 = hash_hmac('SHA256', $token, $secret, true); + $h2 = hash_hmac('SHA256', $identifier, $h1, true); + return hash_hmac('SHA256', (string)$epoch, $h2); + } + + public function testMatchesReturnsTrueWithSignatureAndIdentityInPrivateMode(): void + { + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'anything'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = 'anything'; + $this->assertTrue($this->strategy->matches()); + } + + public function testMatchesReturnsFalseWhenPrivateAndNoSignature(): void + { + $_SERVER['HTTP_X_KYTE_IDENTITY'] = 'anything'; + // No HTTP_X_KYTE_SIGNATURE — this is the /sign helper path. + $this->assertFalse($this->strategy->matches()); + } + + public function testPreAuthAcceptsValidIdentityAndPopulatesState(): void + { + $now = time(); + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $now, self::FIXED_ACCOUNT); + + $this->strategy->preAuth($this->api); + + $this->assertSame(self::FIXED_PUBLIC_KEY, $this->api->key->public_key); + $this->assertSame(self::FIXED_ACCOUNT, $this->api->account->number); + $this->assertSame('0', $this->api->response['session']); + $this->assertNull($this->api->user, 'Anonymous session should not populate user'); + } + + public function testPreAuthRejectsExpiredTimestamp(): void + { + $stale = time() - 7200; + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $stale, self::FIXED_ACCOUNT); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('API request has expired'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthAcceptsTimestampAtBoundary(): void + { + $boundary = time() - SIGNATURE_TIMEOUT; + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $boundary, self::FIXED_ACCOUNT); + + $this->strategy->preAuth($this->api); + $this->addToAssertionCount(1); + } + + public function testPreAuthRejectsTimestampOneSecondPastBoundary(): void + { + $stale = time() - SIGNATURE_TIMEOUT - 1; + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $stale, self::FIXED_ACCOUNT); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('API request has expired'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsMalformedIdentity(): void + { + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = urlencode(base64_encode('only%three%parts')); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->expectExceptionMessage('Invalid identity string'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsUnknownApiKey(): void + { + $now = time(); + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader('nonexistent-key', '0', $now, self::FIXED_ACCOUNT); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('API key not found'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthRejectsUnknownAccount(): void + { + $now = time(); + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $now, 'nonexistent-account'); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unable to find account'); + $this->strategy->preAuth($this->api); + } + + public function testPreAuthTreatsUndefinedSessionAsZero(): void + { + $now = time(); + $date = gmdate('D, d M Y H:i:s', $now) . ' GMT'; + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = 'placeholder'; + $_SERVER['HTTP_X_KYTE_IDENTITY'] = urlencode(base64_encode( + self::FIXED_PUBLIC_KEY . '%undefined%' . $date . '%' . self::FIXED_ACCOUNT + )); + + $this->strategy->preAuth($this->api); + $this->assertSame('0', $this->api->response['session']); + } + + public function testVerifyAcceptsCorrectSignature(): void + { + $now = time(); + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $now, self::FIXED_ACCOUNT); + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = $this->signatureFor('0', self::FIXED_SECRET, self::FIXED_IDENTIFIER, $now); + + $this->strategy->preAuth($this->api); + $this->strategy->verify($this->api); + $this->addToAssertionCount(1); + } + + public function testVerifyRejectsWrongSignature(): void + { + $now = time(); + $_SERVER['HTTP_X_KYTE_IDENTITY'] = $this->identityHeader(self::FIXED_PUBLIC_KEY, '0', $now, self::FIXED_ACCOUNT); + $_SERVER['HTTP_X_KYTE_SIGNATURE'] = str_repeat('0', 64); + + $this->strategy->preAuth($this->api); + + $this->expectException(\Kyte\Exception\SessionException::class); + $this->strategy->verify($this->api); + } +}