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);
+ }
+}