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; } /**