First release

This commit is contained in:
Italo
2022-06-14 05:17:04 -04:00
commit b60b829b96
119 changed files with 9412 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<?php
namespace Laragear\WebAuthn\Attestation;
use Laragear\WebAuthn\Attestation\Formats\Format;
/**
* @internal
*/
class AttestationObject
{
/**
* Create a new Attestation Object.
*
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
* @param \Laragear\WebAuthn\Attestation\Formats\Format $format
* @param string $formatName
*/
public function __construct(
public AuthenticatorData $authenticatorData,
public Format $format,
public string $formatName)
{
//
}
}

View File

@@ -0,0 +1,530 @@
<?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 © 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{aaguid: int|bool, credentialId: string, credentialPublicKey: string} $attestedCredentialData
* @param array $extensionData
*/
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) [
'bit_0' => (bool) ($binFlag & 1),
'bit_1' => (bool) ($binFlag & 2),
'bit_2' => (bool) ($binFlag & 4),
'bit_3' => (bool) ($binFlag & 8),
'bit_4' => (bool) ($binFlag & 16),
'bit_5' => (bool) ($binFlag & 32),
'bit_6' => (bool) ($binFlag & 64),
'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: int|bool, credentialId: string, credentialPublicKey: string}
* @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');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator;
use Illuminate\Http\Request;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\JsonTransport;
class AttestationCreation
{
public const ATTACHMENT_CROSS_PLATFORM = 'cross-platform';
public const ATTACHMENT_PLATFORM = 'platform';
/**
* The underlying JSON representation of the Assertion Challenge.
*
* @var \Laragear\WebAuthn\JsonTransport
*/
public JsonTransport $json;
/**
* Create a new Attestation Instructions instance.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
* @param \Illuminate\Http\Request $request
* @param string|null $residentKey
* @param string|null $userVerification
* @param bool $uniqueCredentials
*/
public function __construct(
public WebAuthnAuthenticatable $user,
public Request $request,
public ?string $residentKey = null,
public ?string $userVerification = null,
public bool $uniqueCredentials = true,
) {
$this->json = new JsonTransport();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator;
use Illuminate\Pipeline\Pipeline;
/**
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
*/
class AttestationCreator extends Pipeline
{
/**
* The array of class pipes.
*
* @var array
*/
protected $pipes = [
Pipes\AddRelyingParty::class,
Pipes\SetResidentKeyConfiguration::class,
Pipes\MayRequireUserVerification::class,
Pipes\AddUserDescriptor::class,
Pipes\AddAcceptedAlgorithms::class,
Pipes\MayPreventDuplicateCredentials::class,
Pipes\CreateAttestationChallenge::class,
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
/**
* @internal
*/
class AddAcceptedAlgorithms
{
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
$attestable->json->set('pubKeyCredParams', [
['type' => 'public-key', 'alg' => -7],
['type' => 'public-key', 'alg' => -257],
]);
// Currently we don't support direct attestation. In other words, it won't ask
// for attestation data from the authenticator to cross-check later against
// root certificates. We may add this in the future, but not guaranteed.
$attestable->json->set('attestation', 'none');
return $next($attestable);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Illuminate\Config\Repository;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
/**
* @internal
*/
class AddRelyingParty
{
/**
* Create a new pipe instance.
*
* @param \Illuminate\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
$attestable->json->set('rp.name', $this->config->get('webauthn.relying_party.name'));
if ($id = $this->config->get('webauthn.relying_party.id')) {
$attestable->json->set('rp.id', $id);
}
return $next($attestable);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Illuminate\Support\Str;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
/**
* @internal
*/
class AddUserDescriptor
{
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
$config = $attestable->user->webAuthnData();
// Create a new User UUID if it doesn't existe already in the credentials.
$config['id'] = $attestable->user->webAuthnCredentials()->value('user_id')
?: Str::uuid()->getHex()->toString();
$attestable->json->set('user', $config);
return $next($attestable);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Illuminate\Config\Repository;
use Illuminate\Contracts\Cache\Factory;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
use Laragear\WebAuthn\Attestation\SessionChallenge;
/**
* @internal
*/
class CreateAttestationChallenge
{
use SessionChallenge;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Config\Repository $config
* @param \Illuminate\Contracts\Cache\Factory $cache
*/
public function __construct(protected Repository $config, protected Factory $cache)
{
//
}
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
$attestable->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
$challenge = $this->storeChallenge($attestable->request, $attestable->userVerification, [
'user_uuid' => $attestable->json->get('user.id'),
'user_handle' => $attestable->json->get('user.name'),
]);
$attestable->json->set('challenge', $challenge->data);
return $next($attestable);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
/**
* @internal
*/
class MayPreventDuplicateCredentials
{
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
if ($attestable->uniqueCredentials) {
$attestable->json->set('excludeCredentials', $this->credentials($attestable->user));
}
return $next($attestable);
}
/**
* Returns a collection of credentials ready to be inserted into the Attestable JSON.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
* @return array
*/
protected function credentials(WebAuthnAuthenticatable $user): array
{
return $user
->webAuthnCredentials()
->get(['id', 'transports'])
->map(static function (WebAuthnCredential $credential): array {
return array_filter([
'id'=> $credential->getKey(),
'type' => 'public-key',
'transports' => $credential->transports
]);
})
->toArray();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
/**
* @internal
*/
class MayRequireUserVerification
{
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
if ($attestable->userVerification) {
$attestable->json->set('authenticatorSelection.userVerification', $attestable->userVerification);
}
return $next($attestable);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Laragear\WebAuthn\Attestation\Creator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
use Laragear\WebAuthn\WebAuthn;
/**
* @internal
*/
class SetResidentKeyConfiguration
{
/**
* Handle the Attestation creation
*
* @param \Laragear\WebAuthn\Attestation\Creator\AttestationCreation $attestable
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationCreation $attestable, Closure $next): mixed
{
if ($attestable->residentKey) {
$attestable->json->set('authenticatorSelection.residentKey', $attestable->residentKey);
$verifiesUser = $attestable->residentKey === WebAuthn::RESIDENT_KEY_REQUIRED;
$attestable->json->set('authenticatorSelection.requireResidentKey', $verifiesUser);
if ($verifiesUser) {
$attestable->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
}
}
return $next($attestable);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Laragear\WebAuthn\Attestation\Formats;
use Laragear\WebAuthn\Attestation\AuthenticatorData;
/**
* MIT License
*
* Copyright © 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 is a base class that hold common tasks for different Attestation Statements formats.
*
* This file has been modernized to fit Laravel.
*
* @author Lukas Buchs
* @see https://www.iana.org/assignments/webauthn/webauthn.xhtml
* @internal
*/
abstract class Format
{
/**
* Create a new Attestation Format.
*
* @param array{fmt: string, attStmt: array, authData: \Laragear\WebAuthn\ByteBuffer} $attestationObject
* @param \Laragear\WebAuthn\Attestation\AuthenticatorData $authenticatorData
*/
public function __construct(public array $attestationObject, public AuthenticatorData $authenticatorData)
{
//
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Laragear\WebAuthn\Attestation\Formats;
/**
* @internal
*/
class None extends Format
{
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Laragear\WebAuthn\Attestation;
use Illuminate\Http\Request;
use Laragear\WebAuthn\Challenge;
use Laragear\WebAuthn\WebAuthn;
trait SessionChallenge
{
/**
* Stores an Attestation challenge into the Cache.
*
* @param \Illuminate\Http\Request $request
* @param string|null $verify
* @param array $options
* @return \Laragear\WebAuthn\Challenge
*/
protected function storeChallenge(Request $request, ?string $verify, array $options = []): Challenge
{
$challenge = $this->createChallenge($verify, $options);
$request->session()->put($this->config->get('webauthn.challenge.key'), $challenge);
return $challenge;
}
/**
* Creates a Challenge using the default timeout.
*
* @param string|null $verify
* @param array $options
* @return \Laragear\WebAuthn\Challenge
*/
protected function createChallenge(?string $verify, array $options = []): Challenge
{
return Challenge::random(
$this->config->get('webauthn.challenge.bytes'),
$this->config->get('webauthn.challenge.timeout'),
$verify === WebAuthn::USER_VERIFICATION_REQUIRED,
$options,
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator;
use Illuminate\Http\Request;
use Laragear\WebAuthn\Attestation\AttestationObject;
use Laragear\WebAuthn\Challenge;
use Laragear\WebAuthn\ClientDataJson;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
class AttestationValidation
{
/**
* Create a new Attestation Validation procedure
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
* @param \Illuminate\Http\Request $request
* @param \Laragear\WebAuthn\Challenge|null $challenge
* @param \Laragear\WebAuthn\Attestation\AttestationObject|null $attestationObject
* @param \Laragear\WebAuthn\ClientDataJson|null $clientDataJson
* @param \Laragear\WebAuthn\Models\WebAuthnCredential|null $credential
*/
public function __construct(
public WebAuthnAuthenticatable $user,
public Request $request,
public ?Challenge $challenge = null,
public ?AttestationObject $attestationObject = null,
public ?ClientDataJson $clientDataJson = null,
public ?WebAuthnCredential $credential = null,
)
{
//
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator;
use Illuminate\Pipeline\Pipeline;
/**
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @method \Laragear\WebAuthn\Attestation\Validator\AttestationValidation thenReturn()
*/
class AttestationValidator extends Pipeline
{
/**
* The array of class pipes.
*
* @var array
*/
protected $pipes = [
Pipes\RetrieveChallenge::class,
Pipes\CompileClientDataJson::class,
Pipes\CompileAttestationObject::class,
Pipes\AttestationIsForCreation::class,
Pipes\CheckChallengeSame::class,
Pipes\CheckOriginSecure::class,
Pipes\CheckRelyingPartyIdContained::class,
Pipes\CheckRelyingPartyHashSame::class,
Pipes\CheckUserInteraction::class,
Pipes\CredentialIdShouldNotBeDuplicated::class,
Pipes\MakeWebAuthnCredential::class,
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Exceptions\AttestationException;
/**
* 7. Verify that the value of C.type is webauthn.create.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class AttestationIsForCreation
{
/**
* Handle the incoming Attestation Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation $validation, Closure $next): mixed
{
if ($validation->clientDataJson->type !== 'webauthn.create') {
throw AttestationException::make('Response is not for creating WebAuthn Credentials.');
}
return $next($validation);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CheckChallengeSame as BaseCheckChallengeSame;
/**
* 8. Verify that the value of C.challenge equals the base64url encoding of options.challenge.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CheckChallengeSame extends BaseCheckChallengeSame
{
//
}

View File

@@ -0,0 +1,10 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
class CheckOriginSecure extends BaseCheckOriginSame
{
//
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\AuthenticatorData;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyHashSame as BaseCheckRelyingPartyHashSame;
/**
* 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CheckRelyingPartyHashSame extends BaseCheckRelyingPartyHashSame
{
/**
* Return the Attestation data to check the RP ID Hash.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @return \Laragear\WebAuthn\Attestation\AuthenticatorData
*/
protected function authenticatorData(AssertionValidation|AttestationValidation $validation): AuthenticatorData
{
return $validation->attestationObject->authenticatorData;
}
/**
* Return the Relying Party ID from the config or credential.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @return string
*/
protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string
{
return $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CheckRelyingPartyIdContained as BaseCheckRelyingPartyIdSame;
/**
* 9. Verify that the value of C.origin matches the Relying Party's origin.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CheckRelyingPartyIdContained extends BaseCheckRelyingPartyIdSame
{
//
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CheckUserInteraction as BaseCheckUserInteraction;
/**
* 14. Verify that the User Present bit of the flags in authData is set.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CheckUserInteraction extends BaseCheckUserInteraction
{
//
}

View File

@@ -0,0 +1,100 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Closure;
use Illuminate\Http\Request;
use JetBrains\PhpStorm\ArrayShape;
use Laragear\WebAuthn\Attestation\AttestationObject;
use Laragear\WebAuthn\Attestation\AuthenticatorData;
use Laragear\WebAuthn\Attestation\Formats\None;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\ByteBuffer;
use Laragear\WebAuthn\CborDecoder;
use Laragear\WebAuthn\Exceptions\AttestationException;
use Laragear\WebAuthn\Exceptions\DataException;
use function base64_decode;
use function is_array;
use function is_string;
/**
* 12. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
* structure to obtain the attestation statement format fmt, the authenticator data authData,
* and the attestation statement attStmt.
*
* 18. Determine the attestation statement format by performing a USASCII case-sensitive match on
* fmt against the set of supported WebAuthn Attestation Statement Format Identifier values.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CompileAttestationObject
{
/**
* Handle the incoming Attestation Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation $validation, Closure $next): mixed
{
$data = $this->decodeCborBase64($validation->request);
// Here we would receive the attestation formats and decode them. Since we're
// only support the universal "none" we can just check if it's equal or not.
// Later we may support multiple authenticator formats through a PHP match.
if ($data['fmt'] !== 'none') {
throw AttestationException::make("Format name [{$data['fmt']}] is invalid.");
}
try {
$authenticatorData = AuthenticatorData::fromBinary($data['authData']->getBinaryString());
} catch (DataException $e) {
throw AttestationException::make($e->getMessage());
}
$validation->attestationObject = new AttestationObject(
$authenticatorData, new None($data, $authenticatorData), $data['fmt']
);
return $next($validation);
}
/**
* Returns an array map from a BASE64 encoded CBOR string.
*
* @param \Illuminate\Http\Request $request
* @return array
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
#[ArrayShape(["fmt" => "string", "attStmt" => "array", "authData" => ByteBuffer::class])]
protected function decodeCborBase64(Request $request): array
{
try {
$data = CborDecoder::decode(base64_decode($request->json('response.attestationObject', '')));
} catch (DataException $e) {
throw AttestationException::make($e->getMessage());
}
if (!is_array($data)) {
throw AttestationException::make('CBOR Object is anything but an array.');
}
if (!isset($data['fmt']) || !is_string($data['fmt'])) {
throw AttestationException::make('Format is missing or invalid.');
}
if (!isset($data['attStmt']) || !is_array($data['attStmt'])) {
throw AttestationException::make('Statement is missing or invalid.');
}
if (!isset($data['authData']) || !$data['authData'] instanceof ByteBuffer) {
throw AttestationException::make('Authenticator Data is missing or invalid.');
}
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CompileClientDataJson as BaseCompileClientDataJson;
/**
* 5. Let JSONtext be the result of running UTF-8 decode on the value of response.clientDataJSON.
*
* 6. Let C, the client data claimed as collected during the credential creation, be the result of
* running an implementation-specific JSON parser on JSONtext.
*
* @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*
* @internal
*/
class CompileClientDataJson extends BaseCompileClientDataJson
{
//
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Exceptions\AttestationException;
use Laragear\WebAuthn\Models\WebAuthnCredential;
/**
* @internal
*/
class CredentialIdShouldNotBeDuplicated
{
/**
* Handle the incoming Attestation Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation $validation, Closure $next): mixed
{
if ($this->credentialAlreadyExists($validation)) {
throw AttestationException::make('Credential ID already exists in the database.');
}
return $next($validation);
}
/**
* Finds a WebAuthn Credential by the issued ID.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @return bool
*/
protected function credentialAlreadyExists(AttestationValidation $validation): bool
{
return WebAuthnCredential::whereKey($validation->request->json('id'))->exists();
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Exceptions\AttestationException;
use Laragear\WebAuthn\Exceptions\DataException;
use Ramsey\Uuid\Uuid;
/**
* @internal
*/
class MakeWebAuthnCredential
{
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming Attestation Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation $validation, Closure $next): mixed
{
$validation->credential = $validation->user->makeWebAuthnCredential([
'id' => $validation->request->json('id'),
'user_id' => $validation->challenge->properties['user_uuid'],
'alias' => $validation->request->json('response.alias'),
'counter' => $validation->attestationObject->authenticatorData->counter,
'rp_id' => $this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'),
'origin' => $validation->clientDataJson->origin,
'transports' => $validation->request->json('response.transports'),
'aaguid' => Uuid::fromBytes($validation->attestationObject->authenticatorData->attestedCredentialData->aaguid),
'public_key' => $this->getPublicKeyAsPem($validation),
'attestation_format' => $validation->attestationObject->formatName,
]);
return $next($validation);
}
/**
* Returns a public key from the credentials as a PEM string.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @return string
*/
protected function getPublicKeyAsPem(AttestationValidation $validation): string
{
try {
return $validation->attestationObject->authenticatorData->getPublicKeyPem();
} catch (DataException $e) {
throw AttestationException::make($e->getMessage());
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Laragear\WebAuthn\Attestation\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\RetrieveChallenge as BaseRetrieveChallenge;
/**
* @internal
*/
class RetrieveChallenge extends BaseRetrieveChallenge
{
//
}