Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse transports and pass them through to the credential #55

Merged
merged 9 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const dataForResponseParser = {
type: credential.type,
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
transports: credential.response.getTransports(),
}

// Send this to your endpoint - adjust to your needs.
Expand Down
1 change: 1 addition & 0 deletions examples/readmeRegisterStep2.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const startRegister = async (e) => {
type: credential.type,
attestationObject: Array.from(new Uint8Array(credential.response.attestationObject)),
clientDataJSON: Array.from(new Uint8Array(credential.response.clientDataJSON)),
transports: credential.response.getTransports(),
}

// Send this to your endpoint - adjust to your needs.
Expand Down
18 changes: 18 additions & 0 deletions src/ArrayBufferResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

namespace Firehed\WebAuthn;

use function array_filter;
use function array_key_exists;
use function array_map;
use function is_array;

/**
* Translates the library-official over-the-wire data formats into the
* necessary data structures for subsequent authentication procedures.
Expand Down Expand Up @@ -32,6 +37,7 @@ class ArrayBufferResponseParser implements ResponseParserInterface
* type: credential.type,
* attestationObject: new Uint8Array(credential.response.attestationObject),
* clientDataJSON: new Uint8Array(credential.response.clientDataJSON),
* transports: credential.response.getTransports(),
* }
* ```
*
Expand All @@ -42,8 +48,13 @@ class ArrayBufferResponseParser implements ResponseParserInterface
* type: string,
* attestationObject: int[],
* clientDataJSON: int[],
* transports?: string[],
* }
*
* Note that `transports` has been added after the original data format.
* It's RECOMMENDED to be provided in all requests, but this should avoid
* disrupting applictions where it is not.
*
* $response is left untyped since it performs additional checking from
* untrusted user data.
*
Expand All @@ -63,10 +74,17 @@ public function parseCreateResponse(array $response): Responses\AttestationInter
if (!array_key_exists('clientDataJSON', $response) || !is_array($response['clientDataJSON'])) {
throw new Errors\ParseError('7.1.2', 'response.clientDataJSON');
}

$transports = array_filter(array_map(
Enums\AuthenticatorTransport::tryFrom(...),
$response['transports'] ?? []
));

return new CreateResponse(
id: BinaryString::fromBytes($response['rawId']),
ao: Attestations\AttestationObject::fromCbor(BinaryString::fromBytes($response['attestationObject'])),
clientDataJson: BinaryString::fromBytes($response['clientDataJSON']),
transports: $transports,
);
}

Expand Down
8 changes: 7 additions & 1 deletion src/CreateResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@
*/
class CreateResponse implements Responses\AttestationInterface
{
/**
* Note: transports are made public to simplify testing, and are not
* considered part of any sort of public API.
*
* @param Enums\AuthenticatorTransport[] $transports
*/
public function __construct(
private BinaryString $id,
private Attestations\AttestationObjectInterface $ao,
private BinaryString $clientDataJson,
public readonly array $transports,
) {
}

Expand Down Expand Up @@ -160,7 +167,6 @@ public function verify(
coseKey: $data->coseKey,
);


// This is not part of the official procedure, but serves as a general
// sanity-check around data handling.
assert($this->id->equals($data->credentialId));
Expand Down
7 changes: 7 additions & 0 deletions src/JsonResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,19 @@ public function parseCreateResponse(array $data): Responses\AttestationInterface
if (!array_key_exists('clientDataJSON', $response) || !is_string($response['clientDataJSON'])) {
throw new Errors\ParseError('7.1.2', 'response.clientDataJSON');
}
if (!array_key_exists('transports', $response) || !is_array($response['transports'])) {
throw new Errors\ParseError('7.1.2', 'response.transports');
}
// "client platforms MUST ignore unknown values" -> tryFrom+filter
$transports = array_filter(array_map(Enums\AuthenticatorTransport::tryFrom(...), $response['transports']));

return new CreateResponse(
id: self::parse($data['rawId'], '7.1.2', 'rawId'),
ao: Attestations\AttestationObject::fromCbor(
self::parse($response['attestationObject'], '7.1.2', 'response.attestationObject'),
),
clientDataJson: self::parse($response['clientDataJSON'], '7.1.2', 'response.clientDataJSON'),
transports: $transports,
);
}

Expand Down
25 changes: 25 additions & 0 deletions tests/ArrayBufferResponseParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ public function testParseCreateResponse(string $directory): void
self::assertInstanceOf(CreateResponse::class, $attestation);
}

public function testParseCreateResponseWithTransports(): void
{
$response = $this->readFixture('touchid/register.json');
$response['transports'] = ['internal', 'hybrid'];

$parser = new ArrayBufferResponseParser();
$parsed = $parser->parseCreateResponse($response);
self::assertEqualsCanonicalizing([
Enums\AuthenticatorTransport::Hybrid,
Enums\AuthenticatorTransport::Internal,
], $parsed->transports); // @phpstan-ignore-line (interface/impl cheat)
}

