fix(webauthn): target a hardware security key (YubiKey), one-touch second factor
Registration set no authenticatorSelection, so the browser offered the passkey-manager 'save passkey' flow (Bitwarden) instead of prompting for the YubiKey. Now request a cross-platform, non-resident, presence-only credential: authenticatorAttachment=cross-platform (roaming key, not a platform/manager passkey), residentKey=discouraged (non-discoverable second factor — what keeps passkey managers out), userVerification=discouraged (single touch, no PIN). Login assertion also uses discouraged so it is: insert key, one tap, done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation v0.9.21
parent
5abf984df2
commit
5ebee9b4a5
11
CHANGELOG.md
11
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._
|
_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
|
## [0.9.20] - 2026-06-19
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Webauthn\AuthenticatorAssertionResponse;
|
||||||
use Webauthn\AuthenticatorAssertionResponseValidator;
|
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||||
use Webauthn\AuthenticatorAttestationResponse;
|
use Webauthn\AuthenticatorAttestationResponse;
|
||||||
use Webauthn\AuthenticatorAttestationResponseValidator;
|
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||||
|
use Webauthn\AuthenticatorSelectionCriteria;
|
||||||
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
||||||
use Webauthn\Denormalizer\WebauthnSerializerFactory;
|
use Webauthn\Denormalizer\WebauthnSerializerFactory;
|
||||||
use Webauthn\PublicKeyCredential;
|
use Webauthn\PublicKeyCredential;
|
||||||
|
|
@ -70,6 +71,19 @@ class WebauthnService
|
||||||
->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
|
->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id)))
|
||||||
->all();
|
->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(
|
$options = PublicKeyCredentialCreationOptions::create(
|
||||||
PublicKeyCredentialRpEntity::create(self::RP_NAME, $this->rpId()),
|
PublicKeyCredentialRpEntity::create(self::RP_NAME, $this->rpId()),
|
||||||
PublicKeyCredentialUserEntity::create($user->email, (string) $user->id, $user->name),
|
PublicKeyCredentialUserEntity::create($user->email, (string) $user->id, $user->name),
|
||||||
|
|
@ -78,6 +92,7 @@ class WebauthnService
|
||||||
PublicKeyCredentialParameters::createPk(-7), // ES256
|
PublicKeyCredentialParameters::createPk(-7), // ES256
|
||||||
PublicKeyCredentialParameters::createPk(-257), // RS256
|
PublicKeyCredentialParameters::createPk(-257), // RS256
|
||||||
],
|
],
|
||||||
|
authenticatorSelection: $authenticatorSelection,
|
||||||
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
||||||
excludeCredentials: $exclude,
|
excludeCredentials: $exclude,
|
||||||
);
|
);
|
||||||
|
|
@ -126,7 +141,9 @@ class WebauthnService
|
||||||
random_bytes(32),
|
random_bytes(32),
|
||||||
rpId: $this->rpId(),
|
rpId: $this->rpId(),
|
||||||
allowCredentials: $allow,
|
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');
|
$json = $this->serializer()->serialize($options, 'json');
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// First tagged release is v0.1.0 (semantic, not -dev).
|
// 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
|
// 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
|
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,21 @@ class WebauthnOptionsTest extends TestCase
|
||||||
$this->assertSame('panel.example.com', data_get($opts, 'rp.id'));
|
$this->assertSame('panel.example.com', data_get($opts, 'rp.id'));
|
||||||
$this->assertNotEmpty(data_get($opts, 'challenge'));
|
$this->assertNotEmpty(data_get($opts, 'challenge'));
|
||||||
$this->assertNotNull(session('webauthn.register'));
|
$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
|
public function test_assertion_options_list_user_credentials(): void
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue