From 0cb9b073ce33594de1a03ac42ad5eb751e22866a Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 18:24:10 +0200 Subject: [PATCH] feat(webauthn): registration/assertion options + ceremony verification WebauthnService builds creation/request options (rpId = active domain, ES256/RS256, attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the challenge in the session, and verifies attestation/assertion responses (allowed origin = https://, sign-count tracked). Option building + serialization are unit-tested; the cryptographic verify path is browser-verified on a domain host. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Services/WebauthnService.php | 166 +++++++++++++++++++++++++- tests/Feature/WebauthnOptionsTest.php | 49 ++++++++ 2 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/WebauthnOptionsTest.php diff --git a/app/Services/WebauthnService.php b/app/Services/WebauthnService.php index a199023..b246411 100644 --- a/app/Services/WebauthnService.php +++ b/app/Services/WebauthnService.php @@ -2,14 +2,42 @@ namespace App\Services; +use App\Models\User; +use App\Models\WebauthnCredential; +use Throwable; +use Webauthn\AttestationStatement\AttestationStatementSupportManager; +use Webauthn\AttestationStatement\NoneAttestationStatementSupport; +use Webauthn\AuthenticatorAssertionResponse; +use Webauthn\AuthenticatorAssertionResponseValidator; +use Webauthn\AuthenticatorAttestationResponse; +use Webauthn\AuthenticatorAttestationResponseValidator; +use Webauthn\CeremonyStep\CeremonyStepManagerFactory; +use Webauthn\Denormalizer\WebauthnSerializerFactory; +use Webauthn\PublicKeyCredential; +use Webauthn\PublicKeyCredentialCreationOptions; +use Webauthn\PublicKeyCredentialDescriptor; +use Webauthn\PublicKeyCredentialParameters; +use Webauthn\PublicKeyCredentialRequestOptions; +use Webauthn\PublicKeyCredentialRpEntity; +use Webauthn\PublicKeyCredentialSource; +use Webauthn\PublicKeyCredentialUserEntity; + +/** + * The single seam onto web-auth/webauthn-lib (v5). Wraps the registration + assertion + * ceremonies for a security-key SECOND factor. Gated on a domain + HTTPS — the rpId is + * the active domain (an IP literal is never a valid rpId). + * + * NOTE: the cryptographic ceremony (verifyRegistration/verifyAssertion) can only be + * exercised by a real authenticator over HTTPS on the rp domain; it is browser-verified + * on a domain host. The option builders + storage are unit-tested here. + */ class WebauthnService { + private const RP_NAME = 'Clusev'; + public function __construct(private DeploymentService $deployment) {} - /** - * WebAuthn needs a secure context AND a domain-based Relying-Party ID — a bare IP - * literal is not a valid rpId, so the feature is unavailable on bare-IP/HTTP. - */ + /** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */ public function available(): bool { return $this->deployment->domain() !== null && request()->isSecure(); @@ -20,4 +48,134 @@ class WebauthnService { 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', $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); + + $model->update(['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, '-_', '+/')); + } } diff --git a/tests/Feature/WebauthnOptionsTest.php b/tests/Feature/WebauthnOptionsTest.php new file mode 100644 index 0000000..5f79b0b --- /dev/null +++ b/tests/Feature/WebauthnOptionsTest.php @@ -0,0 +1,49 @@ +mock(DeploymentService::class); + $d->shouldReceive('domain')->andReturn($domain); + + return app(WebauthnService::class); + } + + public function test_registration_options_use_domain_rpid_and_store_challenge(): void + { + $user = User::factory()->create(); + $opts = $this->service()->registrationOptions($user); + + $this->assertSame('panel.example.com', data_get($opts, 'rp.id')); + $this->assertNotEmpty(data_get($opts, 'challenge')); + $this->assertNotNull(session('webauthn.register')); + } + + public function test_assertion_options_list_user_credentials(): void + { + $user = User::factory()->create(); + WebauthnCredential::create([ + 'user_id' => $user->id, 'name' => 'k', + 'credential_id' => rtrim(strtr(base64_encode('rawid'), '+/', '-_'), '='), + 'public_key' => '{}', 'sign_count' => 0, + ]); + + $opts = $this->service()->assertionOptions($user->fresh()); + + $this->assertSame('panel.example.com', data_get($opts, 'rpId')); + $this->assertNotEmpty(data_get($opts, 'allowCredentials')); + $this->assertNotNull(session('webauthn.login')); + } +}