Skip to content

Commit

Permalink
User handle support (#31)
Browse files Browse the repository at this point in the history
Start to make use of the `userHandle` field in the API wire formats.
This assists in implementing [§7.2 step
6](https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-verifying-assertion)
where the user was _not_ identified prior to the authentication ceremony
beginning.
  • Loading branch information
Firehed authored Nov 10, 2023
1 parent b98441a commit a8bc624
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 11 deletions.
73 changes: 68 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ const result = await fetch(request)

3) Parse and verify the response and, if successful, associate with the user.

> [!NOTE]
> The `publicKey.user.id` field can be looked up and used later on during authentication.
```php
<?php

Expand Down Expand Up @@ -305,8 +308,12 @@ $data = json_decode($json, true);

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

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
if ($userHandle !== null && $userHandle !== $_SESSION['authenticating_user_id']) {
throw new Exception('User handle does not match authentcating user');
}

try {
// $challengeManager and $rp are the values from the setup step
Expand All @@ -330,6 +337,15 @@ header('HTTP/1.1 200 OK');
// Send back whatever your webapp needs to finish authentication
```

> [!NOTE]
> The `$userHandle` value provides flexibility for different authentication flows.
> If null, the authenticator does not support user handles, and you MUST use a user-provided value to look up who is authenticating.
> If a value is present, it will match a previously-registered `publicKey.user.id` value.
> The userHandle SHOULD be used to cross-reference a user-provided id if set, and MAY be used to look up the authenticating user.
> In either case, the previously-registered credentials in `$credentialContainer` MUST be fetched based on the user name or id.
>
> See [Autofill-assited requests](#autofill-assisted-requests) and [WebAuthn §7.2 Step 6](https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-verifying-assertion) for more details.
## Additional details

### Relying Parties
Expand Down Expand Up @@ -392,16 +408,63 @@ const createOptions = {
Examples: `example.com`, `www.example.com`, `localhost`.


### Autofill-assisted requests

The simplest implementation of WebAuthn still starts with a traditional username field.
To make a more streamlined authentication experience, you may use Conditional Medation and Autofill-assisted requests.

#### During registration

Cleanup Tasks
Ensure that the `user.id` field is set appropriately.
This SHOULD be an immutable value, such as (but not limited to) a primary key in a database.

#### During authentication

* Split apart the process of generating a challenge from looking up and providing previously-registered credential IDs.
This is genreally useful for all flows, but required to support Conditional Mediation since you don't know the user ahead of time.
### Mediation/PassKeys
* Add a check for Conditional Mediation support. If supported, use it.
- replace step 1 with just generating challenge (still put in session)
- step 2 removes allowCredentials, adds mediation:conditional
- step 3 replaces user from session with a user lookup from GetResponse.userHandle
```js
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
if (!isCMA) {
// Autofill-assisted requests are not supported. Fall back to username flow.
return
}
const challenge = await getChallenge() // existing API call
const getOptions = {
publicKey: {
challenge,
// Set other options as appropriate
},
mediation: 'conditional', // Add this
}
const credential = await navigator.credentials.get(getOptions)
// proceed as usual
```
* Adjust verification API to use the userHandle from the credential.
This can be done either/or to have a single authentication endpoint.
```php
// ...
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();
$userId = $_POST['username'] ?? null; // match your existing form/API formats
if ($userHandle === null) {
assert($userId !== null);
$user = findUserById($userId); // ORM lookup, etc
} else {
$user = findUserById($userHandle);
assert($userId === $user->id || $userId === null);
}
$credentialContainer = getCredentialsForUser($user);
// ...
```
Cleanup Tasks
- [x] Pull across PublicKeyInterface
- [x] Pull across ECPublicKey
Expand Down
6 changes: 6 additions & 0 deletions src/GetResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ public function __construct(
private BinaryString $rawAuthenticatorData,
private BinaryString $clientDataJson,
private BinaryString $signature,
private ?BinaryString $userHandle,
) {
$this->authData = AuthenticatorData::parse($this->rawAuthenticatorData);
}

public function getUserHandle(): ?string
{
return $this->userHandle?->unwrap();
}

/**
* @internal
*/
Expand Down
13 changes: 9 additions & 4 deletions src/ResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function parseCreateResponse(array $response): Responses\AttestationInter
* authenticatorData: int[],
* clientDataJSON: int[],
* signature: int[],
* userHandle: int[],
* }
*
* $response is left untyped since it performs additional checking from
Expand All @@ -117,17 +118,21 @@ public function parseGetResponse(array $response): Responses\AssertionInterface
if (!array_key_exists('signature', $response) || !is_array($response['signature'])) {
throw new Errors\ParseError('7.2.2', 'response.signature');
}
if (!array_key_exists('userHandle', $response) || !is_array($response['userHandle'])) {
throw new Errors\ParseError('7.2.2', 'response.userHandle');
}

// userHandle provides the user.id from registration
// var_dump(BinaryString::fromBytes($response['userHandle'])->unwrap());
// if userHandle is provided, feed to the response to be read by app
// and have key handles looked up for verify??
// userHandle provides the user.id from registration. Not necessarily
// binary-safe, but will be in the common-case. The recommended API
// format will send `[]` if the PublicKeyCredential.response.userHandle
// is null, so the value is special-cased below.

return new GetResponse(
credentialId: BinaryString::fromBytes($response['rawId']),
rawAuthenticatorData: BinaryString::fromBytes($response['authenticatorData']),
clientDataJson: BinaryString::fromBytes($response['clientDataJSON']),
signature: BinaryString::fromBytes($response['signature']),
userHandle: $response['userHandle'] === [] ? null : BinaryString::fromBytes($response['userHandle']),
);
}
}
25 changes: 23 additions & 2 deletions src/Responses/AssertionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,31 @@
interface AssertionInterface
{
/**
*@internal
*/
* This is used to find the used credential in the credential container. It
* is not used to determine what user is authenticating, and MUST NOT be
* used in an attempt to do so.
*
* @internal
*/
public function getUsedCredentialId(): BinaryString;

/**
* Returns the userHandle associated with the credential. This will be the
* value set during credential creation in the
* `PublicKeyCredentialCreationOptions.user.id` field. While applications
* can put any value they want here, it is RECOMMENDED to store a user id
* or equivalent.
*
* The value may not be binary-safe depending on how your client code set
* up the value.
*
* This will be null if the authenticator doesn't support user handles. U2F
* authenticators, at least, do not support user handles.
*
* @api
*/
public function getUserHandle(): ?string;

/**
* @api
*/
Expand Down
38 changes: 38 additions & 0 deletions tests/GetResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public function testCDJTypeMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.11');
Expand All @@ -115,6 +116,7 @@ public function testUsedChallengeIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$credential = $response->verify($this->cm, $this->rp, $container);
Expand All @@ -136,6 +138,7 @@ public function testCDJChallengeMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

// Simulate replay. ChallengeManager no longer recognizes this one.
Expand All @@ -156,6 +159,7 @@ public function testCDJOriginMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.13');
Expand All @@ -172,6 +176,7 @@ public function testRelyingPartyIdMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.15');
Expand All @@ -194,6 +199,7 @@ public function testUserVerifiedNotPresentWhenRequiredIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.17');
Expand All @@ -213,6 +219,7 @@ public function testIncorrectSignatureIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: new BinaryString('incorrect'),
userHandle: null,
);

