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) <noreply@anthropic.com>
This commit is contained in:
parent
c51e4310b1
commit
dede803632
3 changed files with 85 additions and 23 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add a SHA-256 lookup hash column to workspace_invitations.
|
||||
*
|
||||
* This enables O(1) token lookup via SQL instead of the previous O(n)
|
||||
* sequential bcrypt check, eliminating a timing attack surface and
|
||||
* improving performance.
|
||||
*
|
||||
* The token_hash column stores hash('sha256', $plaintext) and is used
|
||||
* for fast candidate lookup. The existing bcrypt token column is still
|
||||
* used for final verification via Hash::check().
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workspace_invitations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue