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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user