Use a lenient signature-counter check for passkeys

The library default (ThrowExceptionIfInvalid) requires the reported counter to
be strictly greater than the stored one. Synchronised passkeys report a
constant 0 forever, so the default rejects a brand-new credential on its first
login — and only on real hardware, never in a unit test that increments the
counter.

The replacement still rejects a counter that moves backwards, which is the only
signal the counter can carry. Clone detection remains explicitly not a property
this feature claims; see SECURITY.md.
This commit is contained in:
2026-09-27 10:38:02 +00:00
parent c84cf8c308
commit 69609db8af
3 changed files with 236 additions and 0 deletions
+64
View File
@@ -4,10 +4,15 @@ declare(strict_types=1);
namespace App\Service;
use Cose\Algorithm\Manager;
use Cose\Algorithm\Signature\ECDSA\ES256;
use Symfony\Component\Serializer\SerializerInterface;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\CredentialRecord;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\InvalidDataException;
@@ -32,11 +37,70 @@ final readonly class PasskeyCeremonyFactory
{
private AttestationStatementSupportManager $attestationStatementSupportManager;
private PasskeyCounterChecker $counterChecker;
public function __construct()
{
$manager = AttestationStatementSupportManager::create();
$manager->add(NoneAttestationStatementSupport::create());
$this->attestationStatementSupportManager = $manager;
$this->counterChecker = new PasskeyCounterChecker();
}
/**
* Validator for the registration ceremony.
*
* The origins are passed in rather than read from a request, so the scheme
* and host can only ever come from configuration. This is what makes D4
* enforceable: an `http://` origin is never presented to the library as
* acceptable, no matter how the request arrived at the container.
*
* @param string[] $allowedOrigins
*/
public function creationCeremonyValidator(array $allowedOrigins): AuthenticatorAttestationResponseValidator
{
return AuthenticatorAttestationResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->creationCeremony(),
);
}
/**
* Validator for the login (assertion) ceremony.
*
* @param string[] $allowedOrigins
*/
public function requestCeremonyValidator(array $allowedOrigins): AuthenticatorAssertionResponseValidator
{
return AuthenticatorAssertionResponseValidator::create(
$this->ceremonyStepManagerFactory($allowedOrigins)->requestCeremony(),
);
}
public function counterChecker(): PasskeyCounterChecker
{
return $this->counterChecker;
}
/**
* The ceremony steps shared by both ceremonies.
*
* `setSecuredRelyingPartyId()` is deliberately never called: it is deprecated
* in 5.2 and, more importantly, it is the escape hatch that would let an
* `http://` origin through. Development uses real TLS instead (D4).
*
* @param string[] $allowedOrigins
*/
private function ceremonyStepManagerFactory(array $allowedOrigins): CeremonyStepManagerFactory
{
$factory = new CeremonyStepManagerFactory();
$factory->setAllowedOrigins($allowedOrigins);
$factory->setAlgorithmManager(Manager::create()->add(ES256::create()));
$factory->setAttestationStatementSupportManager($this->attestationStatementSupportManager);
/* replace the library default, which rejects the constant-zero counter
* that synchronised passkeys report — see PasskeyCounterChecker */
$factory->setCounterChecker($this->counterChecker);
return $factory;
}
/**
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Override;
use Webauthn\Counter\CounterChecker;
use Webauthn\CredentialRecord;
use Webauthn\Exception\CounterException;
/**
* Signature-counter policy for passkey assertions.
*
* **Why this replaces the library default.** `ThrowExceptionIfInvalid` (the
* default wired up by `CeremonyStepManagerFactory`) requires the reported
* counter to be *strictly greater* than the stored one. That is wrong for the
* passkeys this feature targets: platform passkeys are synchronised through the
* OS keychain, and a synchronised authenticator reports a constant `0` forever
* (the spec permits it, and the multi-device design effectively requires it).
* Measured against the installed library:
*
* stored 0, reported 0 -> `ThrowExceptionIfInvalid` throws `CounterException`
*
* so with the default checker a brand-new synced passkey fails on its *first*
* login — and only on real hardware, never in a unit test that increments the
* counter. That is the worst possible failure shape, so the default is not used.
*
* **What is kept.** A counter that goes *backwards* still fails. That is the one
* signal the counter can carry (a cloned authenticator replaying an older
* assertion), and rejecting it costs nothing because a genuine synchronised
* passkey only ever reports the same value or a larger one.
*
* Note this is defence in depth and not relied upon for security: a
* synchronised passkey's counter carries no clone signal at all, which is why
* `SECURITY.md` records that clone detection is explicitly not a property this
* feature claims. The real protections are per-credential challenge binding,
* origin/RP-ID checks, and the user-verification requirement.
*/
final readonly class PasskeyCounterChecker implements CounterChecker
{
/**
* @throws CounterException when the reported counter moves backwards
*/
#[Override]
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
{
if ($currentCounter < $credentialRecord->counter) {
throw CounterException::create(
$currentCounter,
$credentialRecord->counter,
'The signature counter moved backwards, which can indicate a cloned authenticator.',
);
}
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCounterChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;
use Webauthn\Counter\ThrowExceptionIfInvalid;
use Webauthn\CredentialRecord;
use Webauthn\Exception\CounterException;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The counter policy is the difference between "works on real hardware" and
* "works only in tests", so both halves of it are pinned here: the behaviour we
* deliberately allow, and the behaviour we deliberately still refuse.
*/
final class PasskeyCounterCheckerTest extends TestCase
{
private function makeRecord(int $counter): CredentialRecord
{
return CredentialRecord::create(
random_bytes(32),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
'user-handle',
$counter,
null,
true,
false,
true,
);
}
/**
* The regression this class exists for: a synchronised passkey reports 0
* forever, so the first login of a brand-new credential must succeed.
*/
public function test_a_constant_zero_counter_is_accepted(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(0);
$checker->check($record, 0);
$checker->check($record, 0);
/* reaching this point without an exception is the assertion */
self::assertSame(0, $record->counter);
}
/**
* Documents *why* the library default cannot be used: it rejects the exact
* scenario above. If a future library version relaxes this, the test fails
* and the custom checker can be reconsidered rather than kept by habit.
*/
public function test_the_library_default_would_reject_a_constant_zero_counter(): void
{
$this->expectException(CounterException::class);
(new ThrowExceptionIfInvalid())->check($this->makeRecord(0), 0);
}
public function test_a_counter_that_moves_forward_is_accepted(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(5);
$checker->check($record, 6);
$checker->check($record, PHP_INT_MAX);
self::assertSame(5, $record->counter);
}
public function test_a_counter_that_moves_backwards_is_rejected(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(5);
$this->expectException(CounterException::class);
$checker->check($record, 4);
}
/**
* The exception carries both values, which the listener logs. Asserted so a
* future refactor cannot quietly drop the diagnostic detail.
*/
public function test_the_rejection_reports_both_counters(): void
{
$checker = new PasskeyCounterChecker();
$record = $this->makeRecord(9);
try {
$checker->check($record, 3);
self::fail('Expected a CounterException.');
} catch (CounterException $exception) {
self::assertSame(3, $exception->currentCounter);
self::assertSame(9, $exception->authenticatorCounter);
}
}
/**
* The checker under test must differ from the library default, otherwise
* wiring the default back in by accident would go unnoticed.
*/
public function test_it_is_not_the_library_default(): void
{
self::assertNotInstanceOf(ThrowExceptionIfInvalid::class, new PasskeyCounterChecker());
}
}