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,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);
}
}