$this->expectVerificationError('7.2.20');
Expand All @@ -230,6 +237,7 @@ public function testVerifyReturnsCredentialWithUpdatedCounter(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$updatedCredential = $response->verify($this->cm, $this->rp, $this->credential);
Expand Down Expand Up @@ -265,6 +273,7 @@ public function testCredentialContainerWorks(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$credential = $response->verify($this->cm, $this->rp, $container);
Expand All @@ -280,6 +289,7 @@ public function testEmptyCredentialContainerFails(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.7');
Expand All @@ -297,12 +307,40 @@ public function testCredentialContainerMissingUsedCredentialFails(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.7');
$response->verify($this->cm, $this->rp, $container);
}

public function testNullUserHandle(): void
{
$response = new GetResponse(
credentialId: $this->id,
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

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

public function testUserHandleWithValue(): void
{
$handle = bin2hex(random_bytes(10));
$response = new GetResponse(
credentialId: $this->id,
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: new BinaryString($handle),
);

self::assertSame($handle, $response->getUserHandle());
}

private function expectVerificationError(string $section): void
{
$this->expectException(Errors\VerificationError::class);
Expand Down
19 changes: 19 additions & 0 deletions tests/ResponseParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ public function testParseGetResponseInputValidation(array $response): void
$parser->parseGetResponse($response);
}

public function testParseGetResponseHandlesEmptyUserHandle(): void
{
$parser = new ResponseParser();
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/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');
$assertion = $parser->parseGetResponse($response);

self::assertSame('443945aa-8acc-4b84-f05f-ec8ef86e7c5d', $assertion->getUserHandle());
}

/**
* @return array<mixed>[]
*/
Expand Down Expand Up @@ -104,6 +122,7 @@ public function badGetResponses(): array
// invalid clientDataJSON
'no signature' => $makeVector(['signature' => null]),
'invalid signature' => $makeVector(['signature' => 'sig']),
'no userHandle' => $makeVector(['userHandle' => null]),
];
}

Expand Down

0 comments on commit a8bc624

Please sign in to comment.