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

Have the library handle challenge management #35

Merged
merged 49 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
2c7ae77
Rough pass to read challenge from request
Firehed Aug 28, 2023
4259a31
Taking a different approach
Firehed Aug 28, 2023
eaa502b
rough interface
Firehed Aug 28, 2023
2712839
Integrate interface into verify paths
Firehed Aug 28, 2023
722850f
Update interfaces
Firehed Aug 28, 2023
d9888ae
Extract implementation
Firehed Aug 28, 2023
735aef9
Add session handler
Firehed Aug 28, 2023
6417f37
var fix
Firehed Aug 28, 2023
192bf2a
Suggest simple-cache and use it in dev for testing manager
Firehed Aug 28, 2023
5e8be2e
Ensure cache challenge manager has a challenge
Firehed Aug 28, 2023
06d3d8a
notes
Firehed Aug 28, 2023
c8aabcd
Update inline examples
Firehed Aug 28, 2023
fd790dd
Note new challenge management
Firehed Aug 28, 2023
beaf574
fix formatting
Firehed Aug 28, 2023
240db53
Improve challenge docs
Firehed Aug 28, 2023
99c86b9
Test the cache manager
Firehed Aug 28, 2023
c9c2ffd
Test the session manager
Firehed Aug 28, 2023
a537d45
type fix
Firehed Aug 28, 2023
75dc60c
Explain decisions
Firehed Aug 28, 2023
96abf52
Test both challenge paths in get response
Firehed Aug 28, 2023
31197d1
Test with manager
Firehed Aug 28, 2023
1b21fc0
same for create
Firehed Aug 28, 2023
58bebb7
note to handle inactive
Firehed Aug 28, 2023
a810868
Ignore dev-only dependencies, this is by design
Firehed Aug 28, 2023
a7f1edd
Tidy imports
Firehed Aug 28, 2023
bb619e8
Fix up the readme a bit
Firehed Aug 28, 2023
0f2ed7f
Clean up session internals
Firehed Aug 28, 2023
53cc16a
Merge branch 'main' into get-challenge-from-response
Firehed Oct 27, 2023
ed9f3e7
Merge branch 'main' into get-challenge-from-response
Firehed Oct 27, 2023
d3c7659
remove cache manager for now
Firehed Nov 2, 2023
292c2d5
more de-integration of other cache cm
Firehed Nov 2, 2023
d72734c
require challenge manager for create
Firehed Nov 2, 2023
918633d
require challenge manager for get
Firehed Nov 2, 2023
978da81
Adjust test buildout
Firehed Nov 2, 2023
af76732
rename
Firehed Nov 2, 2023
3ea5f0d
wrap in e2e test
Firehed Nov 2, 2023
4806de9
update register examples
Firehed Nov 2, 2023
530e659
update examples rest of the way through
Firehed Nov 2, 2023
49d748c
Format
Firehed Nov 2, 2023
3ebcc81
refactor to test replays
Firehed Nov 2, 2023
1c4ecf8
add test
Firehed Nov 2, 2023
a0f3fff
adjust parsing
Firehed Nov 2, 2023
411b387
remove imports no longer needed
Firehed Nov 2, 2023
fa1abf8
adjust create side
Firehed Nov 2, 2023
09f40d6
test/notes
Firehed Nov 2, 2023
aa74b26
make internals of test helper clearer and more precise
Firehed Nov 2, 2023
afa59d5
explainer
Firehed Nov 2, 2023
a18249b
further clarify required behavior for challenge manager
Firehed Nov 2, 2023
75a5986
test new requirement
Firehed Nov 2, 2023
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
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.');

Check warning on line 22 in src/SessionChallengeManager.php

View check run for this annotation

Codecov / codecov/patch

src/SessionChallengeManager.php#L22

Added line #L22 was not covered by tests
}
}

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
Loading