Compare commits

..

No commits in common. "feat/fix-token-timing-attack" and "dev" have entirely different histories.

3 changed files with 23 additions and 85 deletions

View file

@ -125,14 +125,10 @@ class HashInvitationTokens extends Command
foreach ($toMigrate as $record) { foreach ($toMigrate as $record) {
try { try {
$hashedToken = Hash::make($record->token); $hashedToken = Hash::make($record->token);
$tokenHash = hash('sha256', $record->token);
DB::table('workspace_invitations') DB::table('workspace_invitations')
->where('id', $record->id) ->where('id', $record->id)
->update([ ->update(['token' => $hashedToken]);
'token' => $hashedToken,
'token_hash' => $tokenHash,
]);
$migrated++; $migrated++;
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -1,38 +0,0 @@
<?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');
});
}
};

View file

@ -26,7 +26,6 @@ class WorkspaceInvitation extends Model
'workspace_id', 'workspace_id',
'email', 'email',
'token', 'token',
'token_hash',
'role', 'role',
'invited_by', 'invited_by',
'expires_at', 'expires_at',
@ -41,9 +40,7 @@ class WorkspaceInvitation extends Model
/** /**
* The "booted" method of the model. * The "booted" method of the model.
* *
* Automatically hashes tokens when creating or updating invitations. * Automatically hashes tokens when creating 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 protected static function booted(): void
{ {
@ -51,15 +48,6 @@ class WorkspaceInvitation extends Model
// Only hash if the token looks like a plaintext token (not already hashed) // Only hash if the token looks like a plaintext token (not already hashed)
// Bcrypt hashes start with $2y$ and are 60 chars // Bcrypt hashes start with $2y$ and are 60 chars
if ($invitation->token && ! str_starts_with($invitation->token, '$2y$')) { 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); $invitation->token = Hash::make($invitation->token);
} }
}); });
@ -146,52 +134,44 @@ class WorkspaceInvitation extends Model
/** /**
* Find invitation by token. * Find invitation by token.
* *
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL, * Since tokens are hashed, we must check each pending/valid invitation
* then verifies the plaintext against the bcrypt hash. This eliminates * against the provided plaintext token using Hash::check().
* the previous O(n) sequential scan and timing attack surface.
*/ */
public static function findByToken(string $token): ?self public static function findByToken(string $token): ?self
{ {
$tokenHash = hash('sha256', $token); // Get all invitations and check the hash
// We limit to recent invitations to improve performance
$invitations = static::orderByDesc('created_at')
->limit(1000)
->get();
$invitation = static::where('token_hash', $tokenHash)->first(); foreach ($invitations as $invitation) {
if (Hash::check($token, $invitation->token)) {
if (! $invitation) { return $invitation;
return null; }
} }
// Verify the plaintext against the bcrypt hash as a second factor return null;
if (! Hash::check($token, $invitation->token)) {
return null;
}
return $invitation;
} }
/** /**
* Find pending invitation by token. * Find pending invitation by token.
* *
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL, * Since tokens are hashed, we must check each pending invitation
* then verifies the plaintext against the bcrypt hash. * against the provided plaintext token using Hash::check().
*/ */
public static function findPendingByToken(string $token): ?self public static function findPendingByToken(string $token): ?self
{ {
$tokenHash = hash('sha256', $token); // Get pending invitations and check the hash
$invitations = static::pending()->get();
$invitation = static::pending() foreach ($invitations as $invitation) {
->where('token_hash', $tokenHash) if (Hash::check($token, $invitation->token)) {
->first(); return $invitation;
}
if (! $invitation) {
return null;
} }
// Verify the plaintext against the bcrypt hash as a second factor return null;
if (! Hash::check($token, $invitation->token)) {
return null;
}
return $invitation;
} }
/** /**