public function testParseCreateResponseWithInvalidTransports(): void
{
$response = $this->readFixture('touchid/register.json');
$response['transports'] = ['invalid', 'usb'];

$parser = new ArrayBufferResponseParser();
$parsed = $parser->parseCreateResponse($response);
self::assertEqualsCanonicalizing([
Enums\AuthenticatorTransport::Usb,
], $parsed->transports); // @phpstan-ignore-line (interface/impl cheat)
}

/**
* @dataProvider badCreateResponses
* @param mixed[] $response
Expand Down
45 changes: 19 additions & 26 deletions tests/CreateResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,11 +175,7 @@

public function testIsUserVerified(): void
{
$response = new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
);
$response = $this->getDefaultResponse();
self::assertFalse($response->isUserVerified(), 'Fixture is not verified');
}

Expand All @@ -195,6 +191,7 @@
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $newCdj,
transports: [],
);

$this->expectRegistrationError('7.1.7');
Expand All @@ -203,11 +200,7 @@

public function testUsedChallengeIsError(): void
{
$response = new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
);
$response = $this->getDefaultResponse();

$cred = $response->verify($this->cm, $this->rp);

Expand All @@ -228,6 +221,7 @@
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $newCdj,
transports: [],
);

$this->expectRegistrationError('7.1.8');
Expand All @@ -246,6 +240,7 @@
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $newCdj,
transports: [],
);

$this->expectRegistrationError('7.1.0');
Expand All @@ -256,11 +251,7 @@
public function testRelyingPartyIdMismatchIsError(): void
{
$rp = new SingleOriginRelyingParty('https://some-other-site.example.com');
$response = new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
);
$response = $this->getDefaultResponse();

$this->expectRegistrationError('7.1.13');
$response->verify($this->cm, $rp);
Expand All @@ -276,11 +267,7 @@
// 7.1.15
public function testUserVerifiedNotPresentWhenRequiredIsError(): void
{
$response = new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
);
$response = $this->getDefaultResponse();

$this->expectRegistrationError('7.1.15');
$response->verify($this->cm, $this->rp, Enums\UserVerificationRequirement::Required);
Expand All @@ -289,7 +276,7 @@
// 7.1.16
public function testPubKeyAlgorithmNotMatchingOptionsIsError(): void
{
self::markTestSkipped('Only EC2/ED256 supported at this time');

Check warning on line 279 in tests/CreateResponseTest.php

View workflow job for this annotation

GitHub Actions / phpunit (high, 8.1)

Only EC2/ED256 supported at this time

Check warning on line 279 in tests/CreateResponseTest.php

View workflow job for this annotation

GitHub Actions / phpunit (high, 8.2)

Only EC2/ED256 supported at this time

Check warning on line 279 in tests/CreateResponseTest.php

View workflow job for this annotation

GitHub Actions / phpunit (low, 8.1)

Only EC2/ED256 supported at this time

Check warning on line 279 in tests/CreateResponseTest.php

View workflow job for this annotation

GitHub Actions / phpunit (low, 8.2)

Only EC2/ED256 supported at this time
}

// 7.1.19
Expand Down Expand Up @@ -322,18 +309,14 @@
id: $this->id,
ao: $ao,
clientDataJson: $this->clientDataJson,
transports: [],
);
$response->verify($this->cm, $this->rp);
}

public function testSuccess(): void
{
$response = new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
);

$response = $this->getDefaultResponse();
$cred = $response->verify($this->cm, $this->rp);

self::assertSame(0, $cred->getSignCount());
Expand All @@ -345,4 +328,14 @@
$this->expectException(Errors\RegistrationError::class);
// TODO: how to assert on $section
}

private function getDefaultResponse(): CreateResponse
{
return new CreateResponse(
id: $this->id,
ao: $this->attestationObject,
clientDataJson: $this->clientDataJson,
transports: [],
);
}
}
13 changes: 13 additions & 0 deletions tests/JsonResponseParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ public function testParseCreateResponseInputValidation(array $response): void
$parser->parseCreateResponse($response);
}

public function testParseCreateResponseWithInvalidTransports(): void
{
$response = $this->readFixture('safari-passkey-polyfill/register.json');
$response['response']['transports'] = ['invalid', 'usb']; // @phpstan-ignore-line

$parser = new JsonResponseParser();
$parsed = $parser->parseCreateResponse($response);
self::assertEqualsCanonicalizing([
Enums\AuthenticatorTransport::Usb,
], $parsed->transports); // @phpstan-ignore-line (interface/impl cheat)
}

/**
* @dataProvider badGetResponses
* @param mixed[] $response
Expand Down Expand Up @@ -109,6 +121,7 @@ public function badCreateResponses(): array
'invalid attestationObject' => $makeVector(['response' => ['attestationObject' => 'not base 64']]),
'no clientDataJSON' => $makeVector(['response' => ['clientDataJSON' => null]]),
'invalid clientDataJSON' => $makeVector(['response' => ['clientDataJSON' => 'not base 64']]),
'no transports' => $makeVector(['response' => ['transports' => null]]),
];
}

Expand Down
Loading