Skip to content

Commit

Permalink
Handle android app origin (web-auth#393)
Browse files Browse the repository at this point in the history
  • Loading branch information
giann committed Apr 25, 2023
1 parent 81dfb98 commit 9e262e2
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 25 deletions.
84 changes: 60 additions & 24 deletions src/webauthn/src/AuthenticatorAttestationResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,28 +172,64 @@ public function check(
->getAuthData()
->getExtensions()
);
$parsedRelyingPartyId = parse_url($C->getOrigin());
is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
sprintf('The origin URI "%s" is not valid', $C->getOrigin())
);
array_key_exists(
'scheme',
$parsedRelyingPartyId
) || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
$clientDataRpId !== '' || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
mb_substr(
'.' . $clientDataRpId,
-($rpIdLength + 1)
) === '.' . $facetId || throw AuthenticatorResponseVerificationException::create('rpId mismatch.');
if (! in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'];
$scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
'Invalid scheme. HTTPS required.'

$origin = $C->getOrigin();

// Android app origin
if (str_starts_with($origin, 'android:apk-key-hash:')) {
$apkKeyHash = base64_decode(substr($origin, 21));

// Get https://<relying-party>/.well-known/assetlinks.json
$assetlinksUrl = 'https://' . $rpId . '/.well-known/assetlinks.json';
$assetlinks = CollectedAssetLinks::createFromJson(@file_get_contents($assetlinksUrl) ?: '');

// Check that it contains this apk key hash
$foundMatchingKey = false;
foreach ($assetlinks->getAssetLinks() as $assetLink) {
if ($assetLink->getTargetNamespace() === 'android_app' && $assetLink->getTargetPackageName() === $C->getAndroidPackageName()) {
foreach ($assetLink->getTargetSha256CertFingerPrints() ?? [] as $fingerprint) {
$fingerprint = explode(':', $fingerprint);
$fingerprint = array_map(fn ($e) => hexdec($e), $fingerprint);
$fingerprint = pack('C' . count($fingerprint), ...$fingerprint);

$foundMatchingKey = $fingerprint == $apkKeyHash;

if ($foundMatchingKey) {
break;
}
}
}
}

$foundMatchingKey || throw AuthenticatorResponseVerificationException::create(
'No matching apk signing certificate signature found'
);
} else {
// Web origin
$parsedRelyingPartyId = parse_url($C->getOrigin());
is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
sprintf('The origin URI "%s" is not valid', $C->getOrigin())
);
array_key_exists(
'scheme',
$parsedRelyingPartyId
) || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
$clientDataRpId !== '' || throw AuthenticatorResponseVerificationException::create('Invalid origin rpId.');
$rpIdLength = mb_strlen($facetId);
mb_substr(
'.' . $clientDataRpId,
- ($rpIdLength + 1)
) === '.' . $facetId || throw AuthenticatorResponseVerificationException::create('rpId mismatch.');
if (!in_array($facetId, $securedRelyingPartyId, true)) {
$scheme = $parsedRelyingPartyId['scheme'];
$scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
'Invalid scheme. HTTPS required.'
);
}
}
if (! is_string($request) && $C->getTokenBinding() !== null) {

if (!is_string($request) && $C->getTokenBinding() !== null) {
$this->tokenBindingHandler?->check($C->getTokenBinding(), $request);
}
$clientDataJSONHash = hash(
Expand Down Expand Up @@ -349,7 +385,7 @@ private function checkCertificateChain(
?MetadataStatement $metadataStatement
): void {
$trustPath = $attestationStatement->getTrustPath();
if (! $trustPath instanceof CertificateTrustPath) {
if (!$trustPath instanceof CertificateTrustPath) {
return;
}
$authenticatorCertificates = $trustPath->getCertificates();
Expand Down Expand Up @@ -502,16 +538,16 @@ private function getFacetId(
AuthenticationExtensionsClientInputs $authenticationExtensionsClientInputs,
?AuthenticationExtensionsClientOutputs $authenticationExtensionsClientOutputs
): string {
if ($authenticationExtensionsClientOutputs === null || ! $authenticationExtensionsClientInputs->has(
if ($authenticationExtensionsClientOutputs === null || !$authenticationExtensionsClientInputs->has(
'appid'
) || ! $authenticationExtensionsClientOutputs->has('appid')) {
) || !$authenticationExtensionsClientOutputs->has('appid')) {
return $rpId;
}
$appId = $authenticationExtensionsClientInputs->get('appid')
->value();
$wasUsed = $authenticationExtensionsClientOutputs->get('appid')
->value();
if (! is_string($appId) || $wasUsed !== true) {
if (!is_string($appId) || $wasUsed !== true) {
return $rpId;
}
return $appId;
Expand Down
159 changes: 159 additions & 0 deletions src/webauthn/src/CollectedAssetLinks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

declare(strict_types=1);

namespace Webauthn;

use Webauthn\Exception\InvalidDataException;

class CollectedAssetLinks
{
/**
* @var mixed[]
*/
private readonly array $data;

/**
* @var CollectedAssetLink[]
*/
private readonly array $assetLinks;

/**
* @param mixed[] $data
*/
public function __construct(
private readonly string $rawData,
array $data
) {
$this->assetLinks = [];
foreach ($data as $assetLink) {
$this->assetLinks[] = new CollectedAssetLink($assetLink);
}

$this->data = $data;
}

public static function createFromJson(string $data): self
{
$json = json_decode($data, true, 512, JSON_THROW_ON_ERROR);

return new self($data, $json);
}

/**
* @return CollectedAssetLink[]
*/
public function getAssetLinks(): array
{
return $this->assetLinks;
}

/**
* @return string[]
*/
public function all(): array
{
return array_keys($this->data);
}

public function has(string $key): bool
{
return array_key_exists($key, $this->data);
}

public function get(string $key): mixed
{
if (!$this->has($key)) {
throw InvalidDataException::create($this->data, sprintf('The key "%s" is missing', $key));
}

return $this->data[$key];
}
}

class CollectedAssetLink
{
/** @var string[] */
private readonly array $relation;

private readonly string $targetNamespace;

private readonly ?string $targetPackageName;

/** @var string[]|null */
private readonly ?array $targetSha256CertFingerPrints;

private readonly ?string $targetSite;

/**
* @param mixed[] $data
*/
public function __construct(
array $data
) {
$relation = $data['relation'] ?? null;
is_array($relation) || throw InvalidDataException::create(
$data,
'Invalid parameter "relation". Shall be an array.'
);
$this->relation = $relation;

$target = $data['target'] ?? [];
(is_array($target) && !empty($target)) || throw InvalidDataException::create(
$data,
'Invalid parameter "target". Shall be a non-empty array.'
);

$namespace = $target['namespace'] ?? '';
(is_string($namespace) && $namespace !== '') || throw InvalidDataException::create(
$data,
'Invalid parameter "namespace". Shall be a non-empty string.'
);
$this->targetNamespace = $namespace;

$packageName = $target['package_name'] ?? null;
(is_string($packageName) && $packageName !== '') || $packageName === null || throw InvalidDataException::create(
$data,
'Invalid parameter "package_name". Shall be a non-empty string or null.'
);
$this->targetNamespace = $packageName;

$sha256CertFingerPrints = $target['sha256_cert_fingerprints'] ?? null;
is_array($sha256CertFingerPrints) || $sha256CertFingerPrints === null || throw InvalidDataException::create(
$data,
'Invalid parameter "sha256_cert_fingerprints". Shall be an array of string or null.'
);
$this->targetSha256CertFingerPrints = $sha256CertFingerPrints;

$targetSite = $target['site'] ?? null;
is_string($targetSite) || $targetSite === null || throw InvalidDataException::create(
$data,
'Invalid parameter "site". Shall be a string or null.'
);
}

public function getRelation(): array
{
return $this->relation;
}

public function getTargetNamespace(): string
{
return $this->targetNamespace;
}

public function getTargetPackageName(): ?string
{
return $this->targetPackageName;
}

public function getTargetSha256CertFingerPrints(): ?array
{
return $this->targetSha256CertFingerPrints;
}

public function getTargetSite(): ?string
{
return $this->targetSite;
}
}
15 changes: 14 additions & 1 deletion src/webauthn/src/CollectedClientData.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class CollectedClientData

private readonly bool $crossOrigin;

private readonly ?string $androidPackageName;

/**
* @var mixed[]|null
* @deprecated Since 4.3.0 and will be removed in 5.0.0
Expand Down Expand Up @@ -75,6 +77,12 @@ public function __construct(
);
$this->tokenBinding = $tokenBinding;

$androidPackageName = $data['androidPackageName'] ?? null;
$androidPackageName === null || is_string($androidPackageName) || throw InvalidDataException::create(
$data,
'Invalid parameter "androidPackageName". Shall be a string or .'
);

$this->data = $data;
}

Expand Down Expand Up @@ -106,6 +114,11 @@ public function getCrossOrigin(): bool
return $this->crossOrigin;
}

public function getAndroidPackageName(): ?string
{
return $this->androidPackageName;
}

/**
* @deprecated Since 4.3.0 and will be removed in 5.0.0
*/
Expand Down Expand Up @@ -134,7 +147,7 @@ public function has(string $key): bool

public function get(string $key): mixed
{
if (! $this->has($key)) {
if (!$this->has($key)) {
throw InvalidDataException::create($this->data, sprintf('The key "%s" is missing', $key));
}

Expand Down

0 comments on commit 9e262e2

Please sign in to comment.