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