Enhance WebAuthn handling and error management
- Improve user verification in `authentication.php` by adding a check for user existence and throwing meaningful exceptions for missing users. - Refactor `webauthn.php` to handle Base64URL decoding and COSE key normalization for consistent WebAuthn library compatibility. - Extend error logging with additional context for debugging (e.g., public key hex representation). - Add utility functions for Base64URL decoding and checking PEM/DER format. - Update `passkeysRoute.php` to normalize public keys and handle errors gracefully during WebAuthn workflows.
This commit is contained in:
@@ -74,8 +74,12 @@ class authentication implements authentication_i
|
||||
{
|
||||
// Create a token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
// Get the user id
|
||||
$user_id = (new users_o())->getUserByCustomerNumber($customer_number)->id;
|
||||
// Resolve the user by customer number and ensure it exists to avoid accessing an uninitialized typed property
|
||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
if (!$user->exists()) {
|
||||
throw new \Exception('User not found for customer number: ' . $customer_number);
|
||||
}
|
||||
$user_id = $user->id;
|
||||
// Save the token in the database
|
||||
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
||||
return $token;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use Symfony\Component\Serializer\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||
@@ -15,6 +16,7 @@ use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
use objects\passkeys_o;
|
||||
use CBOR\Encoder;
|
||||
|
||||
class webauthn
|
||||
{
|
||||
@@ -66,9 +68,8 @@ class webauthn
|
||||
// Create PublicKeyCredentialSource from passkey object
|
||||
$source = $this->createSourceFromObject($passkey);
|
||||
|
||||
// Prepare challenge (Base64URL without padding)
|
||||
$rawChallenge = hex2bin($challengeToken);
|
||||
$challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '=');
|
||||
// Prepare challenge (binary as expected by the WebAuthn lib)
|
||||
$challenge = hex2bin($challengeToken);
|
||||
|
||||
// Create PublicKeyCredentialRequestOptions
|
||||
$options = PublicKeyCredentialRequestOptions::create(
|
||||
@@ -92,18 +93,24 @@ class webauthn
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
// Log error
|
||||
error_log('WebAuthn verification failed: ' . $e->getMessage());
|
||||
$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)");
|
||||
return false;
|
||||
} catch (ExceptionInterface $e) {
|
||||
throw new Exception('Serialization error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function createSourceFromObject(passkeys_o $passkey): PublicKeyCredentialSource
|
||||
{
|
||||
$transports = $passkey->transports->value() ?: [];
|
||||
|
||||
// Decode Base64URL credential ID and public key to binary as expected by the WebAuthn lib
|
||||
$credential_id_bin = base64_decode(strtr($passkey->credential_id->value(), '-_', '+/'));
|
||||
$public_key_bin = base64_decode(strtr($passkey->public_key->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)
|
||||
@@ -112,9 +119,170 @@ class webauthn
|
||||
'none', // attestationType
|
||||
new EmptyTrustPath(),
|
||||
Uuid::fromString('00000000-0000-0000-0000-000000000000'), // aaguid
|
||||
$public_key_bin, // credentialPublicKey (binary)
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +68,26 @@ class passkeysRoute
|
||||
self::requireMinLength('credential_id', 16);
|
||||
self::requireMaxLength('credential_id', 4096);
|
||||
|
||||
$public_key = (string)self::getParameter('public_key');
|
||||
self::requireType($public_key, self::type_string());
|
||||
self::requireMinLength('public_key', 32);
|
||||
self::requireMaxLength('public_key', 8192);
|
||||
|
||||
$algorithm = (string)self::getParameter('algorithm');
|
||||
self::requireType($algorithm, self::type_string());
|
||||
self::requireMinLength('algorithm', 3);
|
||||
self::requireMaxLength('algorithm', 32);
|
||||
|
||||
$public_key = (string)self::getParameter('public_key');
|
||||
self::requireType($public_key, self::type_string());
|
||||
self::requireMinLength('public_key', 32);
|
||||
self::requireMaxLength('public_key', 16384);
|
||||
|
||||
// Normalize public key to COSE (some clients send full attestation object instead of just the key)
|
||||
try {
|
||||
$wa = new \classes\webauthn();
|
||||
$decoded_pk = $wa->b64urlDecode($public_key);
|
||||
$normalized_pk = $wa->ensureCosePublicKey($decoded_pk, $algorithm);
|
||||
$public_key = rtrim(strtr(base64_encode($normalized_pk), '+/', '-_'), '=');
|
||||
} catch (\Exception $e) {
|
||||
// If normalization fails, we continue with original public_key and let verification handle it later if possible
|
||||
}
|
||||
|
||||
$transports = self::getParameter('transports');
|
||||
self::requireType($transports, self::TYPE_ARRAY());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user