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). */
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(),
]);
}

View File

@ -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://<rpId>.
// Defaults register ES256+RS256 + NoneAttestation; constrain the origin to
// https://<rpId> 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;
}

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
{
$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);