diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c5520..a31c47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.21] - 2026-06-19 + +### Behoben +- **Security-Key-Registrierung zielte nicht auf einen Hardware-Schlüssel** → der Browser bot das + Passkey-Speichern (z. B. Bitwarden) an statt den YubiKey abzufragen. Die Registrierung setzt jetzt + `authenticatorSelection`: **cross-platform** (externer/roaming Schlüssel statt Plattform-/ + Passwortmanager-Passkey), **residentKey discouraged** (nicht-auffindbarer Zweitfaktor — genau das, + was die Passkey-Manager-UI vermeidet) und **userVerification discouraged** (nur Anwesenheit = ein + Tippen, kein PIN/Biometrie). Login fragt den Schlüssel ebenfalls mit „discouraged" ab → **einstecken, + einmal tippen, fertig**. + ## [0.9.20] - 2026-06-19 ### Behoben diff --git a/app/Services/WebauthnService.php b/app/Services/WebauthnService.php index 0c84ec2..120d493 100644 --- a/app/Services/WebauthnService.php +++ b/app/Services/WebauthnService.php @@ -12,6 +12,7 @@ use Webauthn\AuthenticatorAssertionResponse; use Webauthn\AuthenticatorAssertionResponseValidator; use Webauthn\AuthenticatorAttestationResponse; use Webauthn\AuthenticatorAttestationResponseValidator; +use Webauthn\AuthenticatorSelectionCriteria; use Webauthn\CeremonyStep\CeremonyStepManagerFactory; use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\PublicKeyCredential; @@ -70,6 +71,19 @@ class WebauthnService ->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id))) ->all(); + // Target a roaming HARDWARE security key (YubiKey) used as a SECOND factor with a single + // touch — NOT a platform/password-manager passkey: + // - cross-platform → the browser asks for an external/roaming key (steers away from the + // OS/Bitwarden "save passkey" flow). + // - residentKey discouraged → a non-discoverable second-factor credential (a resident / + // discoverable credential is what triggers the passkey-manager UI). + // - userVerification discouraged → user PRESENCE only (one tap), no PIN/biometric. + $authenticatorSelection = AuthenticatorSelectionCriteria::create( + AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM, + AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_DISCOURAGED, + AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_DISCOURAGED, + ); + $options = PublicKeyCredentialCreationOptions::create( PublicKeyCredentialRpEntity::create(self::RP_NAME, $this->rpId()), PublicKeyCredentialUserEntity::create($user->email, (string) $user->id, $user->name), @@ -78,6 +92,7 @@ class WebauthnService PublicKeyCredentialParameters::createPk(-7), // ES256 PublicKeyCredentialParameters::createPk(-257), // RS256 ], + authenticatorSelection: $authenticatorSelection, attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, excludeCredentials: $exclude, ); @@ -126,7 +141,9 @@ class WebauthnService random_bytes(32), rpId: $this->rpId(), allowCredentials: $allow, - userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED, + // Single touch (user presence) — no PIN/biometric, matching the registration's + // discouraged userVerification, so login is: plug in → one tap → done. + userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_DISCOURAGED, ); $json = $this->serializer()->serialize($options, 'json'); diff --git a/config/clusev.php b/config/clusev.php index 0395f61..7dc84b8 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.20', + 'version' => '0.9.21', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/tests/Feature/WebauthnOptionsTest.php b/tests/Feature/WebauthnOptionsTest.php index 5f79b0b..6930c87 100644 --- a/tests/Feature/WebauthnOptionsTest.php +++ b/tests/Feature/WebauthnOptionsTest.php @@ -29,6 +29,21 @@ class WebauthnOptionsTest extends TestCase $this->assertSame('panel.example.com', data_get($opts, 'rp.id')); $this->assertNotEmpty(data_get($opts, 'challenge')); $this->assertNotNull(session('webauthn.register')); + + // Target a roaming hardware key (YubiKey) used as a one-touch second factor — not a + // platform/password-manager passkey. + $this->assertSame('cross-platform', data_get($opts, 'authenticatorSelection.authenticatorAttachment')); + $this->assertSame('discouraged', data_get($opts, 'authenticatorSelection.residentKey')); + $this->assertSame('discouraged', data_get($opts, 'authenticatorSelection.userVerification')); + } + + public function test_assertion_options_require_only_user_presence(): void + { + $user = User::factory()->create(); + $opts = $this->service()->assertionOptions($user); + + // discouraged = a single touch on login, no PIN/biometric prompt. + $this->assertSame('discouraged', data_get($opts, 'userVerification')); } public function test_assertion_options_list_user_credentials(): void