Files
api/services/nginx/app/classes/webauthn.php
T
Jeppe B 2a6a86c9c3 Resolve backend Qodana critical and high findings (#314)
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.
2026-07-17 05:44:16 +02:00

288 lines
12 KiB
PHP

<?php
namespace classes;
use Exception;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Uid\Uuid;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\EmptyTrustPath;
use objects\passkeys_o;
use CBOR\Encoder;
class webauthn
{
private AuthenticatorAssertionResponseValidator $validator;
private SerializerInterface $serializer;
public function __construct()
{
$factory = new CeremonyStepManagerFactory();
// In a real scenario, we should set allowed origins.
// We use the $_SERVER['HTTP_ORIGIN'] or similar.
$rpId = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
$factory->setAllowedOrigins(['https://' . $rpId, 'http://' . $rpId]);
$this->validator = new AuthenticatorAssertionResponseValidator(
$factory->requestCeremony()
);
$attestationStatementSupportManager = new AttestationStatementSupportManager();
$serializerFactory = new WebauthnSerializerFactory($attestationStatementSupportManager);
$this->serializer = $serializerFactory->create();
}
/**
* Verify a WebAuthn assertion
*
* @param string $assertionJson The JSON string from the client
* @param string $challengeToken The hex challenge token stored in our DB
* @param passkeys_o $passkey The passkey object from our DB
* @param string $host The host (rpId) to verify against
* @return bool
*/
public function verifyAssertion(
string $assertionJson,
string $challengeToken,
passkeys_o $passkey,
string $host
): bool {
try {
$publicKeyCredential = $this->serializer->deserialize($assertionJson, PublicKeyCredential::class, 'json');
$response = $publicKeyCredential->response;
if (!$response instanceof AuthenticatorAssertionResponse) {
throw new Exception('Not an assertion response');
}
// Create PublicKeyCredentialSource from passkey object
$source = $this->createSourceFromObject($passkey);
// Prepare challenge (binary as expected by the WebAuthn lib)
$challenge = hex2bin($challengeToken);
// Create PublicKeyCredentialRequestOptions
$options = PublicKeyCredentialRequestOptions::create(
$challenge,
$host
);
// Check
$this->validator->check(
$source,
$response,
$options,
$host,
$source->userHandle
);
// If it didn't throw, it's valid. Update sign count.
// Note: validator updates source counter in v5
$passkey->sign_count->set($source->counter);
return true;
} catch (Exception $e) {
// Log error
$pk_hex = bin2hex($this->b64urlDecode((string)$passkey->public_key->value()));
error_log("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
throw new Exception("WebAuthn verification failed: " . $e->getMessage() . " (PK Hex: $pk_hex)");
} catch (ExceptionInterface $e) {
throw new Exception('Serialization error: ' . $e->getMessage());
}
}
private function createSourceFromObject(passkeys_o $passkey): PublicKeyCredentialSource
{
$transports = $passkey->transports->value() ?: [];
$credential_id_bin = $this->b64urlDecode((string)$passkey->credential_id->value());
$public_key_raw = $this->b64urlDecode((string)$passkey->public_key->value());
// Ensure the credential public key is in COSE (CBOR) format as expected by webauthn-lib
$credentialPublicKey = $this->ensureCosePublicKey($public_key_raw, (string)$passkey->algorithm->value());
return new PublicKeyCredentialSource(
$credential_id_bin, // publicKeyCredentialId (binary)
'public-key',
$transports,
'none', // attestationType
new EmptyTrustPath(),
Uuid::fromString('00000000-0000-0000-0000-000000000000'), // aaguid
$credentialPublicKey, // credentialPublicKey (COSE/CBOR binary)
(string)$passkey->user_id->value(), // userHandle
(int)$passkey->sign_count->value()
);
}
/**
* Base64URL decode with padding handling
*/
public function b64urlDecode(string $data): string
{
$b64 = strtr($data, '-_', '+/');
$pad = strlen($b64) % 4;
if ($pad) {
$b64 .= str_repeat('=', 4 - $pad);
}
$decoded = base64_decode($b64, true);
if ($decoded === false) {
// As a last resort, try non-strict decode
$decoded = base64_decode($b64, false);
}
return $decoded === false ? '' : $decoded;
}
/**
* Detects if the given data looks like a PEM public key (ASCII) or DER SPKI (ASN.1 sequence)
*/
private function looksLikePemOrDer(string $data): bool
{
if ($data === '') {
return false;
}
if (str_contains($data, '-----BEGIN')) {
return true;
}
// DER SEQUENCE usually starts with 0x30
return isset($data[0]) && ord($data[0]) === 0x30;
}
/**
* Convert PEM/DER EC public key to COSE EC2 key (CBOR binary). If already COSE, return as-is.
* Supports ES256/P-256. Other curves will throw.
*/
public function ensureCosePublicKey(string $data, string $algorithm): string
{
// If data already looks like COSE (CBOR map) with small leading major type 5 (map)
// we check if it is indeed a valid CBOR map with at least the 'kty' (1) key.
if ($data !== '' && (ord($data[0]) >= 0xA0 && ord($data[0]) <= 0xBF)) {
try {
$stream = new \Webauthn\StringStream($data);
$object = \CBOR\Decoder::create()->decode($stream);
if ($object instanceof \CBOR\Normalizable) {
$normalized = $object->normalize();
if (is_array($normalized)) {
if (isset($normalized[1])) {
return $data; // valid COSE map
}
// Some clients send the full attestation object instead of just the public key.
// We detect this by checking for 'authData' and 'fmt' keys in the CBOR map.
if (isset($normalized['authData']) && isset($normalized['fmt']) && is_string($normalized['authData'])) {
$authData = $normalized['authData'];
if (strlen($authData) >= 37) {
$flags = ord($authData[32]);
$offset = 37; // RP ID Hash(32) + Flags(1) + Counter(4)
if (($flags & 0x40) === 0x40) { // Attested credential data present
$offset += 16; // AAGUID
if (strlen($authData) >= $offset + 2) {
$l = (ord($authData[$offset]) << 8) | ord($authData[$offset + 1]);
$offset += 2 + $l; // Credential ID
if (strlen($authData) > $offset) {
$publicKeyData = substr($authData, $offset);
try {
// Extract just the public key (which is a CBOR object)
$pkStream = new \Webauthn\StringStream($publicKeyData);
$pkObject = \CBOR\Decoder::create()->decode($pkStream);
return (new Encoder())->encode($pkObject);
} catch (Exception $e) {
// Not a valid CBOR object at the expected offset; fall through
}
}
}
}
}
}
}
}
} catch (Exception $e) {
// Not a valid CBOR map or missing kty; proceed to PEM/DER checks
}
}
if (!$this->looksLikePemOrDer($data)) {
// Unknown binary; try returning as-is and let downstream throw if invalid.
return $data;
}
// Normalize to PEM string
$pem = $data;
if (!str_contains($pem, '-----BEGIN')) {
$pem = "-----BEGIN PUBLIC KEY-----\r\n" . chunk_split(base64_encode($data), 64, "\r\n") . "-----END PUBLIC KEY-----\r\n";
}
$key = @openssl_pkey_get_public($pem);
if ($key === false) {
throw new Exception('Invalid public key material (PEM/DER parse failed)');
}
$details = openssl_pkey_get_details($key);
if (!is_array($details) || !isset($details['type'])) {
throw new Exception('Could not extract public key details');
}
if ((int)$details['type'] !== OPENSSL_KEYTYPE_EC) {
throw new Exception('Unsupported key type for WebAuthn (expected EC)');
}
$curve = $details['ec']['curve_name'] ?? null;
$point = $details['ec']['public_key'] ?? null;
$x = $details['ec']['x'] ?? null;
$y = $details['ec']['y'] ?? null;
if ($x === null || $y === null) {
// Try to parse uncompressed point (0x04 || X || Y)
if (!is_string($point) || $point === '' || $point[0] !== "\x04") {
throw new Exception('Unable to extract EC public key coordinates');
}
$coordLen = (strlen($point) - 1) / 2;
if ($coordLen <= 0) {
throw new Exception('Invalid EC public key point');
}
$x = substr($point, 1, (int)$coordLen);
$y = substr($point, 1 + (int)$coordLen, (int)$coordLen);
}
// Map curve to COSE crv id
$crv = 1; // default P-256
if (is_string($curve)) {
$lc = strtolower($curve);
if (str_contains($lc, 'secp256') || str_contains($lc, 'prime256') || str_contains($lc, 'p-256')) {
$crv = 1; // P-256
} elseif (str_contains($lc, 'secp384') || str_contains($lc, 'p-384')) {
$crv = 2; // P-384
} elseif (str_contains($lc, 'secp521') || str_contains($lc, 'p-521')) {
$crv = 3; // P-521
} else {
throw new Exception('Unsupported EC curve: ' . $curve);
}
}
// Map algorithm string to COSE alg id (defaults to ES256)
$alg = -7; // ES256
$algStr = strtoupper((string)$algorithm);
if ($algStr === 'ES384') { $alg = -35; }
elseif ($algStr === 'ES512') { $alg = -36; }
// Build COSE key map and CBOR-encode it
$cose = [
1 => 2, // kty: EC2
3 => $alg, // alg
-1 => $crv, // crv
-2 => $x, // x-coordinate (bytes)
-3 => $y, // y-coordinate (bytes)
];
$encoder = new Encoder();
return $encoder->encode($cose);
}
}