Files
preauth/tests/Unit/Service/PasskeyCredentialStoreTest.php
T
lyra c84cf8c308 Add passkey credential store
Persistence for registered passkeys, backed by sessionCache so credentials
survive a container restart the way sessions do.

The pool is wrapped in MonitorCacheKeys, matching LoginManager and
BackupCodeManager. Without that wrapper the credentials would live only in the
APCu-side pool and vanish on the next restart, because PersistCache::persist()
only flushes keys a monitor recorded. A test asserts visibility to the
persistent pool rather than trusting the wrapper.

Two storage hazards found while building this and covered by tests:

  - makeCacheKey() is not injective for base64url. It collapses the whole
    punctuation alphabet to "_", so "abc-def" and "abc_def" would share one
    cache slot and one credential would silently overwrite the other.
    Credential ids are therefore hashed, and a test uses precisely that pair.
  - A record's own credential id is authoritative. An index entry pointing at
    a record that disagrees with its key is rejected rather than trusted.

Unreadable or wrong-shaped entries degrade to "credential unavailable" so a
corrupt value cannot 500 the login page.

PasskeyCeremonyFactory is the single seam onto webauthn-lib: it builds the
serializer and pins attestation to `none` only, so a future version that moves
or renames library types touches one file.

Suite: 353 tests / 831 assertions, 100% coverage on all new files.
phpstan level 6 clean, php-cs-fixer clean, conformance 35/35.
2026-09-27 02:40:31 +00:00

