diff --git a/src/Data/PasskeyCredential.php b/src/Data/PasskeyCredential.php new file mode 100644 index 0000000..f82a776 --- /dev/null +++ b/src/Data/PasskeyCredential.php @@ -0,0 +1,49 @@ +identity, + $this->label, + $this->createdAt, + $usedAt, + ); + } +} diff --git a/src/Service/PasskeyCeremonyFactory.php b/src/Service/PasskeyCeremonyFactory.php new file mode 100644 index 0000000..a050da2 --- /dev/null +++ b/src/Service/PasskeyCeremonyFactory.php @@ -0,0 +1,86 @@ +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; + } + } +} diff --git a/src/Service/PasskeyCredentialStore.php b/src/Service/PasskeyCredentialStore.php new file mode 100644 index 0000000..5a4e7f2 --- /dev/null +++ b/src/Service/PasskeyCredentialStore.php @@ -0,0 +1,266 @@ + -> 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 + * + * @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 $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); + } +} diff --git a/src/Service/PasskeyCredentialStoreInterface.php b/src/Service/PasskeyCredentialStoreInterface.php new file mode 100644 index 0000000..8589a63 --- /dev/null +++ b/src/Service/PasskeyCredentialStoreInterface.php @@ -0,0 +1,60 @@ +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"}')); + } +} diff --git a/tests/Unit/Service/PasskeyCredentialStoreTest.php b/tests/Unit/Service/PasskeyCredentialStoreTest.php new file mode 100644 index 0000000..aab402d --- /dev/null +++ b/tests/Unit/Service/PasskeyCredentialStoreTest.php @@ -0,0 +1,293 @@ +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)); + } +}