clusev/app/Services/WebauthnService.php

239 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Support\Webauthn\ZeroTolerantCounterChecker;
use Throwable;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AuthenticatorSelectionCriteria;
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 rpId — never a bare IP. Available when the
* panel is reached via the ACTIVE DOMAIN, which is always served over HTTPS through the front
* door (Caddy's own TLS or an external TLS proxy). Deliberately NOT keyed on
* request()->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<string, mixed> 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<string, mixed> 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://<rpId> 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, '-_', '+/'));
}
}