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,36 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\JsonTransport;
class AssertionCreation
{
/**
* The Json Transport helper to build the message.
*
* @var \Laragear\WebAuthn\JsonTransport
*/
public JsonTransport $json;
/**
* Create a new Assertion Creation instance.
*
* @param \Illuminate\Http\Request $request
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
* @param \Illuminate\Database\Eloquent\Collection|null $acceptedCredentials
* @param string|null $userVerification
*/
public function __construct(
public Request $request,
public ?WebAuthnAuthenticatable $user = null,
public ?Collection $acceptedCredentials = null,
public ?string $userVerification = null,
)
{
$this->json = new JsonTransport();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator;
use Illuminate\Pipeline\Pipeline;
/**
* @method \Laragear\WebAuthn\Assertion\Creator\AssertionCreation thenReturn()
*/
class AssertionCreator extends Pipeline
{
/**
* The array of class pipes.
*
* @var array
*/
protected $pipes = [
Pipes\AddConfiguration::class,
Pipes\MayRetrieveCredentialsIdForUser::class,
Pipes\MayRequireUserVerification::class,
Pipes\CreateAssertionChallenge::class,
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
class AddConfiguration
{
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming Assertion.
*
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
* @param \Closure $next
* @return mixed
*/
public function handle(AssertionCreation $assertion, Closure $next): mixed
{
$assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);
return $next($assertion);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
use Laragear\WebAuthn\Attestation\SessionChallenge;
class CreateAssertionChallenge
{
use SessionChallenge;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming Assertion.
*
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
* @param \Closure $next
* @return mixed
*/
public function handle(AssertionCreation $assertion, Closure $next): mixed
{
$options = [];
if ($assertion->acceptedCredentials?->isNotEmpty()) {
$options['credentials'] = $assertion->acceptedCredentials->map->getKey()->toArray();
}
$challenge = $this->storeChallenge($assertion->request, $assertion->userVerification, $options);
$assertion->json->set('challenge', $challenge->data);
return $next($assertion);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
class MayRequireUserVerification
{
/**
* Handle the incoming Assertion.
*
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
* @param \Closure $next
* @return mixed
*/
public function handle(AssertionCreation $assertion, Closure $next): mixed
{
if ($assertion->userVerification) {
$assertion->json->set('userVerification', $assertion->userVerification);
}
return $next($assertion);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Laragear\WebAuthn\Assertion\Creator\Pipes;
use Closure;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use function array_filter;
class MayRetrieveCredentialsIdForUser
{
/**
* Handle the incoming Assertion.
*
* @param \Laragear\WebAuthn\Assertion\Creator\AssertionCreation $assertion
* @param \Closure $next
* @return mixed
*/
public function handle(AssertionCreation $assertion, Closure $next): mixed
{
// If there is a user found, we will pluck the IDS and add them as a binary buffer.
if ($assertion->user) {
$assertion->acceptedCredentials = $assertion->user->webAuthnCredentials()->get(['id', 'transports']);
if ($assertion->acceptedCredentials->isNotEmpty()) {
$assertion->json->set('allowCredentials', $this->parseCredentials($assertion->acceptedCredentials));
}
}
return $next($assertion);
}
/**
* Adapt all credentials into an `allowCredentials` digestible array.
*
* @param \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $credentials
* @return \Illuminate\Support\Collection<int, array>
*/
protected function parseCredentials(EloquentCollection $credentials): Collection
{
return $credentials->map(static function (WebAuthnCredential $credential): array {
return array_filter([
'id' => $credential->getKey(),
'type' => 'public-key',
'transports' => $credential->transports
]);
});
}
}

View File

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

View File

@@ -0,0 +1,34 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator;
use Illuminate\Pipeline\Pipeline;
/**
* @method \Laragear\WebAuthn\Assertion\Validator\AssertionValidation thenReturn()
*/
class AssertionValidator extends Pipeline
{
/**
* The array of class pipes.
*
* @var array
*/
protected $pipes = [
Pipes\RetrieveChallenge::class,
Pipes\RetrievesCredentialId::class,
Pipes\CheckCredentialIsForUser::class,
Pipes\CheckTypeIsPublicKey::class,
Pipes\CompileAuthenticatorData::class,
Pipes\CompileClientDataJson::class,
Pipes\CheckCredentialIsWebAuthnGet::class,
Pipes\CheckChallengeSame::class,
Pipes\CheckOriginSecure::class,
Pipes\CheckRelyingPartyIdContained::class,
Pipes\CheckRelyingPartyHashSame::class,
Pipes\CheckUserInteraction::class,
Pipes\CheckPublicKeySignature::class,
Pipes\CheckPublicKeyCounterCorrect::class,
Pipes\IncrementCredentialCounter::class,
];
}

View File

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

View File

@@ -0,0 +1,79 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
use Ramsey\Uuid\Uuid;
use function hash_equals;
/**
* 6. Identify the user being authenticated and verify that this user is the owner of the public
* key credential source credentialSource identified by credential.id:
*
* - If the user was identified before the authentication ceremony was initiated, e.g., via a
* username or cookie, verify that the identified user is the owner of credentialSource. If
* response.userHandle is present, let userHandle be its value. Verify that userHandle also
* maps to the same user.
*
* - If the user was not identified before the authentication ceremony was initiated, verify
* that response.userHandle is present, and that the user identified by this value is the
* owner of credentialSource.
*
* @internal
*/
class CheckCredentialIsForUser
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
if ($validation->user) {
$this->validateUser($validation);
if ($validation->request->json('response.userHandle')) {
$this->validateId($validation);
}
} else {
$this->validateId($validation);
}
return $next($validation);
}
/**
* Validate the user owns the Credential if it already exists in the validation procedure.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @return void
*/
protected function validateUser(AssertionValidation $validation): void
{
if ($validation->credential->authenticatable()->isNot($validation->user)) {
throw AssertionException::make('User is not owner of the stored credential.');
}
}
/**
* Validate the user ID of the response.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @return void
*/
protected function validateId(AssertionValidation $validation): void
{
$handle = $validation->request->json('response.userHandle');
if (! $handle || ! hash_equals(Uuid::fromString($validation->credential->user_id)->getHex()->toString(), $handle)) {
throw AssertionException::make('User ID is not owner of the stored credential.');
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
/**
* @internal
*/
class CheckCredentialIsWebAuthnGet
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
if ($validation->clientDataJson->type !== 'webauthn.get') {
throw AssertionException::make('Client Data type is not [webauthn.get].');
}
return $next($validation);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Laragear\WebAuthn\SharedPipes\CheckOriginSecure as BaseCheckOriginSame;
/**
* 9. Verify that the value of C.origin matches the Relying Party's origin.
*
* @internal
*/
class CheckOriginSecure extends BaseCheckOriginSame
{
//
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Events\CredentialCloned;
use Laragear\WebAuthn\Exceptions\AssertionException;
/**
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
*
* - If authData.signCount
* -> is greater than storedSignCount:
* Update storedSignCount to be the value of authData.signCount.
* -> less than or equal to storedSignCount:
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
* credential private key may exist and are being used in parallel. Relying Parties
* should incorporate this information into their risk scoring. Whether the Relying
* Party updates storedSignCount in this case, or not, or fails the authentication
* ceremony or not, is Relying Party-specific.
*
* @internal
*/
class CheckPublicKeyCounterCorrect
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
if ($this->hasCounter($validation) && $this->counterBelowStoredCredential($validation)) {
$validation->credential->disable();
CredentialCloned::dispatch($validation->credential, $validation->authenticatorData->counter);
throw AssertionException::make('Credential counter not over stored counter.');
}
return $next($validation);
}
/**
* Check if the incoming credential or the stored credential have a counter.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @return bool
*/
protected function hasCounter(AssertionValidation $validation): bool
{
return $validation->credential->counter
|| $validation->authenticatorData->counter;
}
/**
* Check if the credential counter is equal or higher than what the authenticator reports.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @return bool
*/
protected function counterBelowStoredCredential(AssertionValidation $validation): bool
{
return $validation->authenticatorData->counter <= $validation->credential->counter;
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
use OpenSSLAsymmetricKey;
use function base64_decode;
use function hash;
use function openssl_pkey_get_public;
use function openssl_verify;
use const OPENSSL_ALGO_SHA256;
/**
* @internal
*/
class CheckPublicKeySignature
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
$publicKey = openssl_pkey_get_public($validation->credential->public_key);
if (!$publicKey) {
throw AssertionException::make('Stored Public Key is invalid.');
}
$signature = base64_decode($validation->request->json('response.signature', ''));
if (!$signature) {
throw AssertionException::make('Signature is empty.');
}
$this->validateSignature($validation, $publicKey, $signature);
return $next($validation);
}
/**
* Validate the signature from the assertion.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param string $signature
* @param \OpenSSLAsymmetricKey $publicKey
* @return void
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function validateSignature(
AssertionValidation $validation,
OpenSSLAsymmetricKey $publicKey,
string $signature
): void {
$verifiable = base64_decode($validation->request->json('response.authenticatorData'))
.hash('sha256', base64_decode($validation->request->json('response.clientDataJSON')), true);
if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
throw AssertionException::make('Signature is invalid.');
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Laragear\WebAuthn\Assertion\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;
/**
* @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->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 $validation->credential->rp_id;
}
}

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
/**
* @internal
*/
class CheckTypeIsPublicKey
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
if ($validation->request->json('type') !== 'public-key') {
throw AssertionException::make('Response type is not [public-key].');
}
return $next($validation);
}
}

View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\AuthenticatorData;
use Laragear\WebAuthn\Exceptions\AssertionException;
use Laragear\WebAuthn\Exceptions\DataException;
use function base64_decode;
/**
* @internal
*/
class CompileAuthenticatorData
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
$data = base64_decode($validation->request->json('response.authenticatorData', ''));
if (!$data) {
throw AssertionException::make('Authenticator Data does not exist or is empty.');
}
try {
$validation->authenticatorData = AuthenticatorData::fromBinary($data);
} catch (DataException $e) {
throw AssertionException::make($e->getMessage());
}
return $next($validation);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
/**
* 21. Let storedSignCount be the stored signature counter value associated with credential.id.
* If authData.signCount is nonzero or storedSignCount is nonzero, then run the following sub-step:
*
* - If authData.signCount
* -> is greater than storedSignCount:
* Update storedSignCount to be the value of authData.signCount.
* -> less than or equal to storedSignCount:
* This is a signal that the authenticator may be cloned, i.e. at least two copies of the
* credential private key may exist and are being used in parallel. Relying Parties
* should incorporate this information into their risk scoring. Whether the Relying
* Party updates storedSignCount in this case, or not, or fails the authentication
* ceremony or not, is Relying Party-specific.
*
* @internal
*/
class IncrementCredentialCounter
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
$validation->credential->syncCounter($validation->authenticatorData->counter);
return $next($validation);
}
}

View File

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

View File

@@ -0,0 +1,58 @@
<?php
namespace Laragear\WebAuthn\Assertion\Validator\Pipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use function in_array;
/**
* @internal
*/
class RetrievesCredentialId
{
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation $validation, Closure $next): mixed
{
$id = $validation->request->json('id');
// First, always check the challenge credentials before finding the real one.
if ($this->credentialNotInChallenge($id, $validation->challenge->properties)) {
throw AssertionException::make('Credential is not on accepted list.');
}
// We can now find the credential.
$validation->credential = WebAuthnCredential::whereKey($id)->first();
if (!$validation->credential) {
throw AssertionException::make('Credential ID does not exist.');
}
if ($validation->credential->isDisabled()) {
throw AssertionException::make('Credential ID is blacklisted.');
}
return $next($validation);
}
/**
* Check if the previous Assertion request specified a credentials list to accept.
*
* @param string $id
* @param array $properties
* @return bool
*/
protected function credentialNotInChallenge(string $id, array $properties): bool
{
return isset($properties['credentials']) && ! in_array($id, $properties['credentials'], true);
}
}

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
{
//
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Laragear\WebAuthn\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidator;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Exceptions\AssertionException;
use function class_implements;
use function config;
use function logger;
use function request;
/**
* This class is not meant to be used directly.
*
* @internal
*/
class WebAuthnUserProvider extends EloquentUserProvider
{
/**
* Create a new database user provider.
*
* @param \Illuminate\Contracts\Hashing\Hasher $hasher
* @param string $model
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidator $validator
* @param bool $fallback
*/
public function __construct(
HasherContract $hasher,
string $model,
protected AssertionValidator $validator,
protected bool $fallback,
) {
parent::__construct($hasher, $model);
}
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function retrieveByCredentials(array $credentials)
{
if (class_implements($this->model, WebAuthnAuthenticatable::class) && $this->isSignedChallenge($credentials)) {
/** @noinspection PhpIncompatibleReturnTypeInspection */
return $this->newModelQuery()
->whereHas('webAuthnCredentials', static function (Builder $query) use ($credentials): void {
$query->whereKey($credentials['id'])->whereEnabled();
})
->first();
}
return parent::retrieveByCredentials($credentials);
}
/**
* Check if the credentials are for a public key signed challenge
*
* @param array $credentials
* @return bool
*/
protected function isSignedChallenge(array $credentials): bool
{
return isset($credentials['id'], $credentials['rawId'], $credentials['response'], $credentials['type']);
}
/**
* Validate a user against the given credentials.
*
* @param \Illuminate\Contracts\Auth\Authenticatable|\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials($user, array $credentials): bool
{
if ($user instanceof WebAuthnAuthenticatable && $this->isSignedChallenge($credentials)) {
return $this->validateWebAuthn();
}
// If the fallback is enabled, we will validate the credential password.
return $this->fallback && parent::validateCredentials($user, $credentials);
}
/**
* Validate the WebAuthn assertion.
*
* @return bool
*/
protected function validateWebAuthn(): bool
{
try {
$this->validator->send(new AssertionValidation(request()))->thenReturn();
} catch (AssertionException $e) {
// If we're debugging, like under local development, push the error to the logger.
if (config('app.debug')) {
logger($e->getMessage());
}
return false;
}
return true;
}
}

472
src/ByteBuffer.php Normal file
View File

@@ -0,0 +1,472 @@
<?php
namespace Laragear\WebAuthn;
use Illuminate\Contracts\Support\Jsonable;
use InvalidArgumentException;
use JetBrains\PhpStorm\ArrayShape;
use JsonSerializable;
use OutOfBoundsException;
use Stringable;
use function base64_decode;
use function base64_encode;
use function bin2hex;
use function hash_equals;
use function hex2bin;
use function json_decode;
use function ord;
use function random_bytes;
use function rtrim;
use function str_repeat;
use function strlen;
use function strtr;
use function substr;
use function unpack;
/**
* MIT License
*
* Copyright (c) 2018 Thomas Bleeker
*
* 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.
*
* ---
* MIT License
*
* Copyright © 2021 Lukas Buchs
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
*
* 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
* @author Thomas Bleeker
* @internal
*/
class ByteBuffer implements JsonSerializable, Jsonable, Stringable
{
/**
* Create a new ByteBuffer
*
* @param string $binaryData
* @param int $dataLength
*/
public function __construct(protected string $binaryData, protected int $dataLength = 0)
{
$this->dataLength = strlen($binaryData);
}
/**
* Returns the length of the ByteBuffer data.
*
* @return int
*/
public function getDataLength(): int
{
return $this->dataLength;
}
/**
* Check if the length of the data is greater than zero.
*
* @return bool
*/
public function hasLength(): bool
{
return (bool) $this->dataLength;
}
/**
* Check if the length of the data is zero.
*
* @return bool
*/
public function hasNoLength(): bool
{
return !$this->hasLength();
}
/**
* Returns the binary string verbatim.
*
* @return string
*/
public function getBinaryString(): string
{
return $this->binaryData;
}
/**
* Check if both Byte Buffers are equal using `hash_equals`.
*
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
* @return bool
*/
public function hashEqual(self|string $buffer): bool
{
if ($buffer instanceof static) {
$buffer = $buffer->getBinaryString();
}
return hash_equals($this->binaryData, $buffer);
}
/**
* Check if both Byte Buffers are not equal using `hash_equals`.
*
* @param \Laragear\WebAuthn\ByteBuffer|string $buffer
* @return bool
*/
public function hashNotEqual(self|string $buffer): bool
{
return ! $this->hashEqual($buffer);
}
/**
* Returns a certain portion of these bytes.
*
* @param int $offset
* @param int|null $length
* @return string
*/
public function getBytes(int $offset = 0, int $length = null): string
{
$length ??= $this->dataLength;
if ($offset < 0 || $length < 0 || ($offset + $length > $this->dataLength)) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset or length.');
}
return substr($this->binaryData, $offset, $length);
}
/**
* Returns the value of a single byte.
*
* @param int $offset
* @return int
*/
public function getByteVal(int $offset = 0): int
{
if (!$byte = $this->binaryData[$offset] ?? null) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
return ord($byte);
}
/**
* Returns the value of a single unsigned 16-bit integer.
*
* @param int $offset
* @return mixed
*/
public function getUint16Val(int $offset = 0): int
{
if ($offset < 0 || ($offset + 2) > $this->dataLength) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
return unpack('n', $this->binaryData, $offset)[1];
}
/**
* Returns the value of a single unsigned 32-bit integer.
*
* @param int $offset
* @return mixed
*/
public function getUint32Val(int $offset = 0): int
{
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
$val = unpack('N', $this->binaryData, $offset)[1];
// Signed integer overflow causes signed negative numbers
if ($val < 0) {
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
}
return $val;
}
/**
* Returns the value of a single unsigned 64-bit integer.
*
* @param int $offset
* @return int
*/
public function getUint64Val(int $offset): int
{
if (PHP_INT_SIZE < 8) {
throw new OutOfBoundsException('ByteBuffer: 64-bit values not supported by this system');
}
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
$val = unpack('J', $this->binaryData, $offset)[1];
// Signed integer overflow causes signed negative numbers
if ($val < 0) {
throw new OutOfBoundsException('ByteBuffer: Value out of integer range.');
}
return $val;
}
/**
* Returns the value of a single 16-bit float.
*
* @param int $offset
* @return float
*/
public function getHalfFloatVal(int $offset = 0): float
{
// FROM spec pseudo decode_half(unsigned char *halfp)
$half = $this->getUint16Val($offset);
$exp = ($half >> 10) & 0x1f;
$mant = $half & 0x3ff;
if ($exp === 0) {
$val = $mant * (2 ** -24);
} elseif ($exp !== 31) {
$val = ($mant + 1024) * (2 ** ($exp - 25));
} else {
$val = ($mant === 0) ? INF : NAN;
}
return ($half & 0x8000) ? -$val : $val;
}
/**
* Returns the value of a single 32-bit float.
*
* @param int $offset
* @return float
*/
public function getFloatVal(int $offset = 0): float
{
if ($offset < 0 || ($offset + 4) > $this->dataLength) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
return unpack('G', $this->binaryData, $offset)[1];
}
/**
* Returns the value of a single 64-bit float.
*
* @param int $offset
* @return float
*/
public function getDoubleVal(int $offset = 0): float
{
if ($offset < 0 || ($offset + 8) > $this->dataLength) {
throw new InvalidArgumentException('ByteBuffer: Invalid offset');
}
return unpack('E', $this->binaryData, $offset)[1];
}
/**
* Transforms the ByteBuffer JSON into a generic Object.
*
* @param int $jsonFlags
* @return object
* @throws \JsonException
*/
public function toObject(int $jsonFlags = 0): object
{
return json_decode($this->binaryData, null, 512, JSON_THROW_ON_ERROR | $jsonFlags);
}
/**
* Returns a Base64 URL representation of the byte buffer.
*
* @return string
*/
public function toBase64Url(): string
{
return static::encodeBase64Url($this->binaryData);
}
/**
* Specify data which should be serialized to JSON.
*
* @return string
*/
public function jsonSerialize(): string
{
return $this->toBase64Url();
}
/**
* Returns a hexadecimal representation of the ByteBuffer.
*
* @return string
*/
public function toHex(): string
{
return bin2hex($this->binaryData);
}
/**
* object to string
*
* @return string
*/
public function __toString(): string
{
return $this->toHex();
}
/**
* Convert the object to its JSON representation.
*
* @param int $options
* @return string
*/
public function toJson($options = 0): string
{
return $this->jsonSerialize();
}
/**
* Returns an array of data for serialization.
*
* @return array
*/
#[ArrayShape(['binaryData' => "string"])]
public function __serialize(): array
{
return ['binaryData' => static::encodeBase64Url($this->binaryData)];
}
/**
* Serializable-Interface
*
* @param array $data
*/
public function __unserialize(array $data): void
{
$this->binaryData = static::decodeBase64Url($data['binaryData']);
$this->dataLength = strlen($this->binaryData);
}
/**
* Create a ByteBuffer from a BASE64 URL encoded string.
*
* @param string $base64url
* @return static
*/
public static function fromBase64Url(string $base64url): static
{
if (false === $bin = self::decodeBase64Url($base64url)) {
throw new InvalidArgumentException('ByteBuffer: Invalid base64 url string');
}
return new ByteBuffer($bin);
}
/**
* Create a ByteBuffer from a BASE64 encoded string.
*
* @param string $base64
* @return static
*/
public static function fromBase64(string $base64): static
{
if (false === $bin = base64_decode($base64)) {
throw new InvalidArgumentException('ByteBuffer: Invalid base64 string');
}
return new ByteBuffer($bin);
}
/**
* Create a ByteBuffer from a hexadecimal string.
*
* @param string $hex
* @return static
*/
public static function fromHex(string $hex): static
{
if (false === $bin = hex2bin($hex)) {
throw new InvalidArgumentException('ByteBuffer: Invalid hex string');
}
return new static($bin);
}
/**
* Create a random ByteBuffer
*
* @param int $length
* @return static
*/
public static function makeRandom(int $length): static
{
return new static(random_bytes($length));
}
/**
* Decodes a BASE64 URL string.
*
* @param string $data
* @return string|false
*/
protected static function decodeBase64Url(string $data): string|false
{
return base64_decode(strtr($data, '-_', '+/').str_repeat('=', 3 - (3 + strlen($data)) % 4));
}
/**
* Encodes a BASE64 URL string.
*
* @param string $data
* @return string|false
*/
protected static function encodeBase64Url(string $data): string|false
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}

335
src/CborDecoder.php Normal file
View File

@@ -0,0 +1,335 @@
<?php
namespace Laragear\WebAuthn;
use InvalidArgumentException;
use Laragear\WebAuthn\Exceptions\DataException;
use function is_int;
use function is_string;
use function sprintf;
/**
* MIT License
*
* Copyright (c) 2018 Thomas Bleeker
*
* 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.
*
* ---
* MIT License
*
* Copyright © 2021 Lukas Buchs
* Copyright © 2018 Thomas Bleeker (CBOR & ByteBuffer part)
*
* 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
* @author Thomas Bleeker
* @internal
*/
class CborDecoder
{
public const CBOR_MAJOR_UNSIGNED_INT = 0;
public const CBOR_MAJOR_TEXT_STRING = 3;
public const CBOR_MAJOR_FLOAT_SIMPLE = 7;
public const CBOR_MAJOR_NEGATIVE_INT = 1;
public const CBOR_MAJOR_ARRAY = 4;
public const CBOR_MAJOR_TAG = 6;
public const CBOR_MAJOR_MAP = 5;
public const CBOR_MAJOR_BYTE_STRING = 2;
/**
* Decodes the binary data.
*
* @param \Laragear\WebAuthn\ByteBuffer|string $encoded
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
public static function decode(ByteBuffer|string $encoded): ByteBuffer|array|bool|float|int|string|null
{
if (is_string($encoded)) {
$encoded = new ByteBuffer($encoded);
}
$offset = 0;
$result = static::parseItem($encoded, $offset);
if ($offset !== $encoded->getDataLength()) {
throw new DataException('CBOR: Unused bytes after data item.');
}
return $result;
}
/**
* Decodes a portion of the Byte Buffer.
*
* @param ByteBuffer|string $bufOrBin
* @param int $startOffset
* @param int|null $endOffset
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
public static function decodePortion(ByteBuffer|string $bufOrBin, int $startOffset, ?int &$endOffset = null): ByteBuffer|array|bool|float|int|string|null
{
$buf = $bufOrBin instanceof ByteBuffer ? $bufOrBin : new ByteBuffer($bufOrBin);
$offset = $startOffset;
$data = static::parseItem($buf, $offset);
$endOffset = $offset;
return $data;
}
/**
* Parses a single item of the Byte Buffer.
*
* @param ByteBuffer $buf
* @param int $offset
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseItem(ByteBuffer $buf, int &$offset): ByteBuffer|array|bool|float|int|string|null
{
$first = $buf->getByteVal($offset++);
$type = $first >> 5;
$val = $first & 0b11111;
if ($type === static::CBOR_MAJOR_FLOAT_SIMPLE) {
return static::parseFloatSimple($val, $buf, $offset);
}
$val = static::parseExtraLength($val, $buf, $offset);
try {
return static::parseItemData($type, $val, $buf, $offset);
} catch (InvalidArgumentException $e) {
throw new DataException($e->getMessage());
}
}
/**
* Parses a simple float value.
*
* @param int $val
* @param \Laragear\WebAuthn\ByteBuffer $buf
* @param int $offset
* @return bool|float|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseFloatSimple(int $val, ByteBuffer $buf, int &$offset): bool|float|null
{
switch ($val) {
case 24:
$val = $buf->getByteVal($offset);
$offset++;
return static::parseSimpleValue($val);
case 25:
$floatValue = $buf->getHalfFloatVal($offset);
$offset += 2;
return $floatValue;
case 26:
$floatValue = $buf->getFloatVal($offset);
$offset += 4;
return $floatValue;
case 27:
$floatValue = $buf->getDoubleVal($offset);
$offset += 8;
return $floatValue;
case 28:
case 29:
case 30:
throw new DataException('Reserved value used.');
case 31:
throw new DataException('Indefinite length is not supported.');
default:
return static::parseSimpleValue($val);
}
}
/**
* Parses a simple value from CBOR.
*
* @param int $val
* @return bool|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseSimpleValue(int $val): ?bool
{
return match ($val) {
20 => false,
21 => true,
22 => null,
default => throw new DataException(sprintf('Unsupported simple value %d.', $val))
};
}
/**
* Parses the CBOR extra length.
*
* @param int $val
* @param \Laragear\WebAuthn\ByteBuffer $buf
* @param int $offset
* @return int
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseExtraLength(int $val, ByteBuffer $buf, int &$offset): int
{
switch ($val) {
case 24:
$val = $buf->getByteVal($offset);
$offset++;
return $val;
case 25:
$val = $buf->getUint16Val($offset);
$offset += 2;
return $val;
case 26:
$val = $buf->getUint32Val($offset);
$offset += 4;
return $val;
case 27:
$val = $buf->getUint64Val($offset);
$offset += 8;
return $val;
case 28:
case 29:
case 30:
throw new DataException('Reserved value used.');
case 31:
throw new DataException('Indefinite length is not supported.');
default:
return $val;
}
}
/**
* Parses the data inside a Byte Buffer.
*
* @param int $type
* @param int $val
* @param \Laragear\WebAuthn\ByteBuffer $buf
* @param $offset
* @return \Laragear\WebAuthn\ByteBuffer|array|bool|float|int|string|null
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseItemData(
int $type,
int $val,
ByteBuffer $buf,
&$offset
): ByteBuffer|array|bool|float|int|string|null {
switch ($type) {
case static::CBOR_MAJOR_UNSIGNED_INT: // uint
return $val;
case static::CBOR_MAJOR_NEGATIVE_INT:
return -1 - $val;
case static::CBOR_MAJOR_BYTE_STRING:
$data = $buf->getBytes($offset, $val);
$offset += $val;
return new ByteBuffer($data); // bytes
case static::CBOR_MAJOR_TEXT_STRING:
$data = $buf->getBytes($offset, $val);
$offset += $val;
return $data; // UTF-8
case static::CBOR_MAJOR_ARRAY:
return static::parseArray($buf, $offset, $val);
case static::CBOR_MAJOR_MAP:
return static::parseMap($buf, $offset, $val);
case static::CBOR_MAJOR_TAG:
return static::parseItem($buf, $offset); // 1 embedded data item
}
throw new DataException(sprintf('Unknown major type %d.', $type));
}
/**
* Parses an array with string keys.
*
* @param \Laragear\WebAuthn\ByteBuffer $buffer
* @param int $offset
* @param int $count
* @return array<string, mixed>
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseMap(ByteBuffer $buffer, int &$offset, int $count): array
{
$map = [];
for ($i = 0; $i < $count; $i++) {
$mapKey = static::parseItem($buffer, $offset);
$mapVal = static::parseItem($buffer, $offset);
if (!is_int($mapKey) && !is_string($mapKey)) {
throw new DataException('Can only use strings or integers as map keys');
}
$map[$mapKey] = $mapVal;
}
return $map;
}
/**
* Parses an array from the byte buffer.
*
* @param \Laragear\WebAuthn\ByteBuffer $buf
* @param int $offset
* @param int $count
* @return array
* @throws \Laragear\WebAuthn\Exceptions\DataException
*/
protected static function parseArray(ByteBuffer $buf, int &$offset, int $count): array
{
$arr = [];
for ($i = 0; $i < $count; $i++) {
$arr[] = static::parseItem($buf, $offset);
}
return $arr;
}
}

52
src/Challenge.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace Laragear\WebAuthn;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\InteractsWithTime;
class Challenge
{
use InteractsWithTime;
/**
* Create a new Challenge instance.
*
* @param \Laragear\WebAuthn\ByteBuffer $data
* @param int $timeout
* @param bool $verify
* @param array $properties
*/
public function __construct(
public ByteBuffer $data,
public int $timeout,
public bool $verify = true,
public array $properties = []
) {
$this->timeout = Date::now()->addSeconds($this->timeout)->getTimestamp();
}
/**
* Check if the current challenge has expired in time and no longer valid.
*
* @return bool
*/
public function hasExpired(): bool
{
return Date::createFromTimestamp($this->timeout)->isPast();
}
/**
* Creates a new Challenge instance using a random ByteBuffer of the given length.
*
* @param int $length
* @param int $timeout
* @param bool $verify
* @param array $options
* @return static
*/
public static function random(int $length, int $timeout, bool $verify = true, array $options = []): static
{
return new static(ByteBuffer::makeRandom($length), $timeout, $verify, $options);
}
}

18
src/ClientDataJson.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace Laragear\WebAuthn;
class ClientDataJson
{
/**
* Create a new Client Data JSON object.
*
* @param string $type
* @param string $origin
* @param \Laragear\WebAuthn\ByteBuffer $challenge
*/
public function __construct(public string $type, public string $origin, public ByteBuffer $challenge)
{
//
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Laragear\WebAuthn\Contracts;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Laragear\WebAuthn\Models\WebAuthnCredential;
interface WebAuthnAuthenticatable
{
/**
* Returns displayable data to be used to create WebAuthn Credentials.
*
* @return array{name: string, displayName: string}
*/
public function webAuthnData(): array;
/**
* Removes all credentials previously registered.
*
* @param string ...$except
* @return void
*/
public function flushCredentials(string ...$except): void;
/**
* Disables all credentials for the user.
*
* @param string ...$except
* @return void
*/
public function disableAllCredentials(string ...$except): void;
/**
* Makes an instance of a WebAuthn Credential attached to this user.
*
* @param array $properties
* @return \Laragear\WebAuthn\Models\WebAuthnCredential
*/
public function makeWebAuthnCredential(array $properties): WebAuthnCredential;
/**
* Returns a queryable relationship for its WebAuthn Credentials.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential
*/
public function webAuthnCredentials(): MorphMany;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Laragear\WebAuthn\Contracts;
interface WebAuthnException
{
//
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Laragear\WebAuthn\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
class CredentialCloned
{
use Dispatchable;
/**
* Create a new event instance.
*
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
* @param int $reportedCount The counter reported by the user authenticator.
*/
public function __construct(public WebAuthnCredential $credential, public int $reportedCount)
{
//
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Laragear\WebAuthn\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
class CredentialCreated
{
use Dispatchable;
/**
* Create a new event instance.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $user
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
*/
public function __construct(public WebAuthnAuthenticatable $user, public WebAuthnCredential $credential)
{
//
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Laragear\WebAuthn\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
class CredentialDisabled
{
use Dispatchable;
/**
* Create a new event instance.
*
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
*/
public function __construct(public WebAuthnCredential $credential)
{
//
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Laragear\WebAuthn\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Laragear\WebAuthn\Models\WebAuthnCredential;
class CredentialEnabled
{
use Dispatchable;
/**
* Create a new event instance.
*
* @param \Laragear\WebAuthn\Models\WebAuthnCredential $credential
*/
public function __construct(public WebAuthnCredential $credential)
{
//
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Laragear\WebAuthn\Exceptions;
use Illuminate\Validation\ValidationException;
use Laragear\WebAuthn\Contracts\WebAuthnException;
class AssertionException extends ValidationException implements WebAuthnException
{
/**
* Create a new Assertion Exception with the error message.
*
* @param string $message
* @return \Laragear\WebAuthn\Exceptions\AssertionException
*/
public static function make(string $message): static
{
return static::withMessages(['assertion' => "Assertion Error: $message"]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Laragear\WebAuthn\Exceptions;
use Illuminate\Validation\ValidationException;
use Laragear\WebAuthn\Contracts\WebAuthnException;
class AttestationException extends ValidationException implements WebAuthnException
{
/**
* Create a new Attestation Exception with the error message.
*
* @param string $message
* @return \Laragear\WebAuthn\Exceptions\AttestationException
*/
public static function make(string $message): static
{
return static::withMessages(['attestation' => "Attestation Error: $message"]);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Laragear\WebAuthn\Exceptions;
use Exception;
use Laragear\WebAuthn\Contracts\WebAuthnException;
class DataException extends Exception implements WebAuthnException
{
//
}

View File

@@ -0,0 +1,65 @@
<?php
namespace Laragear\WebAuthn\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use JetBrains\PhpStorm\ArrayShape;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
class AssertedRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
#[ArrayShape([
'id' => "string", 'rawId' => "string", 'response.authenticatorData' => "string",
'response.clientDataJSON' => "string", 'response.signature' => "string", 'response.userHandle' => "string",
'type' => "string"
])]
public function rules(): array
{
return [
'id' => 'required|string',
'rawId' => 'required|string',
'response.authenticatorData' => 'required|string',
'response.clientDataJSON' => 'required|string',
'response.signature' => 'required|string',
'response.userHandle' => 'sometimes|nullable',
'type' => 'required|string',
];
}
/**
* Check if the login request wants to remember the user as stateful.
*
* @return bool
*/
public function hasRemember(): bool
{
return $this->hasHeader('X-WebAuthn-Remember')
|| $this->hasHeader('WebAuthn-Remember')
|| $this->filled('remember');
}
/**
* Logs in the user for this assertion request.
*
* @param string|null $guard
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable&\Illuminate\Contracts\Auth\Authenticatable|null
*/
public function login(string $guard = null, bool $remember = null, bool $destroySession = false): ?WebAuthnAuthenticatable
{
$auth = Auth::guard($guard);
if ($auth->attempt($this->validated(), $remember ?? $this->hasRemember())) {
$this->session()->regenerate($destroySession);
return $auth->user();
}
return null;
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Laragear\WebAuthn\Http\Requests;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreation;
use Laragear\WebAuthn\Assertion\Creator\AssertionCreator;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\WebAuthn;
use function is_int;
use function is_string;
class AssertionRequest extends FormRequest
{
/**
* The Assertion Creation instance.
*
* @var \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
*/
protected AssertionCreation $assertion;
/**
* The guard to use to retrieve the user.
*
* @var string|null
*/
protected ?string $guard = null;
/**
* If the user may or may not be verified on login.
*
* @var string|null
*/
protected ?string $userVerification = null;
/**
* Validate the class instance.
*
* @return void
*/
public function validateResolved(): void
{
//
}
/**
* Return or make a new Assertion Creation.
*
* @return \Laragear\WebAuthn\Assertion\Creator\AssertionCreation
*/
protected function assertion(): AssertionCreation
{
return $this->assertion ??= new AssertionCreation($this);
}
/**
* Sets the WebAuthn-compatible guard to use.
*
* @param string $guard
* @return $this
*/
public function guard(string $guard): static
{
$this->guard = $guard;
return $this;
}
/**
* Makes the authenticator to only check for user presence on login.
*
* @return $this
*/
public function fastLogin(): static
{
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
return $this;
}
/**
* Makes the authenticator to always verify the user thoroughly on login.
*
* @return $this
*/
public function secureLogin(): static
{
$this->assertion()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
return $this;
}
/**
* Creates an assertion challenge for a user if found.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|string|int|array|null $credentials
* @return \Illuminate\Contracts\Support\Responsable
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function toVerify(WebAuthnAuthenticatable|string|int|array|null $credentials = []): Responsable
{
$this->assertion()->user = $this->findUser($credentials);
return $this->container
->make(AssertionCreator::class)
->send($this->assertion)
->then(static function (AssertionCreation $creation): Responsable {
return $creation->json;
});
}
/**
* Try to find a user to create an assertion procedure.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|array|int|string|null $credentials
* @return \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
protected function findUser(WebAuthnAuthenticatable|array|int|string|null $credentials): ?WebAuthnAuthenticatable
{
if (!$credentials) {
return null;
}
if ($credentials instanceof WebAuthnAuthenticatable) {
return $credentials;
}
// If the developer is using a string or integer, we will understand its trying to
// retrieve by its ID, otherwise we will fall back to credentials. Once done, we
// will check it uses WebAuthn if is not null, otherwise we'll fail miserably.
$user = is_string($credentials) || is_int($credentials)
? Auth::guard($this->guard)->getProvider()->retrieveById($credentials)
: Auth::guard($this->guard)->getProvider()->retrieveByCredentials($credentials);
if ($user && ! $user instanceof WebAuthnAuthenticatable) {
$guard = $this->guard ?? $this->container->make('config')->get('auth.defaults.guard');
throw new InvalidArgumentException(
"The user found for the [$guard] auth guard is not an instance of [WebAuthnAuthenticatable]."
);
}
return $user;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace Laragear\WebAuthn\Http\Requests;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Foundation\Http\FormRequest;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreation;
use Laragear\WebAuthn\Attestation\Creator\AttestationCreator;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\WebAuthn;
/**
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
*/
class AttestationRequest extends FormRequest
{
/**
* The attestation instance that would be returned.
*
* @var \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
*/
protected AttestationCreation $attestation;
/**
* Validate the class instance.
*
* @return void
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function validateResolved(): void
{
if (!$this->passesAuthorization()) {
$this->failedAuthorization();
}
}
/**
* Determine if the user is authorized to make this request.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
* @return bool
*/
public function authorize(?WebAuthnAuthenticatable $user): bool
{
return (bool) $user;
}
/**
* Returns the existing attestation instance.
*
* @return \Laragear\WebAuthn\Attestation\Creator\AttestationCreation
*/
protected function attestation(): AttestationCreation
{
return $this->attestation ??= new AttestationCreation($this->user(), $this);
}
/**
* Makes the authenticator to only check for user presence on registration.
*
* @return $this
*/
public function fastRegistration(): static
{
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_DISCOURAGED;
return $this;
}
/**
* Makes the authenticator to always verify the user thoroughly on registration.
*
* @return $this
*/
public function secureRegistration(): static
{
$this->attestation()->userVerification = WebAuthn::USER_VERIFICATION_REQUIRED;
return $this;
}
/**
* Tells the authenticator use this credential to login instantly, instead of asking for one.
*
* @return $this
*/
public function userless(): static
{
$this->attestation()->residentKey = WebAuthn::RESIDENT_KEY_REQUIRED;
return $this;
}
/**
* Allows the device to create multiple credentials for the same user for this app.
*
* @return $this
*/
public function allowDuplicates(): static
{
$this->attestation()->uniqueCredentials = false;
return $this;
}
/**
* Returns a response with the instructions to create a WebAuthn Credential.
*
* @return \Illuminate\Contracts\Support\Responsable
*/
public function toCreate(): Responsable
{
return $this->container
->make(AttestationCreator::class)
->send($this->attestation())
->then(static function (AttestationCreation $creation): Responsable {
return $creation->json;
});
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Laragear\WebAuthn\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use JetBrains\PhpStorm\ArrayShape;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidator;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
use Laragear\WebAuthn\Events\CredentialCreated;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use function is_callable;
/**
* @method \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable user($guard = null)
*/
class AttestedRequest extends FormRequest
{
/**
* The new credential instance.
*
* @var \Laragear\WebAuthn\Models\WebAuthnCredential
*/
protected WebAuthnCredential $credential;
/**
* Determine if the user is authorized to make this request.
*
* @param \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable|null $user
* @return bool
*/
public function authorize(?WebAuthnAuthenticatable $user): bool
{
return (bool) $user;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
#[ArrayShape([
'id' => "string", 'rawId' => "string", 'response' => "string", 'response.clientDataJSON' => "string",
'response.attestationObject' => "string", 'type' => "string"
])]
public function rules(): array
{
return [
'id' => 'required|string',
'rawId' => 'required|string',
'response' => 'required|array',
'response.clientDataJSON' => 'required|string',
'response.attestationObject' => 'required|string',
'type' => 'required|string',
];
}
/**
* Handle a passed validation attempt.
*
* @return void
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
protected function passedValidation(): void
{
$this->credential = $this->container->make(AttestationValidator::class)
->send(new AttestationValidation($this->user(), $this))
->then(static function (AttestationValidation $validation): WebAuthnCredential {
return $validation->credential;
});
}
/**
* Save and return the generated WebAuthn Credentials.
*
* @param array|callable $saving
* @return string
*/
public function save(array|callable $saving = []): string
{
is_callable($saving) ? $saving($this->credential) : $this->credential->forceFill($saving);
$this->credential->save();
CredentialCreated::dispatch($this->user(), $this->credential);
return $this->credential->getKey();
}
}

106
src/JsonTransport.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
namespace Laragear\WebAuthn;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use JsonSerializable;
use Stringable;
use function json_encode;
/**
* This class will help us build JSON responses by setting and checking for its keys.
*
* @internal
*/
class JsonTransport implements Arrayable, Jsonable, JsonSerializable, Stringable, Responsable
{
/**
* Create a new JSON transport.
*
* @param array $json
*/
public function __construct(public array $json = [])
{
//
}
/**
* Adds a value to the underlying JSON array.
*
* @param string $key
* @param mixed $value
* @return void
*/
public function set(string $key, mixed $value): void
{
Arr::set($this->json, $key, $value);
}
/**
* Retrieves a value from the underlying JSON array.
*
* @param string $key
* @param string|int|null $default
* @return string|int|null
*/
public function get(string $key, string|int $default = null): string|int|null
{
return Arr::get($this->json, $key, $default);
}
/**
* Convert the object to its JSON representation.
*
* @param int $options
* @return string
*/
public function toJson($options = 0): string
{
return json_encode($this->jsonSerialize(), JSON_THROW_ON_ERROR | $options);
}
/**
* Get the instance as an array.
*
* @return array<string, int|string|\Laragear\WebAuthn\ByteBuffer>
*/
public function toArray()
{
return $this->json;
}
/**
* Specify data which should be serialized to JSON.
*
* @return array
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Returns a string representation of the object.
*
* @return string
*/
public function __toString(): string
{
return $this->toJson();
}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function toResponse($request): JsonResponse
{
return new JsonResponse($this);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace Laragear\WebAuthn\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Laragear\WebAuthn\Events\CredentialDisabled;
use Laragear\WebAuthn\Events\CredentialEnabled;
/**
* @mixin \Illuminate\Database\Eloquent\Builder<\Laragear\WebAuthn\Models\WebAuthnCredential>
*
* @method static \Illuminate\Database\Eloquent\Builder|static query()
* @method \Illuminate\Database\Eloquent\Builder|static newQuery()
* @method static static make(array $attributes = [])
* @method static static create(array $attributes = [])
* @method static static forceCreate(array $attributes)
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrNew(array $attributes = [], array $values = [])
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrFail($columns = ['*'])
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOrCreate(array $attributes, array $values = [])
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstOr($columns = ['*'], \Closure $callback = null)
* @method \Laragear\WebAuthn\Models\WebAuthnCredential firstWhere($column, $operator = null, $value = null, $boolean = 'and')
* @method \Laragear\WebAuthn\Models\WebAuthnCredential updateOrCreate(array $attributes, array $values = [])
* @method static|null first($columns = ['*'])
* @method static static findOrFail($id, $columns = ['*'])
* @method static static findOrNew($id, $columns = ['*'])
* @method static static|null find($id, $columns = ['*'])
*
* @property-read string $id
*
* @property-read string $user_id
* @property string|null $alias
*
* @property-read int $counter
* @property-read string $rp_id
* @property-read string $origin
* @property-read array<int, string>|null $transports
* @property-read string $aaguid
*
* @property-read string $public_key
* @property-read string $attestation_format
* @property-read array<int, string> $certificates
*
* @property-read \Illuminate\Support\Carbon|null $disabled_at
*
* @property-read \Laragear\WebAuthn\ByteBuffer $binary_id
*
* @property-read \Illuminate\Support\Carbon $updated_at
* @property-read \Illuminate\Support\Carbon $created_at
*
* @property-read \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable $authenticatable
*
* @method \Illuminate\Database\Eloquent\Builder|static whereEnabled()
* @method \Illuminate\Database\Eloquent\Builder|static whereDisabled()
*/
class WebAuthnCredential extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'webauthn_credentials';
/**
* The "type" of the primary key ID.
*
* @var string
*/
protected $keyType = 'string';
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = false;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'counter' => 'int',
'transports' => 'array',
'public_key' => 'encrypted',
'certificates' => 'array',
'disabled_at' => 'timestamp',
];
/**
* The attributes that should be visible in serialization.
*
* @var array<int, string>
*/
protected $visible = ['id', 'origin', 'alias', 'aaguid', 'attestation_format', 'disabled_at', 'is_enabled'];
/**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo&\Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
*/
public function authenticatable(): MorphTo
{
return $this->morphTo('authenticatable');
}
/**
* Filter the query by enabled credentials.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeWhereEnabled(Builder $query): Builder
{
return $query->whereNull('disabled_at');
}
/**
* Filter the query by disabled credentials.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
protected function scopeWhereDisabled(Builder $query): Builder
{
return $query->whereNotNull('disabled_at');
}
/**
* Check if the credential is enabled.
*
* @return bool
*/
public function isEnabled(): bool
{
return null === $this->attributes['disabled_at'];
}
/**
* Check if the credential is disabled.
*
* @return bool
*/
public function isDisabled(): bool
{
return !$this->isEnabled();
}
/**
* Enables the credential to be used with WebAuthn.
*
* @return void
*/
public function enable(): void
{
$wasDisabled = (bool) $this->attributes['disabled_at'];
$this->attributes['disabled_at'] = null;
$this->save();
if ($wasDisabled) {
CredentialEnabled::dispatch($this);
}
}
/**
* Disables the credential for WebAuthn.
*
* @return void
*/
public function disable(): void
{
$wasEnabled = ! $this->attributes['disabled_at'];
$this->setAttribute('disabled_at', $this->freshTimestamp())->save();
if ($wasEnabled) {
CredentialDisabled::dispatch($this);
}
}
/**
* Increments the assertion counter by 1.
*
* @param int $counter
* @return void
*/
public function syncCounter(int $counter): void
{
$this->attributes['counter'] = $counter;
$this->save();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
/**
* @internal
*/
abstract class CheckChallengeSame
{
use ThrowsCeremonyException;
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
if ($validation->clientDataJson->challenge->hasNoLength()) {
static::throw($validation, "Response has an empty challenge.");
}
if ($validation->clientDataJson->challenge->hashNotEqual($validation->challenge->data)) {
static::throw($validation, "Response challenge is not equal.");
}
return $next($validation);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use function parse_url;
abstract class CheckOriginSecure
{
use ThrowsCeremonyException;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
if (!$validation->clientDataJson->origin) {
static::throw($validation, 'Response has an empty origin.');
}
$origin = parse_url($validation->clientDataJson->origin);
if (!$origin || !isset($origin['host'], $origin['scheme'])) {
static::throw($validation, 'Response origin is invalid.');
}
if ($origin['host'] !== 'localhost' && $origin['scheme'] !== 'https') {
static::throw($validation, 'Response not made to a secure server (localhost or HTTPS).');
}
return $next($validation);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\AuthenticatorData;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use function parse_url;
use const PHP_URL_HOST;
/**
* @internal
*/
abstract class CheckRelyingPartyHashSame
{
use ThrowsCeremonyException;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
// This way we can get the app RP ID on attestation, and the Credential RP ID
// on assertion. The credential will have the same Relaying Party ID on both
// the authenticator and the application so on assertion both should match.
$relayingParty = parse_url($this->relyingPartyId($validation), PHP_URL_HOST);
if ($this->authenticatorData($validation)->hasNotSameRPIdHash($relayingParty)) {
static::throw($validation, 'Response has different Relying Party ID hash.');
}
return $next($validation);
}
/**
* 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
*/
abstract protected function authenticatorData(
AttestationValidation|AssertionValidation $validation
): 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
*/
abstract protected function relyingPartyId(AssertionValidation|AttestationValidation $validation): string;
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Support\Str;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use function hash_equals;
use function parse_url;
use const PHP_URL_HOST;
/**
* @internal
*/
abstract class CheckRelyingPartyIdContained
{
use ThrowsCeremonyException;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
if (!$host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) {
static::throw($validation, 'Relaying Party ID is invalid.');
}
$current = parse_url(
$this->config->get('webauthn.relaying_party.id') ?? $this->config->get('app.url'), PHP_URL_HOST
);
// Check the host is the same or is a subdomain of the current config domain.
if (hash_equals($current, $host) || Str::is("*.$current", $host)) {
return $next($validation);
}
static::throw($validation, 'Relaying Party ID not scoped to current.');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
/**
* @internal
*/
abstract class CheckUserInteraction
{
use ThrowsCeremonyException;
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
$notPresent = $validation instanceof AttestationValidation
? $validation->attestationObject->authenticatorData->wasUserAbsent()
: $validation->authenticatorData->wasUserAbsent();
if ($notPresent) {
static::throw($validation, 'Response did not have the user present.');
}
// Only verify the user if the challenge required it.
if ($validation->challenge->verify) {
$notVerified = $validation instanceof AttestationValidation
? $validation->attestationObject->authenticatorData->wasUserNotVerified()
: $validation->authenticatorData->wasUserNotVerified();
if ($notVerified) {
static::throw($validation, 'Response did not verify the user.');
}
}
return $next($validation);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use JsonException;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\ByteBuffer;
use Laragear\WebAuthn\ClientDataJson;
use function base64_decode;
use function json_decode;
use const JSON_THROW_ON_ERROR;
/**
* @internal
*/
abstract class CompileClientDataJson
{
use ThrowsCeremonyException;
/**
* Handle the incoming WebAuthn Ceremony Validation.
*
* @param \Laragear\WebAuthn\Assertion\Validator\AssertionValidation|\Laragear\WebAuthn\Attestation\Validator\AttestationValidation $validation
* @param \Closure $next
* @return mixed
* @throws \Laragear\WebAuthn\Exceptions\AttestationException
* @throws \Laragear\WebAuthn\Exceptions\AssertionException
*/
public function handle(AssertionValidation|AttestationValidation $validation, Closure $next): mixed
{
try {
$object = json_decode(
base64_decode($validation->request->json('response.clientDataJSON', '')), false, 32, JSON_THROW_ON_ERROR
);
} catch (JsonException) {
static::throw($validation, 'Client Data JSON is invalid or malformed.');
}
if (!$object) {
static::throw($validation, 'Client Data JSON is empty.');
}
foreach (['type', 'origin', 'challenge'] as $key) {
if (!isset($object->{$key})) {
static::throw($validation, "Client Data JSON does not contain the [$key] key.");
}
}
$validation->clientDataJson = new ClientDataJson(
$object->type, $object->origin, ByteBuffer::fromBase64Url($object->challenge)
);
return $next($validation);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Closure;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Http\Request;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Challenge;
/**
* This should be the first pipe to run, as the Challenge may expire by mere milliseconds.
*
* @internal
*/
abstract class RetrieveChallenge
{
use ThrowsCeremonyException;
/**
* Create a new pipe instance.
*
* @param \Illuminate\Contracts\Config\Repository $config
*/
public function __construct(protected Repository $config)
{
//
}
/**
* Handle the incoming Assertion Validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param \Closure $next
* @return mixed
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
$validation->challenge = $this->retrieveChallenge($validation->request);
if (!$validation->challenge) {
static::throw($validation, 'Challenge does not exist.');
}
return $next($validation);
}
/**
* Pulls an Attestation challenge from the Cache.
*
* @param \Illuminate\Http\Request $request
* @return \Laragear\WebAuthn\Challenge|null
*/
protected function retrieveChallenge(Request $request): ?Challenge
{
/** @var \Laragear\WebAuthn\Challenge|null $challenge */
$challenge = $request->session()->pull($this->config->get('webauthn.challenge.key'));
if (!$challenge || $challenge->hasExpired()) {
return null;
}
return $challenge;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Laragear\WebAuthn\SharedPipes;
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;
use Laragear\WebAuthn\Exceptions\AssertionException;
use Laragear\WebAuthn\Exceptions\AttestationException;
/**
* @internal
*/
trait ThrowsCeremonyException
{
/**
* Throws an exception for the validation.
*
* @param \Laragear\WebAuthn\Attestation\Validator\AttestationValidation|\Laragear\WebAuthn\Assertion\Validator\AssertionValidation $validation
* @param string $message
* @return void&never
* @throws \Laragear\WebAuthn\Exceptions\AssertionException|\Laragear\WebAuthn\Exceptions\AttestationException
*/
protected static function throw(AttestationValidation|AssertionValidation $validation, string $message): void
{
throw $validation instanceof AssertionValidation
? AssertionException::make($message)
: AttestationException::make($message);
}
}

38
src/WebAuthn.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace Laragear\WebAuthn;
/**
* @internal
*/
class WebAuthn
{
// Constants for user verification in Attestation and Assertion.
public const USER_VERIFICATION_PREFERRED = 'preferred';
public const USER_VERIFICATION_DISCOURAGED = 'discouraged';
public const USER_VERIFICATION_REQUIRED = 'required';
// Attestation variables to limit the authenticator conveyance.
public const PLATFORMS = ['cross-platform', 'platform'];
public const TRANSPORTS = ['usb', 'nfc', 'ble', 'internal'];
public const FORMATS = ['none', 'android-key', 'android-safetynet', 'apple', 'fido-u2f', 'packed', 'tpm'];
// Resident Keys requirement.
public const RESIDENT_KEY_REQUIRED = 'required';
public const RESIDENT_KEY_PREFERRED = 'preferred';
public const RESIDENT_KEY_DISCOURAGED = 'discouraged';
/**
* Returns all user verifications flags possible.
*
* @return string[]
*/
public static function userVerifications(): array
{
return [
static::USER_VERIFICATION_REQUIRED,
static::USER_VERIFICATION_PREFERRED,
static::USER_VERIFICATION_DISCOURAGED,
];
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Laragear\WebAuthn;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use JetBrains\PhpStorm\ArrayShape;
use Laragear\WebAuthn\Models\WebAuthnCredential;
use function in_array;
/**
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laragear\WebAuthn\Models\WebAuthnCredential> $webAuthnCredentials
*
* @see \Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable
* @see \Laragear\WebAuthn\Models\WebAuthnCredential
*/
trait WebAuthnAuthentication
{
/**
* Returns displayable data to be used to create WebAuthn Credentials.
*
* @return array{name: string, displayName: string}
*/
#[ArrayShape(['name' => "string", 'displayName' => "string"])]
public function webAuthnData(): array
{
return [
'name' => $this->email,
'displayName' => $this->name,
];
}
/**
* Removes all credentials previously registered.
*
* @param string ...$except
* @return void
*/
public function flushCredentials(string ...$except): void
{
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) {
$partitioned = $this->webAuthnCredentials
->partition(static function (WebAuthnCredential $credential) use ($except): bool {
return in_array($credential->getKey(), $except, true);
});
$partitioned->first()->each->delete();
$this->setRelation('webAuthnCredentials', $partitioned->last());
return;
}
$this->webAuthnCredentials()->whereKeyNot($except)->delete();
}
/**
* Disables all credentials for the user.
*
* @param string ...$except
* @return void
*/
public function disableAllCredentials(string ...$except): void
{
if ($this->relationLoaded('webAuthnCredentials') && $this->webAuthnCredentials instanceof Collection) {
$this->webAuthnCredentials
->each(static function (WebAuthnCredential $credential) use ($except): bool {
if ($credential->isEnabled() && in_array($credential->getKey(), $except, true)) {
$credential->disable();
}
});
} else {
$this->webAuthnCredentials()->whereKeyNot($except)->update(['is_enabled' => false]);
}
}
/**
* Makes an instance of a WebAuthn Credential attached to this user.
*
* @param array $properties
* @return \Laragear\WebAuthn\Models\WebAuthnCredential
*/
public function makeWebAuthnCredential(array $properties): Models\WebAuthnCredential
{
return $this->webAuthnCredentials()->make()->forceFill($properties);
}
/**
* Returns a queryable relationship for its WebAuthn Credentials.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany&\Laragear\WebAuthn\Models\WebAuthnCredential
*/
public function webAuthnCredentials(): MorphMany
{
return $this->morphMany(Models\WebAuthnCredential::class, 'authenticatable');
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Laragear\WebAuthn;
use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable;
/**
* @internal
*/
class WebAuthnServiceProvider extends ServiceProvider
{
public const ROUTES = __DIR__.'/../routes/webauthn.php';
public const CONTROLLERS = __DIR__.'/../stubs/controllers';
public const CONFIG = __DIR__.'/../config/webauthn.php';
public const MIGRATIONS = __DIR__.'/../database/migrations';
public const JS = __DIR__.'/../resources/js';
/**
* Register the service provider.
*
* @return void
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function register(): void
{
$this->mergeConfigFrom(static::CONFIG, 'webauthn');
$this->registerUser();
$this->registerUserProvider();
}
/**
* Boot the service provider.
*
* @return void
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishesMigrations(static::MIGRATIONS);
$this->publishes([static::ROUTES => $this->app->basePath('routes/webauthn.php')], 'routes');
$this->publishes([static::CONTROLLERS => $this->app->path('Http/Controllers/WebAuthn')], 'controllers');
$this->publishes([static::JS => $this->app->resourcePath('js/vendor/webauthn')], 'js');
}
}
/**
* Publishes migrations from the given path.
*
* @param array|string $paths
* @param string $groups
* @return void
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
protected function publishesMigrations(array|string $paths, string $groups = 'migrations'): void
{
$prefix = now()->format('Y_m_d_His');
$files = [];
foreach ($this->app->make('files')->files($paths) as $file) {
$filename = preg_replace('/^[\d|_]+/', '', $file->getFilename());
$files[$file->getRealPath()] = $this->app->databasePath("migrations/{$prefix}_$filename");
}
$this->publishes($files, $groups);
}
/**
* Registers the Web Authenticatable User.
*
* @return void
*/
protected function registerUser(): void
{
$this->app->bind(
Contracts\WebAuthnAuthenticatable::class,
static function (Application $app): ?Contracts\WebAuthnAuthenticatable {
$user = $app->make(AuthenticatableContract::class);
return $user instanceof WebAuthnAuthenticatable ? $user : null;
}
);
}
/**
* Extends the Authentication Factory with a WebAuthn Eloquent-Compatible User Provider.
*
* @return void
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
protected function registerUserProvider(): void
{
$this->callAfterResolving('auth', static function (AuthManager $auth): void {
$auth->provider(
'eloquent-webauthn',
static function (Application $app, array $config): Auth\WebAuthnUserProvider {
return new Auth\WebAuthnUserProvider(
$app->make('hash'),
$config['model'],
$app->make(Assertion\Validator\AssertionValidator::class),
$config['password_fallback'] ?? true,
);
}
);
});
}
}