diff --git a/app/Console/Commands/ResetAdmin.php b/app/Console/Commands/ResetAdmin.php index b00b3c6..d5db394 100644 --- a/app/Console/Commands/ResetAdmin.php +++ b/app/Console/Commands/ResetAdmin.php @@ -48,6 +48,9 @@ class ResetAdmin extends Command 'two_factor_confirmed_at' => null, 'two_factor_recovery_codes' => null, ]); + // Drop security keys too — a key that prompted the emergency reset must not + // silently revive when 2FA is re-enabled. + $user->webauthnCredentials()->delete(); } $user->save(); diff --git a/app/Support/Webauthn/ZeroTolerantCounterChecker.php b/app/Support/Webauthn/ZeroTolerantCounterChecker.php index 3928d17..584aba2 100644 --- a/app/Support/Webauthn/ZeroTolerantCounterChecker.php +++ b/app/Support/Webauthn/ZeroTolerantCounterChecker.php @@ -16,8 +16,10 @@ class ZeroTolerantCounterChecker implements CounterChecker { public function check(CredentialRecord $credentialRecord, int $currentCounter): void { - if ($currentCounter === 0) { - return; // counter unsupported by this authenticator + // Tolerate 0 ONLY when the stored counter is also 0 (authenticator never counts). + // A 0 after a previously non-zero counter is a rollback/clone → reject. + if ($currentCounter === 0 && $credentialRecord->counter === 0) { + return; } if ($currentCounter <= $credentialRecord->counter) { diff --git a/tests/Feature/ResetAdminCommandTest.php b/tests/Feature/ResetAdminCommandTest.php index 8264a8f..df7e2d1 100644 --- a/tests/Feature/ResetAdminCommandTest.php +++ b/tests/Feature/ResetAdminCommandTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Models\User; +use App\Models\WebauthnCredential; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Tests\TestCase; @@ -18,6 +19,7 @@ class ResetAdminCommandTest extends TestCase 'two_factor_secret' => 'x', 'two_factor_confirmed_at' => now(), ]); $user->replaceRecoveryCodes(); + WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); $this->artisan('clusev:reset-admin', ['--email' => 'admin@clusev.local', '--password' => 'BrandNew1234', '--disable-2fa' => true]) ->assertExitCode(0); @@ -27,6 +29,7 @@ class ResetAdminCommandTest extends TestCase $this->assertNull($user->two_factor_secret); $this->assertNull($user->two_factor_confirmed_at); $this->assertFalse($user->hasRecoveryCodes()); + $this->assertFalse($user->hasWebauthnCredentials()); } public function test_unknown_email_fails(): void