Skip to content

Commit

Permalink
Have the library handle challenge management (#35)
Browse files Browse the repository at this point in the history
The examples so far have all used sessions to manage the active
challenges, but not all applications are stateful in this way - namely,
most APIs will not be session-based.

Instead, this creates a new `ChallengeManagerInterface` that handles
this for applications. For now there's a single implementation that's
still session-based, though (via #30 which I'm reworking) other
implementations will be provided (e.g. a cache pool).

The majority of the change here is updating examples and adding tests.
Note that this would be a BC break but since the library is still
pre-1.0 it's not a concern for practical purposes.
  • Loading branch information
Firehed committed Nov 2, 2023
1 parent ae5e600 commit 14bb773
Show file tree
Hide file tree
Showing 21 changed files with 371 additions and 75 deletions.
58 changes: 36 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ The protocol is always required; the port must only be present if using a non-st
$rp = new \Firehed\WebAuthn\RelyingParty('https://www.example.com');
```

Also create a `ChallengeManagerInterface`.
This will store and validate the one-time use challenges that are central to the WebAuthn protocol.
See the [Challenge Management](#challenge-management) section below for more information.

```php
session_start();
$challengeManager = new \Firehed\WebAuthn\SessionChallengeManager();
```

> [!IMPORTANT]
> WebAuthn will only work in a "secure context".
> This means that the domain MUST run over `https`, with a sole exception for `localhost`.
Expand All @@ -49,20 +58,13 @@ $rp = new \Firehed\WebAuthn\RelyingParty('https://www.example.com');
This step takes place either when a user is first registering, or later on to supplement or replace their password.

1) Create an endpoint that will return a new, random Challenge.
This may be stored in a user's session or equivalent; it needs to be kept statefully server-side.
Send it to the user as base64.

```php
<?php

use Firehed\WebAuthn\ExpiringChallenge;

// Generate challenge
$challenge = ExpiringChallenge::withLifetime(120);

// Store server-side; adjust to your app's needs
session_start();
$_SESSION['webauthn_challenge'] = $challenge;
$challenge = $challengeManager->createChallenge();

// Send to user
header('Content-type: application/json');
Expand Down Expand Up @@ -154,11 +156,9 @@ $data = json_decode($json, true);
$parser = new ResponseParser();
$createResponse = $parser->parseCreateResponse($data);

$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
$challenge = $_SESSION['webauthn_challenge'];

try {
$credential = $createResponse->verify($challenge, $rp);
// $challengeManager and $rp are the values from the setup step
$credential = $createResponse->verify($challengeManager, $rp);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
Expand Down Expand Up @@ -205,10 +205,7 @@ This assumes the same schema from the previous Registration example.
```php
<?php

use Firehed\WebAuthn\{
Codecs,
ExpiringChallenge,
};
use Firehed\WebAuthn\Codecs;

session_start();

Expand All @@ -223,8 +220,7 @@ $_SESSION['authenticating_user_id'] = $user['id'];
// See examples/functions.php for how this works
$credentialContainer = getCredentialsForUserId($pdo, $user['id']);

$challenge = ExpiringChallenge::withLifetime(120);
$_SESSION['webauthn_challenge'] = $challenge;
$challenge = $challengeManager->createChallenge();

// Send to user
header('Content-type: application/json');
Expand Down Expand Up @@ -310,13 +306,11 @@ $data = json_decode($json, true);
$parser = new ResponseParser();
$getResponse = $parser->parseGetResponse($data);

$rp = $valueFromSetup; // e.g. $psr11Container->get(RelyingParty::class);
$challenge = $_SESSION['webauthn_challenge'];

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);

try {
$updatedCredential = $getResponse->verify($challenge, $rp, $credentialContainer);
// $challengeManager and $rp are the values from the setup step
$updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
Expand Down Expand Up @@ -440,6 +434,26 @@ Those wire formats are covered by semantic versioning and guaranteed to not have

Similarly, for data storage, the output of `Codecs\Credential::encode()` are also covered.

### Challenge management

Challenges are a [cryptographic nonce](https://en.wikipedia.org/wiki/Cryptographic_nonce) that ensure a login attempt works only once.
Their single-use nature is critical to the security of the WebAuthn protocol.

Your application SHOULD use one of the library-provided `ChallengeManagerInterface` implementations to ensure the correct behavior.

| Implementation | Usage |
| --- | --- |
| `SessionChallengeManager` | Manages challenges through native PHP [Sessions](https://www.php.net/manual/en/intro.session.php). |

If one of the provided options is not suitable, you MAY implement the interface yourself or manage challenges manually.
In the event you find this necessary, you SHOULD open an Issue and/or Pull Request for the library that indicates the shortcoming.

> [!WARNING]
> You MUST validate that the challenge was generated by your server recently and has not already been used.
> **Failing to do so will compromise the security of the protocol!**
> Implementations MUST NOT trust a client-provided value.
> The built-in `ChallengeManagerInterface` implementations will handle this for you.
Challenges generated by your server SHOULD expire after a short amount of time.
You MAY use the `ExpiringChallenge` class for convenience (e.g. `$challenge = ExpiringChallenge::withLifetime(60);`), which will throw an exception if the specified expiration window has been exceeded.
It is RECOMMENDED that your javascript code uses the `timeout` setting (denoted in milliseconds) and matches the server-side challenge expiration, give or take a few seconds.
Expand Down
6 changes: 6 additions & 0 deletions composer-require-checker.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"symbol-whitelist": [
"PHP_SESSION_ACTIVE",
"session_status"
]
}
7 changes: 7 additions & 0 deletions examples/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
declare(strict_types=1);

use Firehed\WebAuthn\{
ChallengeManagerInterface,
Codecs,
CredentialContainer,
RelyingParty,
SessionChallengeManager,
};

/**
Expand All @@ -28,6 +30,11 @@ function createUser(PDO $pdo, string $username): array
return $response;
}

function getChallengeManager(): ChallengeManagerInterface
{
return new SessionChallengeManager();
}

function getCredentialsForUserId(PDO $pdo, string $userId): CredentialContainer
{
$stmt = $pdo->prepare('SELECT * FROM user_credentials WHERE user_id = ?');
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeLoginStep1.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@

$credentialContainer = getCredentialsForUserId($pdo, $user['id']);

$challenge = ExpiringChallenge::withLifetime(120);
$_SESSION['webauthn_challenge'] = $challenge;
$challengeManager = getChallengeManager();
$challenge = $challengeManager->createChallenge();

// Send to user
header('Content-type: application/json');
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeLoginStep3.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
$getResponse = $parser->parseGetResponse($data);

$rp = getRelyingParty();
$challenge = $_SESSION['webauthn_challenge'];

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
$challengeManager = getChallengeManager();

try {
$updatedCredential = $getResponse->verify($challenge, $rp, $credentialContainer);
$updatedCredential = $getResponse->verify($challengeManager, $rp, $credentialContainer);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
Expand Down
6 changes: 2 additions & 4 deletions examples/readmeRegisterStep1.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@
$_SESSION['user_id'] = $user['id'];

// Generate challenge
$challenge = ExpiringChallenge::withLifetime(120);

// Store server-side; adjust to your app's needs
$_SESSION['webauthn_challenge'] = $challenge;
$challengeManager = getChallengeManager();
$challenge = $challengeManager->createChallenge();

// Send to user
header('Content-type: application/json');
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeRegisterStep3.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
$createResponse = $parser->parseCreateResponse($data);

$rp = getRelyingParty();
$challenge = $_SESSION['webauthn_challenge'];
$challengeManager = getChallengeManager();

try {
$credential = $createResponse->verify($challenge, $rp);
$credential = $createResponse->verify($challengeManager, $rp);
} catch (Throwable) {
// Verification failed. Send an error to the user?
header('HTTP/1.1 403 Unauthorized');
Expand Down
2 changes: 1 addition & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
executionOrder="depends,defects"
forceCoversAnnotation="true"
beStrictAboutCoversAnnotation="false"
Expand Down
33 changes: 33 additions & 0 deletions src/ChallengeManagerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

interface ChallengeManagerInterface
{
/**
* Generates a new Challenge, stores it in the backing mechanism, and
* returns it.
*
* @api
*/
public function createChallenge(): ChallengeInterface;

/**
* Consumes the challenge associated with the ClientDataJSON value from the
* underlying storage mechanism, and returns that challenge if found.
*
* Implementations MUST ensure that subsequent calls to this method with
* the same value return `null`, regardless of whether the initial call
* returned a value or null. Failure to do so will compromise the security
* of the webauthn protocol.
*
* Implementations MUST NOT use the ClientDataJSON value to construct
* a challenge. They MUST return a previously-stored value if one is found,
* and MAY use $base64Url to search the storage mechanism.
*
* @internal
*/
public function useFromClientDataJSON(string $base64Url): ?ChallengeInterface;
}
10 changes: 8 additions & 2 deletions src/CreateResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function __construct(
* @link https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
*/
public function verify(
ChallengeInterface $challenge,
ChallengeManagerInterface $challenge,
RelyingParty $rp,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
): CredentialInterface {
Expand All @@ -47,8 +47,14 @@ public function verify(
}

// 7.1.8
$cdjChallenge = $C['challenge'];
$challenge = $challenge->useFromClientDataJSON($cdjChallenge);
if ($challenge === null) {
$this->fail('7.1.8', 'C.challenge');
}

$b64u = Codecs\Base64Url::encode($challenge->getBinary()->unwrap());
if (!hash_equals($b64u, $C['challenge'])) {
if (!hash_equals($b64u, $cdjChallenge)) {
$this->fail('7.1.8', 'C.challenge');
}

Expand Down
15 changes: 12 additions & 3 deletions src/GetResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@
*/
class GetResponse implements Responses\AssertionInterface
{
private AuthenticatorData $authData;

public function __construct(
private BinaryString $credentialId,
private BinaryString $rawAuthenticatorData,
private BinaryString $clientDataJson,
private BinaryString $signature,
) {
$this->authData = AuthenticatorData::parse($this->rawAuthenticatorData);
}

/**
Expand All @@ -36,7 +39,7 @@ public function getUsedCredentialId(): BinaryString
* @link https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion
*/
public function verify(
ChallengeInterface $challenge,
ChallengeManagerInterface $challenge,
RelyingParty $rp,
CredentialContainer | CredentialInterface $credential,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
Expand Down Expand Up @@ -71,7 +74,7 @@ public function verify(

// 7.2.8
$cData = $this->clientDataJson->unwrap();
$authData = AuthenticatorData::parse($this->rawAuthenticatorData);
$authData = $this->authData;
$sig = $this->signature->unwrap();

// 7.2.9
Expand All @@ -89,8 +92,14 @@ public function verify(
}

// 7.2.12
$cdjChallenge = $C['challenge'];
$challenge = $challenge->useFromClientDataJSON($cdjChallenge);
if ($challenge === null) {
$this->fail('7.2.12', 'C.challenge');
}

$b64u = Codecs\Base64Url::encode($challenge->getBinary()->unwrap());
if (!hash_equals($b64u, $C['challenge'])) {
if (!hash_equals($b64u, $cdjChallenge)) {
$this->fail('7.2.12', 'C.challenge');
}

Expand Down
3 changes: 2 additions & 1 deletion src/Responses/AssertionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Firehed\WebAuthn\{
BinaryString,
ChallengeInterface,
ChallengeManagerInterface,
CredentialContainer,
CredentialInterface,
RelyingParty,
Expand All @@ -30,7 +31,7 @@ public function getUsedCredentialId(): BinaryString;
* @api
*/
public function verify(
ChallengeInterface $challenge,
ChallengeManagerInterface $challenge,
RelyingParty $rp,
CredentialContainer | CredentialInterface $credential,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
Expand Down
3 changes: 2 additions & 1 deletion src/Responses/AttestationInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Firehed\WebAuthn\{
ChallengeInterface,
ChallengeManagerInterface,
CredentialInterface,
RelyingParty,
UserVerificationRequirement,
Expand All @@ -20,7 +21,7 @@
interface AttestationInterface
{
public function verify(
ChallengeInterface $challenge,
ChallengeManagerInterface $challenge,
RelyingParty $rp,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
): CredentialInterface;
Expand Down
43 changes: 43 additions & 0 deletions src/SessionChallengeManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

use BadMethodCallException;

use function array_key_exists;
use function session_status;

use const PHP_SESSION_ACTIVE;

class SessionChallengeManager implements ChallengeManagerInterface
{
private const SESSION_KEY = 'passkey_challenge';

public function __construct()
{
// Do this later?
if (session_status() !== PHP_SESSION_ACTIVE) {
throw new BadMethodCallException('No active session. Call session_start() before using this.');
}
}

public function createChallenge(): ChallengeInterface
{
$c = ExpiringChallenge::withLifetime(120);
$_SESSION[self::SESSION_KEY] = $c;
return $c;
}

public function useFromClientDataJSON(string $base64Url): ?ChallengeInterface
{
if (!array_key_exists(self::SESSION_KEY, $_SESSION)) {
return null;
}
$challenge = $_SESSION[self::SESSION_KEY];
unset($_SESSION[self::SESSION_KEY]);
// Validate that the stored challenge matches the CDJ value?
return $challenge;
}
}
Loading

0 comments on commit 14bb773

Please sign in to comment.