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.
121 lines
4.3 KiB
PHP
121 lines
4.3 KiB
PHP
<?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"}'));
|
|
}
|
|
}
|