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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:44:24 +02:00
parent 139c376056
commit 44b87d78ba
4 changed files with 39 additions and 7 deletions

View File

@ -17,7 +17,7 @@ class WebauthnKeys extends Component
/** JSON creation options for navigator.credentials.create (the JS posts the result to register). */ /** JSON creation options for navigator.credentials.create (the JS posts the result to register). */
public function options(WebauthnService $webauthn): array 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 // Validate the label BEFORE the browser ceremony, so an invalid name never
// leaves an orphaned credential on the authenticator. // leaves an orphaned credential on the authenticator.
$this->validate(); $this->validate();
@ -27,7 +27,9 @@ class WebauthnKeys extends Component
public function register(array $attestation, WebauthnService $webauthn): void 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(); $this->validate();
$cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName));
@ -92,7 +94,7 @@ class WebauthnKeys extends Component
public function render() public function render()
{ {
return view('livewire.settings.webauthn-keys', [ 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(), 'keys' => Auth::user()->webauthnCredentials()->latest()->get(),
]); ]);
} }

View File

@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\User; use App\Models\User;
use App\Models\WebauthnCredential; use App\Models\WebauthnCredential;
use App\Support\Webauthn\ZeroTolerantCounterChecker;
use Throwable; use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport; use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
@ -174,10 +175,12 @@ class WebauthnService
private function ceremony(): CeremonyStepManagerFactory private function ceremony(): CeremonyStepManagerFactory
{ {
// Defaults already register ES256+RS256, NoneAttestation and a strict counter // Defaults register ES256+RS256 + NoneAttestation; constrain the origin to
// checker; we only constrain the allowed origin to https://<rpId>. // https://<rpId> and swap the strict counter checker for a zero-tolerant one
// (platform authenticators / passkeys always report counter 0).
$factory = new CeremonyStepManagerFactory; $factory = new CeremonyStepManagerFactory;
$factory->setAllowedOrigins(['https://'.$this->rpId()]); $factory->setAllowedOrigins(['https://'.$this->rpId()]);
$factory->setCounterChecker(new ZeroTolerantCounterChecker);
return $factory; return $factory;
} }

View File

@ -0,0 +1,27 @@
<?php
namespace App\Support\Webauthn;
use RuntimeException;
use Webauthn\Counter\CounterChecker;
use Webauthn\CredentialRecord;
/**
* Counter check that tolerates authenticators which don't support a signature counter
* (platform authenticators / synchronized passkeys always report 0): a current counter
* of 0 is accepted, while a non-zero counter must strictly increase keeping the
* cloned-authenticator / rollback detection for keys that do count.
*/
class ZeroTolerantCounterChecker implements CounterChecker
{
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
{
if ($currentCounter === 0) {
return; // counter unsupported by this authenticator
}
if ($currentCounter <= $credentialRecord->counter) {
throw new RuntimeException('Invalid WebAuthn signature counter (possible cloned authenticator).');
}
}
}

View File

@ -16,7 +16,7 @@ class WebauthnKeysTest extends TestCase
public function test_register_stores_credential(): void 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); $this->actingAs($user);
$svc = $this->mock(WebauthnService::class); $svc = $this->mock(WebauthnService::class);
$svc->shouldReceive('available')->andReturn(true); $svc->shouldReceive('available')->andReturn(true);
@ -34,7 +34,7 @@ class WebauthnKeysTest extends TestCase
public function test_options_validate_label_before_ceremony(): void 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->actingAs($user);
$this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true); $this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true);