Skip to content

Commit

Permalink
Add parser for native toJSON formats (#45)
Browse files Browse the repository at this point in the history
This adds response parsers for the recently-added
[toJSON()](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-tojson)
response formats.

Progress towards #41, which will be completed when there's message
generators that complement the
`PublicKeyCredential.parse{Creation|Request}OptionsFromJSON()` methods.
  • Loading branch information
Firehed authored Nov 17, 2023
1 parent 267d04a commit efccbdf
Show file tree
Hide file tree
Showing 26 changed files with 429 additions and 25 deletions.
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const credential = await navigator.credentials.create(createOptions)
// Format the credential to send to the server. This must match the format
// handed by the ResponseParser class. The formatting code below can be used
// without modification.

const dataForResponseParser = {
rawId: Array.from(new Uint8Array(credential.rawId)),
type: credential.type,
Expand Down Expand Up @@ -156,13 +157,13 @@ const result = await fetch(request)

use Firehed\WebAuthn\{
Codecs,
ResponseParser,
ArrayBufferResponseParser,
};

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$createResponse = $parser->parseCreateResponse($data);

try {
Expand Down Expand Up @@ -296,15 +297,15 @@ const result = await fetch(request)

use Firehed\WebAuthn\{
Codecs,
ResponseParser,
ArrayBufferResponseParser,
};

session_start();

$json = file_get_contents('php://input');
$data = json_decode($json, true);

$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();

Expand Down Expand Up @@ -558,7 +559,16 @@ Testing:
Use the _exact data format_ shown in the examples above (`dataForResponseParser`) and use the `ResponseParser` class to process them.
Those wire formats are covered by semantic versioning and guaranteed to not have breaking changes outside of a major version.
Similarly, for data storage, the output of `Codecs\Credential::encode()` are also covered.
#### Upcoming `.toJSON()` support
Browsers are starting to support a [`.toJSON()`](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API#browser_compatibility) method on the WebAuthn PublicKeyCredential response objects.
There is also a [polyfill](https://github.com/github/webauthn-json) available.
As browser support for this format increases, it will become the recommended approach for sending responses back to the server for verification.
If - and only if - you use that format (either natively or through a polyfill), update the JS code on both calls:
```js
const dataForResponseParser = credential.toJSON()
```
and on the receiving APIs, replace `ArrayBufferResponseParser` with `JsonResponseParser`.
### Challenge management
Expand Down Expand Up @@ -644,6 +654,8 @@ When retreived from the database for use during authentication, it should be uns
This field SHOULD support storing at least 2KiB, and it's RECOMMENDED to support storing at least 64KiB (commonly `TEXT` or `varchar(65535)`).
The value will always be plain ASCII.

This format IS COVERED by semantic versioning.

#### `nickname`
The `nickname` field is optional, and (if used) stores a user-provided value that they can use when managing credentials.
Only the owner of the credential should be able to see this nickname.
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeLoginStep3.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use Firehed\WebAuthn\{
Codecs,
ResponseParser,
ArrayBufferResponseParser,
};

session_start();
Expand All @@ -16,7 +16,7 @@
$data = json_decode($json, true);
assert(is_array($data));

$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$getResponse = $parser->parseGetResponse($data);

$rp = getRelyingParty();
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeRegisterStep3.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use Firehed\WebAuthn\{
Codecs,
ResponseParser,
ArrayBufferResponseParser,
};

session_start();
Expand All @@ -14,7 +14,7 @@
$data = json_decode($json, true);
assert(is_array($data));

$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$createResponse = $parser->parseCreateResponse($data);

$rp = getRelyingParty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*
* @api
*/
class ResponseParser
class ArrayBufferResponseParser implements ResponseParserInterface
{
/**
* Parses the JSON wire format from navigator.credentials.create
Expand Down
6 changes: 6 additions & 0 deletions src/BinaryString.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ public function __construct(
) {
}

public static function fromBase64Url(string $base64Url): BinaryString
{
$base64 = strtr($base64Url, ['-' => '+', '_' => '/']);
return self::fromBase64($base64);
}

public static function fromBase64(string $base64): BinaryString
{
$binary = base64_decode($base64, true);
Expand Down
144 changes: 144 additions & 0 deletions src/JsonResponseParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

use UnexpectedValueException;

use function array_key_exists;
use function is_array;
use function is_string;

/**
* Parses and decodes the native `PublicKeyCredential.toJSON()` formats into the
* necessary data structures for subsequent authentication procedures. When
* using this decoder, the original value provided by the javascript API should
* be passed in after parsing the raw JSON (using `associative: true`)
*
* @api
*/
class JsonResponseParser implements ResponseParserInterface
{
/**
* Parses the JSON wire format from navigator.credentials.create
*
* This should arrive as the following shape:
*
* array{
* id: Base64UrlString,
* rawId: Base64UrlString,
* response: array{
* clientDataJSON: Base64UrlString,
* authenticatorData: Base64UrlString,
* transports: string[],
* publicKey?: Base64UrlString,
* publicKeyAlgorithm: int,
* attestationObject: Base64UrlString,
* },
* authenticatorAttachment?: string,
* clientExtensionResults: array{
* },
* type: string,
* }
*
* $data is left untyped since it performs additional checking from
* untrusted user data.
*
* @param mixed[] $data
*/
public function parseCreateResponse(array $data): Responses\AttestationInterface
{
// Note: the recommended polyfill library (as of writing) excludes some
// of the fields defined as required by the W3C spec. Fortunately,
// they're not needed.
if (!array_key_exists('type', $data) || $data['type'] !== 'public-key') {
throw new Errors\ParseError('7.1.2', 'type');
}
if (!array_key_exists('rawId', $data) || !is_string($data['rawId'])) {
throw new Errors\ParseError('7.1.2', 'rawId');
}
if (!array_key_exists('response', $data) || !is_array($data['response'])) {
throw new Errors\ParseError('7.1.2', 'response');
}
$response = $data['response'];
if (!array_key_exists('attestationObject', $response) || !is_string($response['attestationObject'])) {
throw new Errors\ParseError('7.1.2', 'response.attestationObject');
}
if (!array_key_exists('clientDataJSON', $response) || !is_string($response['clientDataJSON'])) {
throw new Errors\ParseError('7.1.2', 'response.clientDataJSON');
}
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'),
);
}

/**
* This will arrive as the following shape:
*
* array{
* id: Base64UrlString,
* rawId: Base64UrlString,
* response: array{
* clientDataJSON: Base64UrlString,
* authenticatorData: Base64UrlString,
* signature: Base64UrlString,
* userHandle?: Base64UrlString,
* attestationObject?: Base64UrlString,
* },
* authenticatorAttachment?: string,
* clientExtensionResults: array{},
* type: string,
* }
*
* $data is left untyped since it performs additional checking from
* untrusted user data.
*
* @param mixed[] $data
*/
public function parseGetResponse(array $data): Responses\AssertionInterface
{
if (!array_key_exists('type', $data) || $data['type'] !== 'public-key') {
throw new Errors\ParseError('7.2.2', 'type');
}
if (!array_key_exists('rawId', $data) || !is_string($data['rawId'])) {
throw new Errors\ParseError('7.2.2', 'rawId');
}
if (!array_key_exists('response', $data) || !is_array($data['response'])) {
throw new Errors\ParseError('7.1.2', 'response');
}
$response = $data['response'];
if (!array_key_exists('authenticatorData', $response) || !is_string($response['authenticatorData'])) {
throw new Errors\ParseError('7.2.2', 'response.authenticatorData');
}
if (!array_key_exists('clientDataJSON', $response) || !is_string($response['clientDataJSON'])) {
throw new Errors\ParseError('7.2.2', 'response.clientDataJSON');
}
if (!array_key_exists('signature', $response) || !is_string($response['signature'])) {
throw new Errors\ParseError('7.2.2', 'response.signature');
}

return new GetResponse(
credentialId: self::parse($data['rawId'], '7.2.2', 'rawId'),
rawAuthenticatorData: self::parse($response['authenticatorData'], '7.2.2', 'response.authenticatorData'),
clientDataJson: self::parse($response['clientDataJSON'], '7.2.2', 'response.clientDataJSON'),
signature: self::parse($response['signature'], '7.2.2', 'response.signature'),
userHandle: array_key_exists('userHandle', $response) && $response['userHandle'] !== ''
? self::parse($response['userHandle'], '7.2.2', 'response.userHandle')
: null,
);
}

private static function parse(string $data, string $failSection, string $failMessage): BinaryString
{
try {
return BinaryString::fromBase64Url($data);
} catch (UnexpectedValueException) {
throw new Errors\ParseError($failSection, $failMessage);
}
}
}
18 changes: 18 additions & 0 deletions src/ResponseParserInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

interface ResponseParserInterface
{
/**
* @param mixed[] $data
*/
public function parseCreateResponse(array $data): Responses\AttestationInterface;

/**
* @param mixed[] $data
*/
public function parseGetResponse(array $data): Responses\AssertionInterface;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
namespace Firehed\WebAuthn;

/**
* @covers Firehed\WebAuthn\ResponseParser
* @covers Firehed\WebAuthn\ArrayBufferResponseParser
*/
class ResponseParserTest extends \PHPUnit\Framework\TestCase
class ArrayBufferResponseParserTest extends \PHPUnit\Framework\TestCase
{
/**
* Test the happy case for various known-good responses.
Expand All @@ -16,7 +16,7 @@ class ResponseParserTest extends \PHPUnit\Framework\TestCase
*/
public function testParseCreateResponse(string $directory): void
{
$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$registerResponse = $this->safeReadJsonFile("$directory/register.json");
$attestation = $parser->parseCreateResponse($registerResponse);

Expand All @@ -29,7 +29,7 @@ public function testParseCreateResponse(string $directory): void
*/
public function testParseCreateResponseInputValidation(array $response): void
{
$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$this->expectException(Errors\ParseError::class);
$parser->parseCreateResponse($response);
}
Expand All @@ -40,24 +40,24 @@ public function testParseCreateResponseInputValidation(array $response): void
*/
public function testParseGetResponseInputValidation(array $response): void
{
$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$this->expectException(Errors\ParseError::class);
$parser->parseGetResponse($response);
}

public function testParseGetResponseHandlesEmptyUserHandle(): void
{
$parser = new ResponseParser();
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/fido-u2f/login.json');
$parser = new ArrayBufferResponseParser();
$response = $this->readFixture('fido-u2f/login.json');
$assertion = $parser->parseGetResponse($response);

self::assertNull($assertion->getUserHandle());
}

public function testParseGetResponseHandlesProvidedUserHandle(): void
{
$parser = new ResponseParser();
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/touchid/login.json');
$parser = new ArrayBufferResponseParser();
$response = $this->readFixture('touchid/login.json');
$assertion = $parser->parseGetResponse($response);

self::assertSame('443945aa-8acc-4b84-f05f-ec8ef86e7c5d', $assertion->getUserHandle());
Expand All @@ -69,7 +69,7 @@ public function testParseGetResponseHandlesProvidedUserHandle(): void
public function badCreateResponses(): array
{
$makeVector = function (array $overrides): array {
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/fido-u2f/register.json');
$response = $this->readFixture('fido-u2f/register.json');
foreach ($overrides as $key => $value) {
if ($value === null) {
unset($response[$key]);
Expand Down Expand Up @@ -99,7 +99,7 @@ public function badCreateResponses(): array
public function badGetResponses(): array
{
$makeVector = function (array $overrides): array {
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/fido-u2f/login.json');
$response = $this->readFixture('fido-u2f/login.json');
foreach ($overrides as $key => $value) {
if ($value === null) {
unset($response[$key]);
Expand Down Expand Up @@ -133,7 +133,7 @@ public function badGetResponses(): array
*/
public function testParseGetResponse(string $directory): void
{
$parser = new ResponseParser();
$parser = new ArrayBufferResponseParser();
$loginResponse = $this->safeReadJsonFile("$directory/login.json");
$assertion = $parser->parseGetResponse($loginResponse);

Expand All @@ -145,7 +145,7 @@ public function testParseGetResponse(string $directory): void
*/
public function goodVectors(): array
{
$paths = glob(__DIR__ . '/fixtures/*');
$paths = glob(__DIR__ . '/fixtures/ArrayBuffer/*');
assert($paths !== false);
$vectors = [];
foreach ($paths as $path) {
Expand All @@ -171,4 +171,12 @@ private function safeReadJsonFile(string $path): array
assert(is_array($data));
return $data;
}

/**
* @return mixed[]
*/
private function readFixture(string $relativePath): array
{
return $this->safeReadJsonFile(__DIR__ . '/fixtures/ArrayBuffer/' . $relativePath);
}
}
Loading

0 comments on commit efccbdf

Please sign in to comment.