isSecure(): behind an external TLS proxy the forwarded scheme may not be trusted * here, so the request looks like plain HTTP even though the browser context is secure — that * would wrongly lock out hardware keys. The bare-IP recovery host (http, no secure context) * is excluded by the host check. */ public function available(): bool { $domain = $this->deployment->domain(); $request = request(); return $domain !== null && $request !== null && strtolower($request->getHost()) === $domain; } /** The Relying-Party ID = the active domain (asserted present by available()). */ public function rpId(): string { return (string) $this->deployment->domain(); } /** @return array JSON-ready creation options; challenge stored in session. */ public function registrationOptions(User $user): array { $exclude = $user->webauthnCredentials ->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), random_bytes(32), pubKeyCredParams: [ PublicKeyCredentialParameters::createPk(-7), // ES256 PublicKeyCredentialParameters::createPk(-257), // RS256 ], authenticatorSelection: $authenticatorSelection, attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, excludeCredentials: $exclude, ); $json = $this->serializer()->serialize($options, 'json'); session(['webauthn.register' => $json]); // 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 { $serializer = $this->serializer(); $options = $serializer->deserialize((string) session('webauthn.register'), PublicKeyCredentialCreationOptions::class, 'json'); $credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json'); $attestation = $credential->response; abort_unless($attestation instanceof AuthenticatorAttestationResponse, 422); $validator = AuthenticatorAttestationResponseValidator::create($this->ceremony()->creationCeremony()); $record = $validator->check($attestation, $options, $this->rpId()); $source = PublicKeyCredentialSource::fromCredentialRecord($record); $cred = $user->webauthnCredentials()->create([ 'name' => $name, 'credential_id' => $this->toB64u($source->publicKeyCredentialId), 'public_key' => $serializer->serialize($source, 'json'), 'aaguid' => $record->aaguid->__toString(), 'transports' => $record->transports, 'sign_count' => $record->counter, ]); session()->forget('webauthn.register'); return $cred; } /** @return array JSON-ready request options; challenge stored in session. */ public function assertionOptions(User $user): array { $allow = $user->webauthnCredentials ->map(fn (WebauthnCredential $c) => PublicKeyCredentialDescriptor::create('public-key', $this->fromB64u($c->credential_id))) ->all(); $options = PublicKeyCredentialRequestOptions::create( random_bytes(32), rpId: $this->rpId(), allowCredentials: $allow, // 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'); session(['webauthn.login' => $json]); // 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. */ public function verifyAssertion(User $user, array $response): bool { try { $serializer = $this->serializer(); $credential = $serializer->deserialize(json_encode($response), PublicKeyCredential::class, 'json'); $assertion = $credential->response; if (! $assertion instanceof AuthenticatorAssertionResponse) { return false; } $model = $user->webauthnCredentials() ->where('credential_id_hash', hash('sha256', $this->toB64u($credential->rawId))) ->first(); if (! $model) { return false; } $source = $serializer->deserialize($model->public_key, PublicKeyCredentialSource::class, 'json'); $options = $serializer->deserialize((string) session('webauthn.login'), PublicKeyCredentialRequestOptions::class, 'json'); $validator = AuthenticatorAssertionResponseValidator::create($this->ceremony()->requestCeremony()); $updated = $validator->check($source, $assertion, $options, $this->rpId(), (string) $user->id); // Re-serialize the UPDATED record so the new sign counter is also embedded in // public_key — otherwise the next assertion would validate against the stale // registration-time counter and clone/rollback detection would never fire. $updatedSource = $updated instanceof PublicKeyCredentialSource ? $updated : PublicKeyCredentialSource::fromCredentialRecord($updated); $model->update([ 'public_key' => $serializer->serialize($updatedSource, 'json'), 'sign_count' => $updated->counter, 'last_used_at' => now(), ]); session()->forget('webauthn.login'); return true; } catch (Throwable) { return false; } } private function serializer() { return (new WebauthnSerializerFactory( AttestationStatementSupportManager::create([new NoneAttestationStatementSupport]), ))->create(); } private function ceremony(): CeremonyStepManagerFactory { // Defaults register ES256+RS256 + NoneAttestation; constrain the origin to // https:// and swap the strict counter checker for a zero-tolerant one // (platform authenticators / passkeys always report counter 0). $factory = new CeremonyStepManagerFactory; $factory->setAllowedOrigins(['https://'.$this->rpId()]); $factory->setCounterChecker(new ZeroTolerantCounterChecker); return $factory; } private function toB64u(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } private function fromB64u(string $s): string { return (string) base64_decode(strtr($s, '-_', '+/')); } }