From dede8036323f9d078dbf6ddc17f3ecdd545eff8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 13:06:01 +0000 Subject: [PATCH] security: fix O(n) timing attack in findByToken (#9) Add a SHA-256 token_hash lookup column to workspace_invitations so that findByToken and findPendingByToken can locate the candidate row with a single indexed SQL query instead of loading up to 1000 rows and running bcrypt against each one sequentially. The bcrypt hash in the token column is still verified after the O(1) lookup, preserving the existing security guarantee while eliminating both the timing side-channel and the performance bottleneck. Changes: - Migration to add nullable indexed token_hash column - Model booted() creating/updating events compute SHA-256 alongside bcrypt - findByToken/findPendingByToken rewritten to WHERE token_hash then Hash::check - HashInvitationTokens command updated to populate token_hash for existing rows Co-Authored-By: Claude Opus 4.6 (1M context) --- Console/Commands/HashInvitationTokens.php | 6 +- ...dd_token_hash_to_workspace_invitations.php | 38 +++++++++++ Models/WorkspaceInvitation.php | 64 ++++++++++++------- 3 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 Migrations/2026_03_24_100000_add_token_hash_to_workspace_invitations.php diff --git a/Console/Commands/HashInvitationTokens.php b/Console/Commands/HashInvitationTokens.php index 5995641..4c911ce 100644 --- a/Console/Commands/HashInvitationTokens.php +++ b/Console/Commands/HashInvitationTokens.php @@ -125,10 +125,14 @@ class HashInvitationTokens extends Command foreach ($toMigrate as $record) { try { $hashedToken = Hash::make($record->token); + $tokenHash = hash('sha256', $record->token); DB::table('workspace_invitations') ->where('id', $record->id) - ->update(['token' => $hashedToken]); + ->update([ + 'token' => $hashedToken, + 'token_hash' => $tokenHash, + ]); $migrated++; } catch (\Throwable $e) { diff --git a/Migrations/2026_03_24_100000_add_token_hash_to_workspace_invitations.php b/Migrations/2026_03_24_100000_add_token_hash_to_workspace_invitations.php new file mode 100644 index 0000000..d4c56d1 --- /dev/null +++ b/Migrations/2026_03_24_100000_add_token_hash_to_workspace_invitations.php @@ -0,0 +1,38 @@ +string('token_hash', 64)->nullable()->after('token'); + $table->index('token_hash'); + }); + } + + public function down(): void + { + Schema::table('workspace_invitations', function (Blueprint $table) { + $table->dropIndex(['token_hash']); + $table->dropColumn('token_hash'); + }); + } +}; diff --git a/Models/WorkspaceInvitation.php b/Models/WorkspaceInvitation.php index a832d10..78db770 100644 --- a/Models/WorkspaceInvitation.php +++ b/Models/WorkspaceInvitation.php @@ -26,6 +26,7 @@ class WorkspaceInvitation extends Model 'workspace_id', 'email', 'token', + 'token_hash', 'role', 'invited_by', 'expires_at', @@ -40,7 +41,9 @@ class WorkspaceInvitation extends Model /** * The "booted" method of the model. * - * Automatically hashes tokens when creating invitations. + * Automatically hashes tokens when creating or updating invitations. + * Stores both a bcrypt hash (for verification) and a SHA-256 hash + * (for O(1) lookup) to avoid timing attacks and O(n) scans. */ protected static function booted(): void { @@ -48,6 +51,15 @@ class WorkspaceInvitation extends Model // Only hash if the token looks like a plaintext token (not already hashed) // Bcrypt hashes start with $2y$ and are 60 chars if ($invitation->token && ! str_starts_with($invitation->token, '$2y$')) { + $invitation->token_hash = hash('sha256', $invitation->token); + $invitation->token = Hash::make($invitation->token); + } + }); + + static::updating(function (WorkspaceInvitation $invitation) { + // If the token is being changed and is plaintext, rehash both columns + if ($invitation->isDirty('token') && $invitation->token && ! str_starts_with($invitation->token, '$2y$')) { + $invitation->token_hash = hash('sha256', $invitation->token); $invitation->token = Hash::make($invitation->token); } }); @@ -134,44 +146,52 @@ class WorkspaceInvitation extends Model /** * Find invitation by token. * - * Since tokens are hashed, we must check each pending/valid invitation - * against the provided plaintext token using Hash::check(). + * Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL, + * then verifies the plaintext against the bcrypt hash. This eliminates + * the previous O(n) sequential scan and timing attack surface. */ public static function findByToken(string $token): ?self { - // Get all invitations and check the hash - // We limit to recent invitations to improve performance - $invitations = static::orderByDesc('created_at') - ->limit(1000) - ->get(); + $tokenHash = hash('sha256', $token); - foreach ($invitations as $invitation) { - if (Hash::check($token, $invitation->token)) { - return $invitation; - } + $invitation = static::where('token_hash', $tokenHash)->first(); + + if (! $invitation) { + return null; } - return null; + // Verify the plaintext against the bcrypt hash as a second factor + if (! Hash::check($token, $invitation->token)) { + return null; + } + + return $invitation; } /** * Find pending invitation by token. * - * Since tokens are hashed, we must check each pending invitation - * against the provided plaintext token using Hash::check(). + * Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL, + * then verifies the plaintext against the bcrypt hash. */ public static function findPendingByToken(string $token): ?self { - // Get pending invitations and check the hash - $invitations = static::pending()->get(); + $tokenHash = hash('sha256', $token); - foreach ($invitations as $invitation) { - if (Hash::check($token, $invitation->token)) { - return $invitation; - } + $invitation = static::pending() + ->where('token_hash', $tokenHash) + ->first(); + + if (! $invitation) { + return null; } - return null; + // Verify the plaintext against the bcrypt hash as a second factor + if (! Hash::check($token, $invitation->token)) { + return null; + } + + return $invitation; } /** -- 2.45.3