fix(webauthn): add security-key hint so passkey managers defer

Even with authenticatorAttachment=cross-platform, Bitwarden's extension
intercepted navigator.credentials.create and offered to save a passkey. Add the
WebAuthn L3 'hints: [security-key]' to both registration and assertion options;
browsers and passkey managers that honor hints step aside and let the hardware
key prompt through. Injected into the client payload only (the session copy used
for server validation is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.22
boban 2026-06-19 21:30:52 +02:00
parent 5ebee9b4a5
commit c020af4c09
4 changed files with 27 additions and 3 deletions

View File

@ -13,6 +13,16 @@ 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.22] - 2026-06-19
### Behoben
- **Passwortmanager (Bitwarden) kaperte die Security-Key-Registrierung.** Trotz `cross-platform`
fing Bitwardens Browser-Erweiterung `navigator.credentials.create` ab und bot „Passkey speichern"
an. Die Optionen enthalten jetzt zusätzlich den **WebAuthn-L3-Hint `hints: ['security-key']`** (bei
Registrierung und Login), den moderne Browser/Manager respektieren und daher beiseite treten — der
YubiKey wird direkt abgefragt. (Ältere Bitwarden-Versionen, die den Hint nicht kennen, kapern
evtl. weiter — dann in Bitwarden „Nach Passkeys fragen" für die Seite/global deaktivieren.)
## [0.9.21] - 2026-06-19
### Behoben

View File

@ -100,7 +100,14 @@ class WebauthnService
$json = $this->serializer()->serialize($options, 'json');
session(['webauthn.register' => $json]);
return json_decode($json, true);
// WebAuthn L3 `hints`: tell the client to prefer a roaming HARDWARE security key. Modern
// browsers + passkey managers (Bitwarden, 1Password, …) that honor hints then step ASIDE
// and let the YubiKey prompt through instead of hijacking the ceremony to "save a passkey".
// Injected into the client payload only (not the session copy used for server validation).
$result = json_decode($json, true);
$result['hints'] = ['security-key'];
return $result;
}
public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential
@ -149,7 +156,11 @@ class WebauthnService
$json = $this->serializer()->serialize($options, 'json');
session(['webauthn.login' => $json]);
return json_decode($json, true);
// Same security-key hint on login, so the passkey manager doesn't intercept the assertion.
$result = json_decode($json, true);
$result['hints'] = ['security-key'];
return $result;
}
/** Validate an assertion against the user's stored credential. Never throws into the login path. */

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.21',
'version' => '0.9.22',
// 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

View File

@ -35,6 +35,8 @@ class WebauthnOptionsTest extends TestCase
$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'));
// WebAuthn L3 hint so passkey managers (Bitwarden) defer to the hardware key.
$this->assertSame(['security-key'], data_get($opts, 'hints'));
}
public function test_assertion_options_require_only_user_presence(): void
@ -44,6 +46,7 @@ class WebauthnOptionsTest extends TestCase
// discouraged = a single touch on login, no PIN/biometric prompt.
$this->assertSame('discouraged', data_get($opts, 'userVerification'));
$this->assertSame(['security-key'], data_get($opts, 'hints'));
}
public function test_assertion_options_list_user_credentials(): void