Files
preauth/tests/Unit/Service/PasskeyCounterCheckerTest.php
T
lyra 436450cdc2 Add passkey ceremony store and manager
Builds both WebAuthn ceremonies on top of the library, with real cryptography
proven in tests rather than stubbed:

- PasskeyCeremonyStore: server-authoritative, single-use challenge state in the
  nonceCache pool. The client's challenge copy is never trusted, and consume()
  deletes before verifying so a replay cannot retry the same challenge.
- PasskeyManager: registration and login ceremonies. Library types are confined
  to this class and PasskeyCeremonyFactory. Failures return null rather than
  distinguishing unknown-credential from bad-signature, so the endpoint is not
  an enumeration oracle.
- PasskeyTestHelper: builds genuinely valid ceremonies (real P-256 keypair,
  COSE key, signed authenticatorData, CBOR attestation object).
- PasskeyRealCryptoSpikeTest: proves registration and assertion verify, that
  http:// origins are refused (D4), that challenges and rpIdHash are bound, and
  that a synchronised passkey with a constant zero counter can log in repeatedly.
2026-09-27 10:42:59 +00:00

118 lines
3.6 KiB
PHP

<?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\CounterChecker;
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::assertInstanceOf(CounterChecker::class, new PasskeyCounterChecker());
}
}