294 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Data\PasskeyCredential;
use App\MonitorCacheKeys;
use App\Service\PasskeyCeremonyFactory;
use App\Service\PasskeyCredentialStore;
use DateTimeImmutable;
use LogicException;
use Override;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Uid\Uuid;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* Covers credential persistence, the index, and the two failure modes the plan
* called out: losing credentials on restart, and key collisions.
*/
final class PasskeyCredentialStoreTest extends TestCase
{
/** The real backing pool, so persistence can be asserted against it. */
private ?ArrayAdapter $pool = null;
#[Override]
protected function setUp(): void
{
parent::setUp();
$this->pool = new ArrayAdapter();
}
private function makeStore(): PasskeyCredentialStore
{
return new PasskeyCredentialStore($this->pool(), new PasskeyCeremonyFactory());
}
/** The pool for the current test; setUp() always assigns it. */
private function pool(): ArrayAdapter
{
return $this->pool ?? throw new LogicException('setUp() did not run');
}
/** @param array{userHandle?: string, counter?: int, backupEligible?: ?bool} $overrides */
private function makeCredential(
string $credentialId,
string $identity = 'lyra',
string $label = 'Laptop',
array $overrides = [],
): PasskeyCredential {
$record = CredentialRecord::create(
$credentialId,
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
$overrides['userHandle'] ?? 'user-handle',
$overrides['counter'] ?? 0,
null,
$overrides['backupEligible'] ?? true,
false,
true,
);
return new PasskeyCredential($record, $identity, $label, new DateTimeImmutable('2026-01-01 12:00:00'));
}
public function test_save_then_find_round_trips_the_record(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$found = $store->find($credentialId);
self::assertNotNull($found);
self::assertSame($credentialId, $found->record->publicKeyCredentialId);
self::assertSame('lyra', $found->identity);
self::assertSame('Laptop', $found->label);
self::assertSame('COSE_PUBLIC_KEY_BYTES', $found->record->credentialPublicKey);
self::assertSame('user-handle', $found->record->userHandle);
self::assertTrue($found->record->backupEligible);
self::assertNull($found->lastUsedAt);
}
public function test_find_returns_null_for_an_unknown_credential(): void
{
self::assertNull($this->makeStore()->find(random_bytes(32)));
}
public function test_all_returns_every_saved_credential(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential(random_bytes(32), label: 'One'));
$store->save($this->makeCredential(random_bytes(32), label: 'Two'));
$store->save($this->makeCredential(random_bytes(32), label: 'Three'));
self::assertCount(3, $store->all());
self::assertSame(3, $store->count());
}
public function test_find_by_identity_filters(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
$store->save($this->makeCredential(random_bytes(32), identity: 'someone-else'));
self::assertCount(2, $store->findByIdentity('lyra'));
self::assertCount(1, $store->findByIdentity('someone-else'));
self::assertCount(0, $store->findByIdentity('nobody'));
}
public function test_remove_forgets_the_credential_and_the_index_entry(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
self::assertTrue($store->remove($credentialId));
self::assertNull($store->find($credentialId));
self::assertSame(0, $store->count());
self::assertSame([], $store->all());
}
public function test_update_usage_refreshes_the_counter_and_last_used(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId, overrides: ['counter' => 0]));
/* the library updates the counter in place after a verified assertion */
$used = $store->find($credentialId)->record;
$used->counter = 7;
$store->updateUsage($used);
$found = $store->find($credentialId);
self::assertSame(7, $found->record->counter);
self::assertNotNull($found->lastUsedAt);
/* metadata must be preserved, not reset by the usage update */
self::assertSame('lyra', $found->identity);
self::assertSame('Laptop', $found->label);
}
public function test_update_usage_ignores_unknown_credentials(): void
{
$store = $this->makeStore();
$record = $this->makeCredential(random_bytes(32))->record;
$store->updateUsage($record);
self::assertSame(0, $store->count());
}
/**
* Distinct credential ids must never share a cache slot.
*
* `makeCacheKey()` alone is not injective for the base64url alphabet
* ("abc-def" and "abc_def" both sanitise to "abc_def"), so the store hashes
* the id. These two ids differ only by '-' vs '_' on purpose.
*/
public function test_credential_ids_that_differ_only_by_base64url_punctuation_do_not_collide(): void
{
$store = $this->makeStore();
$store->save($this->makeCredential('abc-def', label: 'Dash'));
$store->save($this->makeCredential('abc_def', label: 'Underscore'));
self::assertSame(2, $store->count());
self::assertSame('Dash', $store->find('abc-def')->label);
self::assertSame('Underscore', $store->find('abc_def')->label);
}
/**
* The plan's headline storage risk: without wrapping the pool in
* MonitorCacheKeys, credentials would live only in the APCu-side pool and
* vanish on the next restart, because PersistCache::persist() only flushes
* keys a monitor recorded.
*/
public function test_saved_credentials_are_visible_to_the_persistent_pool(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$monitor = new MonitorCacheKeys($this->pool());
$recorded = $monitor->getKeys();
self::assertNotEmpty($recorded, 'the store must record its writes with MonitorCacheKeys');
self::assertContains(
'passkey_index',
$recorded,
'the index must be tracked so it is flushed to the persistent pool',
);
$changes = $monitor->getChanges();
self::assertArrayHasKey('passkey_index', $changes);
/* and the credential itself must be tracked, not just the index */
$trackedCredentialKeys = array_filter(
$recorded,
static fn (string $key): bool => str_starts_with($key, 'passkey_cred_'),
);
self::assertNotEmpty($trackedCredentialKeys, 'the credential entry must be tracked too');
}
/**
* A corrupt or foreign payload must degrade to "unavailable", never to a
* crash on the login page.
*/
public function test_a_corrupt_entry_is_skipped_rather_than_throwing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
/* corrupt the stored record but leave the index intact */
$key = 'passkey_cred_'.hash('sha256', $credentialId);
$item = $this->pool()->getItem($key);
$payload = $item->get();
$payload['record'] = '{not valid json';
$item->set($payload);
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
/* all() must not throw, it must simply omit the broken entry */
self::assertSame([], $store->all());
}
/**
* The record decides which credential id it belongs to; an index entry
* pointing somewhere else must not be honoured.
*/
public function test_a_record_that_disagrees_with_its_key_is_rejected(): void
{
$store = $this->makeStore();
$real = random_bytes(32);
$store->save($this->makeCredential($real));
/* copy the payload to a different credential id's slot */
$source = $this->pool()->getItem('passkey_cred_'.hash('sha256', $real));
$otherId = random_bytes(32);
$target = $this->pool()->getItem('passkey_cred_'.hash('sha256', $otherId));
$target->set($source->get());
$this->pool()->save($target);
self::assertNull($store->find($otherId));
}
public function test_an_empty_index_reads_as_empty(): void
{
self::assertSame([], $this->makeStore()->all());
self::assertSame(0, $this->makeStore()->count());
}
/**
* A stored value that is not the expected structure (for example written by
* a different version) must read as "no such credential".
*/
public function test_a_payload_of_the_wrong_shape_reads_as_missing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$item = $this->pool()->getItem('passkey_cred_'.hash('sha256', $credentialId));
$item->set('not-an-array');
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
}
/** A payload missing one of the required metadata keys is also unusable. */
public function test_a_payload_missing_metadata_reads_as_missing(): void
{
$store = $this->makeStore();
$credentialId = random_bytes(32);
$store->save($this->makeCredential($credentialId));
$key = 'passkey_cred_'.hash('sha256', $credentialId);
$item = $this->pool()->getItem($key);
$payload = $item->get();
unset($payload['identity']);
$item->set($payload);
$this->pool()->save($item);
self::assertNull($store->find($credentialId));
}
}