deployment->domain() !== null && request()->isSecure(); } /** 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(); $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 ], attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE, excludeCredentials: $exclude, ); $json = $this->serializer()->serialize($options, 'json'); session(['webauthn.register' => $json]); return json_decode($json, true); } 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, userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED, ); $json = $this->serializer()->serialize($options, 'json'); session(['webauthn.login' => $json]); return json_decode($json, true); } /** 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 already register ES256+RS256, NoneAttestation and a strict counter // checker; we only constrain the allowed origin to https://. $factory = new CeremonyStepManagerFactory; $factory->setAllowedOrigins(['https://'.$this->rpId()]); 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, '-_', '+/')); } }