From d1a316858b20529396eacf8e32f67176de0bf17b Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 24 Apr 2026 04:02:03 -0500 Subject: [PATCH 1/4] Phase 1 commit 1: introduce AuthStrategy, HmacSessionStrategy, AuthDispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the strategy-pattern scaffolding described in design doc section 3.5, as dead code (not wired into validateRequest yet — that lands in commit 2). - src/Core/Auth/AuthStrategy.php: two-phase interface (matches, preAuth, verify, name). Two phases preserve current validateRequest ordering where parseIdentity runs before URL parsing and verifySignature runs after response hydration. - src/Core/Auth/HmacSessionStrategy.php: wraps the existing HMAC-SHA256 flow (Api::parseIdentityString + Api::verifySignature) with identical logic. Handles both IS_PRIVATE=true (sig required) and IS_PRIVATE=false (identity-only) flavors. matches() returns false when IS_PRIVATE and no signature header, deferring to the /sign helper path. - src/Core/Auth/AuthDispatcher.php: picks the first matching strategy. buildDefault() returns Phase-1 stack (Hmac only). Returning null means "no strategy claimed this request" — caller (commit 2) will interpret as generateSignature fallthrough. - src/Core/Api.php: $key, $signature, $utcDate bumped from private to public so strategies can populate. No behavior change; characterization tests still use reflection and remain green. 18/18 Phase 0 unit tests still passing. Smoke-tested dispatcher selection via CLI: null when no auth headers (IS_PRIVATE mode), hmac_session when both present. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Api.php | 11 +- src/Core/Auth/AuthDispatcher.php | 54 +++++++++ src/Core/Auth/AuthStrategy.php | 46 ++++++++ src/Core/Auth/HmacSessionStrategy.php | 153 ++++++++++++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 src/Core/Auth/AuthDispatcher.php create mode 100644 src/Core/Auth/AuthStrategy.php create mode 100644 src/Core/Auth/HmacSessionStrategy.php diff --git a/src/Core/Api.php b/src/Core/Api.php index 8991c40..a33dabf 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. 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/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 + ); + } + } +} From aa36b3c85cbb4f8662e2c97878f5c71fecc66b09 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 24 Apr 2026 04:08:41 -0500 Subject: [PATCH 2/4] Phase 1 commit 2: wire AuthDispatcher into validateRequest behind flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-install constant AUTH_STRATEGY_DISPATCHER (default 'off'). When 'off', the legacy inline auth path runs — bit-for-bit identical behavior to v4.2.0, and the Phase 0 characterization tests remain the contract. When 'on', validateRequest selects an AuthStrategy via AuthDispatcher::buildDefault() and calls its preAuth() / verify() at the same two points where the legacy code called parseIdentityString / verifySignature, preserving response-body ordering on failure. Minor field additions on Api: - $authStrategy (public, null on legacy path): the selected strategy. - AUTH_STRATEGY_DISPATCHER entry in $defaultEnvironmentConstants. Flag mechanism (per-install constant) deviates from design doc O2's original AppConfig plan. Reason: AppConfig is keyed by kyte_account, which the auth flow itself is responsible for resolving — chicken-and-egg. Per-install constants match the pattern used for IS_PRIVATE, SESSION_TIMEOUT, SIGNATURE_TIMEOUT, etc. Design doc O2 updated to reflect the revision. 18/18 Phase 0 unit tests green with flag=off. Integration-smoke with flag=on (CLI script using the same DB fixtures as SignatureTest) exercises matches → preAuth → verify and confirms: account + key loaded, signature verified, anonymous session state set correctly in $api->response. Commit 3 (shadow-mode harness) is the next step before any flag=on rollout on real traffic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Api.php | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/Core/Api.php b/src/Core/Api.php index a33dabf..cf18cb6 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -174,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 * @@ -938,16 +947,29 @@ 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) { + 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 ($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 @@ -1008,7 +1030,9 @@ 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(); } From 992d689f33c1ccfe5b7dc3f06c88e7a355a9ac5a Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 24 Apr 2026 04:29:25 -0500 Subject: [PATCH 3/4] Phase 1 commit 3: live shadow-mode harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the AUTH_STRATEGY_DISPATCHER='shadow' mode required by the Phase 1 exit criteria. When enabled, validateRequest: 1. Snapshots $api->response before any auth work. 2. Runs the legacy inline auth path normally — this is what serves the client response. Shadow never affects serving. 3. After legacy's verifySignature completes, invokes AuthShadowHarness::runAndCompare, which resets Api state to pre-auth, runs AuthDispatcher->select()->preAuth()->verify(), compares the new strategy's fingerprint against the legacy fingerprint, logs any divergence via ActivityLogger (action types AUTH_SHADOW_DIFF and AUTH_SHADOW_EXCEPTION), and restores legacy state in a finally block. Fingerprint compared: key_id, account_id, user_id, response[session], response[token], response[uid], response[name], response[email], signature, utc_epoch. Known side effects during shadow (documented in the harness): - SessionManager::validate() runs twice per request. Each call may update a session's last-activity timestamp. Acceptable on dev for a 2-4 week soak; not for production customers. - One extra DB read for account + api key per request. Shadow mode is intended for the Phase 1 cutover soak only — it verifies HmacSessionStrategy produces identical auth results to the legacy inline code before we flip to flag=on. After the soak, shadow is never used on a given install again. Smoke-tested via CLI with both the matching-state case (no diff logged, state cleanly restored) and a forced-mismatch case (diff detected, log attempt made, state still cleanly restored). 18/18 Phase 0 unit tests still green (flag defaults to 'off', shadow code is dormant). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Core/Api.php | 13 +++ src/Core/Auth/AuthShadowHarness.php | 140 ++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/Core/Auth/AuthShadowHarness.php diff --git a/src/Core/Api.php b/src/Core/Api.php index cf18cb6..49b45d7 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -947,6 +947,13 @@ private function validateRequest() error_log(print_r($this->data, true)); } + // 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. @@ -1037,6 +1044,12 @@ private function validateRequest() $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/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()); + } + } +} From 296d01e3246b40450921ced8b829703e13e54d81 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 24 Apr 2026 04:31:41 -0500 Subject: [PATCH 4/4] Phase 1 commit 4: HmacSessionStrategyTest + strategy-selection log - tests/HmacSessionStrategyTest.php: 12 parallel characterization tests exercising HmacSessionStrategy::matches / preAuth / verify directly. Mirrors SignatureTest's coverage (same timestamp-boundary, unknown-key, unknown-account, undefined-session, malformed-identity cases) but goes through the new strategy surface instead of Api reflection. Together with SignatureTest this forms the Phase 1 equivalence contract: the strategy must behave identically to the legacy inline code in every branch tested. - phpunit.xml.dist: registers the new test file in the unit suite. - src/Core/Api.php: small VERBOSE_LOG-gated error_log of the selected strategy name when flag=on. Full per-request ActivityLogger entry was considered but deferred to Phase 2: with only one strategy today, an AUTH_STRATEGY_SELECTED row per request would be constant noise. When Phase 2 adds McpTokenStrategy and the selection becomes informative, this stub can graduate to a structured activity-log entry. 30/30 unit tests green (18 original + 12 new), 103 assertions, zero warnings, zero deprecations. This completes Phase 1 on branch feature/phase-1-strategy-dispatcher. Next steps before Phase 2: - Push branch, open PR, confirm CI green. - Dev-server soak with flag=shadow for 2-4 weeks; verify zero AUTH_SHADOW_DIFF log entries before flipping flag=on. - Tag v4.3.0 at Phase 1 exit. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpunit.xml.dist | 1 + src/Core/Api.php | 3 + tests/HmacSessionStrategyTest.php | 206 ++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 tests/HmacSessionStrategyTest.php 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 49b45d7..f48ed30 100644 --- a/src/Core/Api.php +++ b/src/Core/Api.php @@ -958,6 +958,9 @@ private function validateRequest() // 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; } 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); + } +}