From 44b87d78bab68006ecf79629ca9c7ca338bb0bc5 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 18:44:24 +0200 Subject: [PATCH] fix(webauthn): tolerate zero-counter authenticators + recheck 2FA on register - Swap the strict counter checker for a zero-tolerant one so platform authenticators / synced passkeys (which always report counter 0) can assert, while non-zero counters still must strictly increase (clone detection). - Guard registration (options + register) and the UI on hasTwoFactorEnabled, so a key is never created against a disabled/reset second factor. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Settings/WebauthnKeys.php | 8 +++--- app/Services/WebauthnService.php | 7 +++-- .../Webauthn/ZeroTolerantCounterChecker.php | 27 +++++++++++++++++++ tests/Feature/WebauthnKeysTest.php | 4 +-- 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 app/Support/Webauthn/ZeroTolerantCounterChecker.php diff --git a/app/Livewire/Settings/WebauthnKeys.php b/app/Livewire/Settings/WebauthnKeys.php index 5624e17..acdc74f 100644 --- a/app/Livewire/Settings/WebauthnKeys.php +++ b/app/Livewire/Settings/WebauthnKeys.php @@ -17,7 +17,7 @@ class WebauthnKeys extends Component /** JSON creation options for navigator.credentials.create (the JS posts the result to register). */ public function options(WebauthnService $webauthn): array { - abort_unless($webauthn->available(), 404); + abort_unless($webauthn->available() && Auth::user()->hasTwoFactorEnabled(), 404); // Validate the label BEFORE the browser ceremony, so an invalid name never // leaves an orphaned credential on the authenticator. $this->validate(); @@ -27,7 +27,9 @@ class WebauthnKeys extends Component public function register(array $attestation, WebauthnService $webauthn): void { - abort_unless($webauthn->available(), 404); + // Re-check 2FA enrollment (it may have been disabled in another tab since the + // ceremony started) so a key is never created against a reset second factor. + abort_unless($webauthn->available() && Auth::user()->hasTwoFactorEnabled(), 404); $this->validate(); $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); @@ -92,7 +94,7 @@ class WebauthnKeys extends Component public function render() { return view('livewire.settings.webauthn-keys', [ - 'available' => app(WebauthnService::class)->available(), + 'available' => app(WebauthnService::class)->available() && Auth::user()->hasTwoFactorEnabled(), 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), ]); } diff --git a/app/Services/WebauthnService.php b/app/Services/WebauthnService.php index 5f28ee7..328a07e 100644 --- a/app/Services/WebauthnService.php +++ b/app/Services/WebauthnService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\User; use App\Models\WebauthnCredential; +use App\Support\Webauthn\ZeroTolerantCounterChecker; use Throwable; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\NoneAttestationStatementSupport; @@ -174,10 +175,12 @@ class WebauthnService private function ceremony(): CeremonyStepManagerFactory { - // Defaults already register ES256+RS256, NoneAttestation and a strict counter - // checker; we only constrain the allowed origin to https://. + // Defaults register ES256+RS256 + NoneAttestation; constrain the origin to + // https:// and swap the strict counter checker for a zero-tolerant one + // (platform authenticators / passkeys always report counter 0). $factory = new CeremonyStepManagerFactory; $factory->setAllowedOrigins(['https://'.$this->rpId()]); + $factory->setCounterChecker(new ZeroTolerantCounterChecker); return $factory; } diff --git a/app/Support/Webauthn/ZeroTolerantCounterChecker.php b/app/Support/Webauthn/ZeroTolerantCounterChecker.php new file mode 100644 index 0000000..3928d17 --- /dev/null +++ b/app/Support/Webauthn/ZeroTolerantCounterChecker.php @@ -0,0 +1,27 @@ +counter) { + throw new RuntimeException('Invalid WebAuthn signature counter (possible cloned authenticator).'); + } + } +} diff --git a/tests/Feature/WebauthnKeysTest.php b/tests/Feature/WebauthnKeysTest.php index 816f5e7..db2579c 100644 --- a/tests/Feature/WebauthnKeysTest.php +++ b/tests/Feature/WebauthnKeysTest.php @@ -16,7 +16,7 @@ class WebauthnKeysTest extends TestCase public function test_register_stores_credential(): void { - $user = User::factory()->create(); + $user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); $this->actingAs($user); $svc = $this->mock(WebauthnService::class); $svc->shouldReceive('available')->andReturn(true); @@ -34,7 +34,7 @@ class WebauthnKeysTest extends TestCase public function test_options_validate_label_before_ceremony(): void { - $user = User::factory()->create(); + $user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); $this->actingAs($user); $this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true);