Laragear/WebAuthn

View on GitHub
src/Attestation/AuthenticatorData.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php

namespace Laragear\WebAuthn\Attestation;

use Laragear\WebAuthn\ByteBuffer;
use Laragear\WebAuthn\CborDecoder;
use Laragear\WebAuthn\Exceptions\DataException;
use function base64_encode;
use function chr;
use function chunk_split;
use function intdiv;
use function is_array;
use function ord;
use function strlen;
use function substr;
use function unpack;

/**
 * MIT License
 *
 * Copyright (c) 2021 Lukas Buchs
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is furnished
 * to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * ---
 *
 * This file has been modernized to fit Laravel.
 *
 * @author Lukas Buchs
 * @internal
 *
 * DER = Distinguished Encoding Rules;
 * PEM = Privacy Enhanced Mail, basically BASE64 encoded DER.
 */
class AuthenticatorData
{
    // COSE encoded keys
    protected static int $COSE_KTY = 1;
    protected static int $COSE_ALG = 3;

    // COSE EC2 ES256 P-256 curve
    protected static int $COSE_CRV = -1;
    protected static int $COSE_X = -2;
    protected static int $COSE_Y = -3;

    // COSE RSA PS256
    protected static int $COSE_N = -1;
    protected static int $COSE_E = -2;

    protected static int $EC2_TYPE = 2;
    protected static int $EC2_ES256 = -7;
    protected static int $EC2_P256 = 1;

    protected static int $RSA_TYPE = 3;
    protected static int $RSA_RS256 = -257;

    /**
     * Creates a new Authenticator Data instance from a binary string.
     *
     * @param  string  $relyingPartyIdHash
     * @param  object  $flags
     * @param  int  $counter
     * @param  object  $attestedCredentialData
     * @param  array  $extensionData
     */
    final public function __construct(
        public string $relyingPartyIdHash,
        public object $flags,
        public int $counter,
        public object $attestedCredentialData,
        public array $extensionData,
    )
    {
        //
    }


    /**
     * Checks if the Relying Party ID hash is the same as the one issued.
     *
     * @param  string  $relyingPartyId
     * @param  bool  $hash
     * @return bool
     */
    public function hasSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
    {
        if ($hash) {
            $relyingPartyId = hash('sha256', $relyingPartyId, true);
        }

        return hash_equals($relyingPartyId, $this->relyingPartyIdHash);
    }

    /**
     * Checks if the Relying Party ID hash is not the same as the one issued.
     *
     * @param  string  $relyingPartyId
     * @param  bool  $hash
     * @return bool
     */
    public function hasNotSameRPIdHash(string $relyingPartyId, bool $hash = true): bool
    {
        return ! $this->hasSameRPIdHash($relyingPartyId, $hash);
    }

    /**
     * Check if the user was present during the authentication.
     *
     * @return bool
     */
    public function wasUserPresent(): bool
    {
        return $this->flags->userPresent;
    }

    /**
     * Check if the user was absent during the authentication.
     *
     * @return bool
     */
    public function wasUserAbsent(): bool
    {
        return ! $this->wasUserPresent();
    }

    /**
     * Check if the user was actively verified by the authenticator.
     *
     * @return bool
     */
    public function wasUserVerified(): bool
    {
        return $this->flags->userVerified;
    }

    /**
     * Check if the user was not actively verified by the authenticator.
     *
     * @return bool
     */
    public function wasUserNotVerified(): bool
    {
        return ! $this->wasUserVerified();
    }

    /**
     * Returns the public key in PEM format.
     *
     * @return string
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    public function getPublicKeyPem(): string
    {
        $der = match ($this->attestedCredentialData->credentialPublicKey->kty) {
            self::$EC2_TYPE => $this->getEc2Der(),
            self::$RSA_TYPE => $this->getRsaDer(),
            default => throw new DataException('Invalid credential public key type [kty].'),
        };

        $pem = '-----BEGIN PUBLIC KEY-----'."\n";
        $pem .= chunk_split(base64_encode($der), 64, "\n");
        $pem .= '-----END PUBLIC KEY-----'."\n";

        return $pem;
    }

    /**
     * Returns the public key in U2F format.
     *
     * @return string
     */
    public function getPublicKeyU2F(): string
    {
        return "\x04". // ECC uncompressed
            $this->attestedCredentialData->credentialPublicKey->x.
            $this->attestedCredentialData->credentialPublicKey->y;
    }

    /**
     * Returns DER encoded EC2 key
     *
     * @return string
     */
    protected function getEc2Der(): string
    {
        return $this->derSequence(
            $this->derSequence(
                $this->derOid("\x2A\x86\x48\xCE\x3D\x02\x01"). // OID 1.2.840.10045.2.1 ecPublicKey
                $this->derOid("\x2A\x86\x48\xCE\x3D\x03\x01\x07")  // 1.2.840.10045.3.1.7 prime256v1
            ).
            $this->derBitString($this->getPublicKeyU2F())
        );
    }

