Files
preauth/tests/Unit/Service/PasskeyCeremonyStoreTest.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

152 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCeremonyStore;
use App\Service\PasskeyCeremonyStoreInterface;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
/**
* The ceremony store holds the two properties the whole flow depends on: the
* challenge is server-authoritative, and it can only be used once.
*/
final class PasskeyCeremonyStoreTest extends TestCase
{
private function makeStore(?ArrayAdapter $cache = null): PasskeyCeremonyStore
{
return new PasskeyCeremonyStore($cache ?? new ArrayAdapter());
}
public function test_it_starts_a_login_ceremony(): void
{
$ceremony = $this->makeStore()->startLogin();
self::assertArrayHasKey('ceremonyId', $ceremony);
self::assertArrayHasKey('challenge', $ceremony);
self::assertSame(32, \strlen($ceremony['challenge']));
self::assertNotSame('', $ceremony['ceremonyId']);
}
public function test_it_starts_a_registration_ceremony_with_a_derived_user_handle(): void
{
$ceremony = $this->makeStore()->startRegistration('lyra');
self::assertSame(hash('sha256', 'lyra', true), $ceremony['userHandle']);
/* the handle must not be the raw identity, or the label leaks into the
* authenticator's credential list */
self::assertStringNotContainsString('lyra', $ceremony['userHandle']);
}
public function test_each_ceremony_gets_a_distinct_id_and_challenge(): void
{
$store = $this->makeStore();
$first = $store->startLogin();
$second = $store->startLogin();
self::assertNotSame($first['ceremonyId'], $second['ceremonyId']);
self::assertNotSame($first['challenge'], $second['challenge']);
}
public function test_consume_returns_the_stored_challenge(): void
{
$store = $this->makeStore();
$ceremony = $store->startLogin();
$consumed = $store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN);
self::assertNotNull($consumed);
self::assertSame($ceremony['challenge'], $consumed['challenge']);
}
/**
* The replay guard. A second consume must fail, otherwise an observed
* `finish` could be replayed against the same challenge.
*/
public function test_a_ceremony_can_only_be_consumed_once(): void
{
$store = $this->makeStore();
$ceremony = $store->startLogin();
self::assertNotNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
public function test_an_unknown_id_yields_null(): void
{
self::assertNull($this->makeStore()->consume('never-issued', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
/**
* A registration ceremony must not be usable to complete a login, or the
* two flows' differing trust assumptions would blur together.
*/
public function test_a_ceremony_cannot_be_used_for_the_other_type(): void
{
$store = $this->makeStore();
$registration = $store->startRegistration('lyra');
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
/* and it was destroyed by that failed attempt, so it cannot be retried
* with the correct type either */
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_REGISTER));
}
/**
* `makeCacheKey()` sanitises the base64url alphabet into `_`, so using it
* directly on an id would let two distinct ids collide on one cache slot.
* The store hashes instead; this asserts distinct ids stay distinct.
*/
public function test_distinct_ids_never_share_a_cache_key(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
$ids = [];
for ($i = 0; $i < 50; ++$i) {
$ids[] = $store->startRegistration("user$i")['ceremonyId'];
}
self::assertCount(50, array_unique($ids));
foreach ($ids as $id) {
self::assertNotNull($store->consume($id, PasskeyCeremonyStoreInterface::TYPE_REGISTER));
}
}
public function test_ceremonies_do_not_survive_the_cache_being_cleared(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
$ceremony = $store->startLogin();
/* stand-in for expiry/eviction: a ceremony must not outlive its TTL, and
* `nonceCache` is APCu precisely so it does not survive a restart */
$cache->clear();
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
/**
* Malformed cache contents must degrade to "no ceremony" rather than
* throwing into the listener, which would surface as a 500 on the login page.
*/
public function test_a_malformed_record_is_treated_as_absent(): void
{
$cache = new ArrayAdapter();
$store = $this->makeStore($cache);
/* reach the private key derivation so the malformed value lands exactly
* where a real ceremony record would */
$key = new ReflectionMethod(PasskeyCeremonyStore::class, 'key');
$item = $cache->getItem($key->invoke($store, 'bogus'));
$item->set('not-an-array');
$cache->save($item);
self::assertNull($store->consume('bogus', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
}
}