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) {
try {
$hashedToken = Hash::make($record->token);
$tokenHash = hash('sha256', $record->token);
DB::table('workspace_invitations')
->where('id', $record->id)
->update([
'token' => $hashedToken,
'token_hash' => $tokenHash,
]);
->update(['token' => $hashedToken]);
$migrated++;
} 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',
'email',
'token',
'token_hash',
'role',
'invited_by',
'expires_at',
@ -41,9 +40,7 @@ class WorkspaceInvitation extends Model
/**
* The "booted" method of the model.
*
* 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.
* Automatically hashes tokens when creating invitations.
*/
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)
// 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);
}
});
@ -146,52 +134,44 @@ class WorkspaceInvitation extends Model
/**
* Find invitation by token.
*
* 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.
* Since tokens are hashed, we must check each pending/valid invitation
* against the provided plaintext token using Hash::check().
*/
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();
if (! $invitation) {
return null;
foreach ($invitations as $invitation) {
if (Hash::check($token, $invitation->token)) {
return $invitation;
}
}
// Verify the plaintext against the bcrypt hash as a second factor
if (! Hash::check($token, $invitation->token)) {
return null;
}
return $invitation;
return null;
}
/**
* Find pending invitation by token.
*
* Uses the SHA-256 token_hash column for O(1) candidate lookup via SQL,
* then verifies the plaintext against the bcrypt hash.
* Since tokens are hashed, we must check each pending invitation
* against the provided plaintext token using Hash::check().
*/
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()
->where('token_hash', $tokenHash)
->first();
if (! $invitation) {
return null;
foreach ($invitations as $invitation) {
if (Hash::check($token, $invitation->token)) {
return $invitation;
}
}
// Verify the plaintext against the bcrypt hash as a second factor
if (! Hash::check($token, $invitation->token)) {
return null;
}
return $invitation;
return null;
}
/**