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.
This commit is contained in:
2026-09-27 02:40:31 +00:00
parent 108e9623e6
commit c84cf8c308
6 changed files with 874 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Data;
use DateTimeImmutable;
use Webauthn\CredentialRecord;
/**
* A stored passkey: the WebAuthn credential plus the application metadata that
* ties it to a visitor of this gateway.
*
* The credential itself carries the public key and signature counter; the
* metadata here records *whose* passkey it is and when it was used. The identity
* is deliberately read from this record on assertion rather than from the value
* the client returns, which is never trusted.
*/
final readonly class PasskeyCredential
{
public function __construct(
public CredentialRecord $record,
/** The session id this passkey authenticates, as typed at registration. */
public string $identity,
/** Operator-facing description, shown so a user can tell their keys apart. */
public string $label,
public DateTimeImmutable $createdAt,
public ?DateTimeImmutable $lastUsedAt = null,
) {
}
/**
* A copy of this credential with the signature counter and last-used time
* refreshed after a successful assertion.
*
* The counter is only ever observed, never enforced — many passkeys report a
* constant zero, so treating it as a clone signal would lock users out.
*/
public function withUsage(CredentialRecord $updated, DateTimeImmutable $usedAt): self
{
return new self(
$updated,
$this->identity,
$this->label,
$this->createdAt,
$usedAt,
);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\Serializer\SerializerInterface;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\CredentialRecord;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\InvalidDataException;
/**
* Builds the WebAuthn collaborators, and is the one place that knows how they
* are wired.
*
* Everything that touches webauthn-lib types goes through here, so a library
* major version that renames or moves those types is a single-file change
* instead of a hunt through the codebase.
*
* **Attestation is deliberately `none`.** The alternatives were measured and
* rejected: attestation conveyance is only a preference a client may ignore,
* and the FIDO metadata service is bypassed both by the zero AAGUID that
* privacy-preserving passkeys already send and by self attestation — while
* still refusing legitimate authenticators that postdate its cached blob. See
* `docs/passkey-auth-subdomain-plan.md` §2.3 for the evidence, and `SECURITY.md`
* for the conditions that would justify revisiting it.
*/
final readonly class PasskeyCeremonyFactory
{
private AttestationStatementSupportManager $attestationStatementSupportManager;
public function __construct()
{
$manager = AttestationStatementSupportManager::create();
$manager->add(NoneAttestationStatementSupport::create());
$this->attestationStatementSupportManager = $manager;
}
/**
* The serializer for every WebAuthn value: credential records, ceremony
* options, and the client's response.
*
* This must be used instead of `json_encode()`. Options carry raw binary
* (the challenge) which `json_encode()` rejects outright, and
* `CredentialRecord` is not `JsonSerializable` at all — the serializer
* base64url-encodes those fields and is required for a correct round-trip.
*/
public function serializer(): SerializerInterface
{
return (new WebauthnSerializerFactory($this->attestationStatementSupportManager))->create();
}
public function attestationStatementSupportManager(): AttestationStatementSupportManager
{
return $this->attestationStatementSupportManager;
}
/**
* Serialize a credential record for storage.
*
* @throws InvalidDataException
*/
public function serializeCredential(CredentialRecord $record): string
{
return $this->serializer()->serialize($record, 'json');
}
/**
* Rebuild a credential record previously written by {@see serializeCredential()}.
*
* Returns null rather than throwing when the stored payload is unusable: a
* corrupt entry must degrade to "this passkey is unavailable", never to a
* 500 on the login page.
*/
public function deserializeCredential(string $json): ?CredentialRecord
{
try {
return $this->serializer()->deserialize($json, CredentialRecord::class, 'json');
} catch (Throwable) {
return null;
}
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\AppConstants;
use App\Data\PasskeyCredential;
use App\MonitorCacheKeys;
use App\Trait\StringTrait;
use DateTimeImmutable;
use Override;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Webauthn\CredentialRecord;
/**
* @see PasskeyCredentialStoreInterface
*
* **Persistence.** The pool is wrapped in a {@see MonitorCacheKeys}, exactly
* as `LoginManager` and `BackupCodeManager` do. Without that wrapper the
* credentials would live only in the APCu-side pool and disappear on the next
* container restart, because `PersistCache::persist()` only flushes keys that a
* monitor recorded. `PasskeyCredentialStoreTest` asserts visibility in the
* underlying persistent pool, not just the wrapped one.
*
* **Layout.**
* passkey_cred_<key(credentialId)> -> serialized credential + metadata
* passkey_index -> credentialId => {identity, label, createdAt}
*
* The index exists so the login page can build `allowCredentials` without
* enumerating the whole key space, and so a corrupt credential cannot make the
* list disappear entirely.
*/
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
{
use StringTrait;
private const string PREFIX = 'passkey_cred_';
private const string INDEX_KEY = 'passkey_index';
private MonitorCacheKeys $sessionCache;
/**
* @throws InvalidArgumentException
*/
public function __construct(
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
private PasskeyCeremonyFactory $ceremonyFactory,
) {
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function all(): array
{
$credentials = [];
foreach ($this->credentialIds() as $credentialId) {
$credential = $this->find($credentialId);
if (null !== $credential) {
$credentials[] = $credential;
}
}
return $credentials;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function find(string $credentialId): ?PasskeyCredential
{
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
if (!$item->isHit()) {
return null;
}
$payload = $item->get();
if (!\is_array($payload)
|| !isset($payload['record'], $payload['identity'], $payload['label'], $payload['createdAt'])
) {
return null;
}
$record = $this->ceremonyFactory->deserializeCredential((string) $payload['record']);
if (null === $record) {
return null;
}
/* Guard against an index/record mismatch: the stored record decides which
* credential id it answers to. */
if (!hash_equals($record->publicKeyCredentialId, $credentialId)) {
return null;
}
return new PasskeyCredential(
$record,
(string) $payload['identity'],
(string) $payload['label'],
new DateTimeImmutable((string) $payload['createdAt']),
isset($payload['lastUsedAt']) ? new DateTimeImmutable((string) $payload['lastUsedAt']) : null,
);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function findByIdentity(string $identity): array
{
$matches = [];
foreach ($this->all() as $credential) {
if (hash_equals($credential->identity, $identity)) {
$matches[] = $credential;
}
}
return $matches;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function save(PasskeyCredential $credential): void
{
$credentialId = $credential->record->publicKeyCredentialId;
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
$item->set([
'record' => $this->ceremonyFactory->serializeCredential($credential->record),
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
'lastUsedAt' => $credential->lastUsedAt?->format(\DATE_ATOM),
]);
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
$this->writeIndexEntry($credentialId, [
'identity' => $credential->identity,
'label' => $credential->label,
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
]);
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function updateUsage(CredentialRecord $record): void
{
$existing = $this->find($record->publicKeyCredentialId);
if (null === $existing) {
return;
}
$this->save($existing->withUsage($record, new DateTimeImmutable()));
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function remove(string $credentialId): bool
{
$index = $this->readIndex();
$existed = \array_key_exists($credentialId, $index);
unset($index[$credentialId]);
$this->writeIndex($index);
return $this->sessionCache->deleteItem($this->credentialKey($credentialId)) || $existed;
}
/**
* @throws InvalidArgumentException
*/
#[Override]
public function count(): int
{
return \count($this->credentialIds());
}
/**
* Credential ids from the index only.
*
* Deliberately not `getKeys()`: keys are truncated to a fixed length by
* `makeCacheKey()`, so enumerating them cannot reliably recover a full
* credential id. The index stores the ids verbatim.
*
* @return string[]
*
* @throws InvalidArgumentException
*/
private function credentialIds(): array
{
return array_keys($this->readIndex());
}
/**
* @return array<string,array{identity: string, label: string, createdAt: string}>
*
* @throws InvalidArgumentException
*/
private function readIndex(): array
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$index = $item->isHit() ? $item->get() : null;
return \is_array($index) ? $index : [];
}
/**
* @param array<string,array{identity: string, label: string, createdAt: string}> $index
*
* @throws InvalidArgumentException
*/
private function writeIndex(array $index): void
{
$item = $this->sessionCache->getItem(self::INDEX_KEY);
$item->set($index);
$item->expiresAt($this->forever());
$this->sessionCache->save($item);
}
/**
* @param array{identity: string, label: string, createdAt: string} $entry
*
* @throws InvalidArgumentException
*/
private function writeIndexEntry(string $credentialId, array $entry): void
{
$index = $this->readIndex();
$index[$credentialId] = $entry;
$this->writeIndex($index);
}
/** Credentials and the index are kept indefinitely; only removal clears them. */
private function forever(): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat('Y-m-d', AppConstants::FAR_FUTURE_DATE);
}
/**
* Cache key for one credential id.
*
* The id is hashed rather than passed through `makeCacheKey()`, because that
* sanitises the base64url alphabet into a single `_` and is therefore not
* injective: "abc-def" and "abc_def" both become "abc_def", so two distinct
* credentials could share one cache slot and one of them would silently
* overwrite the other. A hash is injective for practical purposes and keeps
* the key within the allowed character set.
*/
private function credentialKey(string $credentialId): string
{
return self::PREFIX.hash('sha256', $credentialId);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Data\PasskeyCredential;
use Webauthn\CredentialRecord;
/**
* Persistence for registered passkeys, backed by the `sessionCache` pool so that
* credentials survive a container restart the same way sessions do.
*
* The identity is stored alongside the credential and is authoritative on
* assertion: the `userHandle` a client returns is attacker-controlled and is
* compared for consistency but never used to decide who is logging in.
*/
interface PasskeyCredentialStoreInterface
{
/**
* Every registered credential, for the login ceremony's `allowCredentials`.
*
* Credentials whose stored payload cannot be read are skipped rather than
* failing the ceremony, so one bad entry cannot lock everyone out.
*
* @return PasskeyCredential[]
*/
public function all(): array;
/**
* Look up a single credential by its raw credential id.
*/
public function find(string $credentialId): ?PasskeyCredential;
/**
* Every credential belonging to one identity.
*
* @return PasskeyCredential[]
*/
public function findByIdentity(string $identity): array;
/**
* Persist a newly registered credential.
*/
public function save(PasskeyCredential $credential): void;
/**
* Record that a credential was just used, refreshing its counter.
*/
public function updateUsage(CredentialRecord $record): void;
/**
* Forget a credential.
*
* @return bool true when a credential was removed
*/
public function remove(string $credentialId): bool;
public function count(): int;
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\PasskeyCeremonyFactory;
use JsonSerializable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\CredentialRecord;
use Webauthn\TrustPath\EmptyTrustPath;
/**
* The factory is the single seam between the application and webauthn-lib, so
* these tests pin the library behaviours the rest of the feature relies on.
*/
final class PasskeyCeremonyFactoryTest extends TestCase
{
private function makeFactory(): PasskeyCeremonyFactory
{
return new PasskeyCeremonyFactory();
}
private function makeRecord(): CredentialRecord
{
return CredentialRecord::create(
random_bytes(32),
'public-key',
['internal'],
'none',
EmptyTrustPath::create(),
Uuid::v4(),
'COSE_PUBLIC_KEY_BYTES',
'user-handle',
0,
null,
true,
false,
true,
);
}
public function test_it_exposes_the_serializer(): void
{
self::assertInstanceOf(SerializerInterface::class, $this->makeFactory()->serializer());
}
/**
* Only the `none` attestation format is registered — the deliberate choice
* recorded in `SECURITY.md`. Registering more formats would not make
* attestation verifiable; it would only accept statements nothing checks.
*/
public function test_only_none_attestation_is_supported(): void
{
$manager = $this->makeFactory()->attestationStatementSupportManager();
self::assertInstanceOf(AttestationStatementSupportManager::class, $manager);
self::assertTrue($manager->has('none'));
self::assertFalse($manager->has('packed'));
self::assertFalse($manager->has('fido-u2f'));
self::assertFalse($manager->has('tpm'));
self::assertFalse($manager->has('android-key'));
self::assertFalse($manager->has('apple'));
}
public function test_the_support_manager_has_the_none_support_registered(): void
{
$manager = $this->makeFactory()->attestationStatementSupportManager();
self::assertInstanceOf(
NoneAttestationStatementSupport::class,
$manager->get('none'),
);
}
public function test_credential_records_round_trip_through_storage(): void
{
$factory = $this->makeFactory();
$record = $this->makeRecord();
$restored = $factory->deserializeCredential($factory->serializeCredential($record));
self::assertNotNull($restored);
self::assertSame($record->publicKeyCredentialId, $restored->publicKeyCredentialId);
self::assertSame($record->credentialPublicKey, $restored->credentialPublicKey);
self::assertSame($record->userHandle, $restored->userHandle);
self::assertSame($record->counter, $restored->counter);
self::assertSame($record->transports, $restored->transports);
self::assertSame($record->attestationType, $restored->attestationType);
self::assertSame($record->backupEligible, $restored->backupEligible);
self::assertSame($record->backupStatus, $restored->backupStatus);
self::assertSame($record->uvInitialized, $restored->uvInitialized);
self::assertSame($record->aaguid->__toString(), $restored->aaguid->__toString());
}
/**
* A record is not JsonSerializable, so a plain json_encode() would silently
* produce something that cannot be read back. The factory must not rely on
* that path.
*/
public function test_credential_records_are_not_naively_json_encodable(): void
{
self::assertNotInstanceOf(JsonSerializable::class, $this->makeRecord());
}
/**
* Unreadable stored data degrades to null so a corrupt entry cannot produce
* a 500 on the login page.
*/
public function test_unreadable_stored_data_returns_null(): void
{
self::assertNull($this->makeFactory()->deserializeCredential('{not valid json'));
self::assertNull($this->makeFactory()->deserializeCredential(''));
self::assertNull($this->makeFactory()->deserializeCredential('{"unexpected":"shape"}'));
}
}
@@ -0,0 +1,293 @@
<?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));
}
}