fix(webauthn): hash-indexed credential ids + reset keys on 2FA disable

- credential_id can exceed the unique-index key length; store it in TEXT and
  enforce uniqueness on an auto-derived sha256 hash column (lookups use the hash).
- Disabling 2FA now also deletes the user's security keys + backup codes, so
  re-enrolling starts clean and old factors never silently revive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:40:24 +02:00
parent 98a39e1257
commit 139c376056
4 changed files with 24 additions and 3 deletions

View File

@ -77,10 +77,16 @@ class Index extends Component
#[On('twoFactorDisabled')]
public function disableTwoFactor(): void
{
// Reset the whole second factor: clearing 2FA must also drop the backup codes
// and security keys, so re-enrolling later starts clean (old keys/codes never
// silently become valid again).
Auth::user()->forceFill([
'two_factor_secret' => null,
'two_factor_confirmed_at' => null,
'two_factor_recovery_codes' => null,
])->save();
Auth::user()->webauthnCredentials()->delete();
}
private function audit(string $action, string $target): void

View File

@ -19,4 +19,13 @@ class WebauthnCredential extends Model
{
return $this->belongsTo(User::class);
}
protected static function booted(): void
{
// The unique index is on a fixed-length hash, since credential_id can exceed the
// index key-length limit. Derive it automatically from credential_id.
static::saving(function (self $cred): void {
$cred->credential_id_hash = hash('sha256', (string) $cred->credential_id);
});
}
}

View File

@ -132,7 +132,9 @@ class WebauthnService
return false;
}
$model = $user->webauthnCredentials()->where('credential_id', $this->toB64u($credential->rawId))->first();
$model = $user->webauthnCredentials()
->where('credential_id_hash', hash('sha256', $this->toB64u($credential->rawId)))
->first();
if (! $model) {
return false;
}

View File

@ -12,7 +12,11 @@ return new class extends Migration
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('credential_id', 512); // base64url of the raw credential id (indexed)
// Credential IDs can be up to 1023 bytes (base64url ~1364 chars) — too long for
// a unique index, so store the full value in TEXT and enforce uniqueness on a
// fixed-length sha256 hash of it.
$table->text('credential_id');
$table->char('credential_id_hash', 64);
$table->text('public_key'); // serialized PublicKeyCredentialSource (JSON)
$table->string('aaguid')->nullable();
$table->json('transports')->nullable();
@ -20,7 +24,7 @@ return new class extends Migration
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique');
$table->unique(['user_id', 'credential_id_hash'], 'webauthn_user_credential_unique');
});
}