    /**
     * Returns DER encoded RSA key.
     *
     * @return string
     */
    protected function getRsaDer(): string
    {
        return $this->derSequence(
            $this->derSequence(
                $this->derOid("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01"). // OID 1.2.840.113549.1.1.1 rsaEncryption
                $this->derNullValue()
            ).
            $this->derBitString(
                $this->derSequence(
                    $this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->n).
                    $this->derUnsignedInteger($this->attestedCredentialData->credentialPublicKey->e)
                )
            )
        );
    }

    /**
     * Returns the length of a DER encoded string.
     *
     * @param  int  $der
     * @return string
     */
    protected function derLength(int $der): string
    {
        if ($der < 128) {
            return chr($der);
        }

        $lenBytes = '';

        while ($der > 0) {
            $lenBytes = chr($der % 256).$lenBytes;
            $der = intdiv($der, 256);
        }

        return chr(0x80 | strlen($lenBytes)).$lenBytes;
    }

    /**
     * Encode a string as DER.
     *
     * @param  string  $contents
     * @return string
     */
    protected function derSequence(string $contents): string
    {
        return "\x30".$this->derLength(strlen($contents)).$contents;
    }

    /**
     * Encode something an ID of zero as DER.
     *
     * @param  string  $encoded
     * @return string
     */
    protected function derOid(string $encoded): string
    {
        return "\x06".$this->derLength(strlen($encoded)).$encoded;
    }

    /**
     * Encode the bit string as DER.
     *
     * @param  string  $bytes
     * @return string
     */
    protected function derBitString(string $bytes): string
    {
        return "\x03".$this->derLength(strlen($bytes) + 1)."\x00".$bytes;
    }

    /**
     * Encode a null value as DER.
     *
     * @return string
     */
    protected function derNullValue(): string
    {
        return "\x05\x00";
    }

    /**
     * Encode a unsigned integer as DER.
     *
     * @param  string  $bytes
     * @return string
     */
    protected function derUnsignedInteger(string $bytes): string
    {
        $len = strlen($bytes);

        // Remove leading zero bytes
        for ($i = 0; $i < ($len - 1); $i++) {
            if (ord($bytes[$i]) !== 0) {
                break;
            }
        }
        if ($i !== 0) {
            $bytes = substr($bytes, $i);
        }

        // If most significant bit is set, prefix with another zero to prevent it being seen as negative number
        if ((ord($bytes[0]) & 0x80) !== 0) {
            $bytes = "\x00".$bytes;
        }

        return "\x02".$this->derLength(strlen($bytes)).$bytes;
    }

    /**
     * Create a new Authenticator data from a binary string.
     *
     * @param  string  $binary
     * @return static
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     * @codeCoverageIgnore
     */
    public static function fromBinary(string $binary): static
    {
        if (strlen($binary) < 37) {
            throw new DataException('Authenticator Data: Invalid input.');
        }

        $relyingPartyIdHash = substr($binary, 0, 32);

        // flags (1 byte)
        $flags = static::readFlags(unpack('Cflags', $binary[32])['flags']);

        // signature counter: 32-bit unsigned big-endian integer.
        $counter = unpack('Nsigncount', substr($binary, 33, 4))['signcount'];

        $offset = 37;

        $attestedCredentialData = $flags->attestedDataIncluded
            ? static::readAttestData($binary, $offset)
            : (object) null;

        $extensionData = $flags->extensionDataIncluded
            ? static::readExtensionData(substr($binary, $offset))
            : [];

        return new static($relyingPartyIdHash, $flags, $counter, $attestedCredentialData, $extensionData);
    }

    /**
     * Reads the flags from flag byte array.
     *
     * @param  string  $binFlag
     * @return object{userPresent: bool, userVerified: bool, attestedDataIncluded: bool, extensionDataIncluded: bool}
     */
    protected static function readFlags(string $binFlag): object
    {
        $flags = (object) [
            // @phpstan-ignore-next-line
            'bit_0' => (bool) ($binFlag & 1),
            // @phpstan-ignore-next-line
            'bit_1' => (bool) ($binFlag & 2),
            // @phpstan-ignore-next-line
            'bit_2' => (bool) ($binFlag & 4),
            // @phpstan-ignore-next-line
            'bit_3' => (bool) ($binFlag & 8),
            // @phpstan-ignore-next-line
            'bit_4' => (bool) ($binFlag & 16),
            // @phpstan-ignore-next-line
            'bit_5' => (bool) ($binFlag & 32),
            // @phpstan-ignore-next-line
            'bit_6' => (bool) ($binFlag & 64),
            // @phpstan-ignore-next-line
            'bit_7' => (bool) ($binFlag & 128),
            'userPresent' => false,
            'userVerified' => false,
            'attestedDataIncluded' => false,
            'extensionDataIncluded' => false,
        ];

        // named flags
        $flags->userPresent = $flags->bit_0;
        $flags->userVerified = $flags->bit_2;
        $flags->attestedDataIncluded = $flags->bit_6;
        $flags->extensionDataIncluded = $flags->bit_7;

        return $flags;
    }

    /**
     * Reads the attestation data.
     *
     * @param  string  $binary
     * @param  int  $endOffset
     * @return object{aaguid: string, credentialId: \Laragear\WebAuthn\ByteBuffer, credentialPublicKey: object}&\stdClass
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    protected static function readAttestData(string $binary, int &$endOffset): object
    {
        if (strlen($binary) <= 55) {
            throw new DataException('Attested data is missing');
        }

        // Byte length L of Credential ID, 16-bit unsigned big-endian integer.
        $length = unpack('nlength', substr($binary, 53, 2))['length'];

        // Set end offset
        $endOffset = 55 + $length;

        return (object) [
            'aaguid' => substr($binary, 37, 16),
            'credentialId' => new ByteBuffer(substr($binary, 55, $length)),
            'credentialPublicKey' => static::readCredentialPublicKey($binary, 55 + $length, $endOffset)
        ];
    }

    /**
     * Read COSE key-encoded elliptic curve public key in EC2 format.
     *
     * @param  string  $binary
     * @param  int  $offset
     * @param  int  $endOffset
     * @return object
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    protected static function readCredentialPublicKey(string $binary, int $offset, int &$endOffset): object
    {
        $enc = CborDecoder::decodePortion($binary, $offset, $endOffset);

        // COSE key-encoded elliptic curve public key in EC2 format
        $publicKey = (object) [
            'kty' => $enc[static::$COSE_KTY],
            'alg' => $enc[static::$COSE_ALG]
        ];

        switch ($publicKey->alg) {
            case static::$EC2_ES256:
                static::readCredentialPublicKeyES256($publicKey, $enc);
                break;
            case static::$RSA_RS256:
                static::readCredentialPublicKeyRS256($publicKey, $enc);
                break;
        }

        return $publicKey;
    }

    /**
     * Extracts ES256 information from COSE encoding.
     *
     * @param  object  $publicKey
     * @param  array  $cose
     * @return object
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    protected static function readCredentialPublicKeyES256(object $publicKey, array $cose): object
    {
        $publicKey->crv = $cose[self::$COSE_CRV];
        $publicKey->x = $cose[self::$COSE_X] instanceof ByteBuffer ? $cose[self::$COSE_X]->getBinaryString() : null;
        $publicKey->y = $cose[self::$COSE_Y] instanceof ByteBuffer ? $cose[self::$COSE_Y]->getBinaryString() : null;

        if ($publicKey->kty !== self::$EC2_TYPE) {
            throw new DataException('Public key not in EC2 format');
        }

        if ($publicKey->alg !== self::$EC2_ES256) {
            throw new DataException('Signature algorithm not ES256');
        }

        if ($publicKey->crv !== self::$EC2_P256) {
            throw new DataException('Curve not P-256');
        }

        if (strlen($publicKey->x) !== 32) {
            throw new DataException('Invalid X-coordinate');
        }

        if (strlen($publicKey->y) !== 32) {
            throw new DataException('Invalid Y-coordinate');
        }

        return $publicKey;
    }

    /**
     * Extract RS256 information from COSE.
     *
     * @param  object  $publicKey
     * @param  array  $enc
     * @return void
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    protected static function readCredentialPublicKeyRS256(object $publicKey, array $enc): void
    {
        $publicKey->n = $enc[self::$COSE_N] instanceof ByteBuffer ? $enc[self::$COSE_N]->getBinaryString() : null;
        $publicKey->e = $enc[self::$COSE_E] instanceof ByteBuffer ? $enc[self::$COSE_E]->getBinaryString() : null;

        if ($publicKey->kty !== self::$RSA_TYPE) {
            throw new DataException('Public key not in RSA format');
        }

        if ($publicKey->alg !== self::$RSA_RS256) {
            throw new DataException('Signature algorithm not ES256');
        }

        if (strlen($publicKey->n) !== 256) {
            throw new DataException('Invalid RSA modulus');
        }

        if (strlen($publicKey->e) !== 3) {
            throw new DataException('Invalid RSA public exponent');
        }
    }

    /**
     * Reads CBOR encoded extension data.
     *
     * @param  string  $binary
     * @return array<int, string>
     * @throws \Laragear\WebAuthn\Exceptions\DataException
     */
    protected static function readExtensionData(string $binary): array
    {
        $ext = CborDecoder::decode($binary);

        return is_array($ext) ? $ext : throw new DataException('Invalid extension data');
    }
}