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
parent
139c376056
commit
44b87d78ba
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue