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

Add cache-based challenge manager #30

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
4a680ee
Merge branch 'main' into get-challenge-from-response
Firehed Nov 2, 2023
bacb875
remove now-useless tests
Firehed Nov 2, 2023
83170d6
Do a bunch of extra work to de-risk race conditions since psr16 is in…
Firehed Nov 2, 2023
849b576
add warning
Firehed Nov 2, 2023
e7d4600
Merge branch 'main' into get-challenge-from-response
Firehed Nov 8, 2023
4690a0f
Merge branch 'main' into get-challenge-from-response
Firehed Nov 14, 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ $rp = new \Firehed\WebAuthn\SingleOriginRelyingParty('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.
There are multiple options available which can suit different applications.
See the [Challenge Management](#challenge-management) section below for more information.

```php
Expand Down Expand Up @@ -571,6 +572,7 @@ Your application SHOULD use one of the library-provided `ChallengeManagerInterfa

| Implementation | Usage |
| --- | --- |
| `CacheChallengeManager` | Manages challenges in a site-wide pool stored in a [PSR-16](https://www.php-fig.org/psr/psr-16/) SimpleCache implementation. |
| `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.
Expand Down
1 change: 1 addition & 0 deletions composer-require-checker.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"symbol-whitelist": [
"PHP_SESSION_ACTIVE",
"Psr\\SimpleCache\\CacheInterface",
"session_status"
]
}
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^9.3",
"psr/simple-cache": "^3.0",
"squizlabs/php_codesniffer": "^3.5"
},
"scripts": {
Expand All @@ -54,5 +55,8 @@
"phpstan": "phpstan analyse",
"phpstan-baseline": "phpstan analyse --generate-baseline",
"phpcs": "phpcs"
},
"suggest": {
"psr/simple-cache": "Allows use of CacheChallengeManager"
}
}
110 changes: 110 additions & 0 deletions src/CacheChallengeManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

use Psr\SimpleCache\CacheInterface;
use RuntimeException;
use UnexpectedValueException;

use function assert;
use function bin2hex;
use function hash_equals;
use function is_string;
use function random_bytes;
use function sprintf;

/**
* Uses a PSR-16 cache implementation to manage challenges.
*
* IMPORTANT: The provided cache MUST be a shared pool across the service,
* rather than a server- or process-local version (such as APCu). Providing
* a local cache is highly likely to result in undesired runtime behavior and
* can pose a security risk.
*/
class CacheChallengeManager implements ChallengeManagerInterface
{
public function __construct(
private CacheInterface $cache,
private string $cacheKeyPrefix = 'webauthn-challenge-',
) {
}

public function createChallenge(): ChallengeInterface
{
$c = ExpiringChallenge::withLifetime(120);
// The cache key is designed to mirror the comparison value used in
// the `verify()` methods and `useFromClientDataJSON()` below.
$key = $this->getKey(Codecs\Base64Url::encode($c->getBinary()->unwrap()));
$this->cache->set($key, $c, 120);
return $c;
}

public function useFromClientDataJSON(string $base64Url): ?ChallengeInterface
{
$key = $this->getKey($base64Url);

// PSR-16 (through the shared definition in PSR-6) designates that
// cache item deletion "MUST NOT be considered an error condition if the
// specified key does not exist". Consequently, there's no way within
// that interface to know if deletion was a no-op or actually removed
// an item.
//
// Since this is used to managed cryptographic nonces and a race
// condition could be exploited, this implementation does some
// additional work (at the expense of some extra round-trips) to block
// race conditions.
//
// First, generate a random value to store in the cache before doing
// anything else. This value will be checked later.

$raceConditionBlocker = bin2hex(random_bytes(10));
$raceConditionBlockerKey = $key . '-rcb';
$this->cache->set($raceConditionBlockerKey, $raceConditionBlocker, 120);

// Retrieve the original value from the cache that would have been
// stored during createChallenge().
$challenge = $this->cache->get($key);

// Remove it from the cache, as it is one-time-use. Always do this,
// even if $challege above is null or invalid: this reduces the
// possibility of other timing attacks.
$deleteResult = $this->cache->delete($key);

// Finally, read out the value stored above. If a race condition
// occurred and another process or request overwrote the value with
// a different random value, this will be different from the generated
// value above. Look for this and throw an exception if detected.
$raceConditionCheck = $this->cache->get($raceConditionBlockerKey, '');
assert(is_string($raceConditionCheck));
if (!hash_equals($raceConditionBlocker, $raceConditionCheck)) {
throw new RuntimeException('Another process or request has used this challenge.');

Check warning on line 82 in src/CacheChallengeManager.php

View check run for this annotation

Codecov / codecov/patch

src/CacheChallengeManager.php#L82

Added line #L82 was not covered by tests
}

// If unable to delete the challenge, abort. This is additional
// insurance to block challenge reuse.
if ($deleteResult === false) {
throw new RuntimeException('Could not remove challenge from pool');

Check warning on line 88 in src/CacheChallengeManager.php

View check run for this annotation

Codecov / codecov/patch

src/CacheChallengeManager.php#L88

Added line #L88 was not covered by tests
}

if ($challenge instanceof ChallengeInterface) {
// Found, happy path
return $challenge;
} elseif ($challenge === null) {
// Not found, either expired or potentially malicious.
return null;
}
// Something interfered with the cache contents.
throw new UnexpectedValueException('Non-challenge found in cache');

Check warning on line 99 in src/CacheChallengeManager.php

View check run for this annotation

Codecov / codecov/patch

src/CacheChallengeManager.php#L99

Added line #L99 was not covered by tests
}

private function getKey(string $base64Url): string
{
return sprintf(
'%s%s',
$this->cacheKeyPrefix,
$base64Url,
);
}
}
97 changes: 97 additions & 0 deletions tests/CacheChallengeManagerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

use BadMethodCallException;
use DateInterval;
use DateTimeImmutable;
use DateTimeInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\SimpleCache\CacheInterface;

/**
* @covers \Firehed\WebAuthn\CacheChallengeManager
*/
class CacheChallengeManagerTest extends TestCase
{
use ChallengeManagerTestTrait;

protected function getChallengeManager(): ChallengeManagerInterface
{
// Ordinarily, I'd pull in a library for this. Doing so seemed to
// cause an explosion of dependencies so I opted against in this case.
$cache = new class implements CacheInterface
{
/**
* @var array{mixed, ?DateTimeInterface}[]
*/
private array $values = [];

public function get(string $key, mixed $default = null): mixed
{
if (!$this->has($key)) {
return $default;
}
[$value, $exp] = $this->values[$key];
if ($exp !== null && $exp < new DateTimeImmutable()) {
return $default;
}

return $value;
}

public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
{
if (is_int($ttl)) {
$itvl = new DateInterval('PT' . $ttl . 'S');
$exp = (new DateTimeImmutable())->add($itvl);
} elseif ($ttl instanceof DateInterval) {
$exp = (new DateTimeImmutable())->add($ttl);
} else {
$exp = null;
}
$this->values[$key] = [$value, $exp];
return true;
}

public function delete(string $key): bool
{
unset($this->values[$key]);
return true;
}

public function clear(): bool
{
$this->values = [];
return true;
}

public function getMultiple(iterable $keys, mixed $default = null): iterable
{
throw new BadMethodCallException();
}

/**
* @param iterable<mixed> $values
*/
public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
{
throw new BadMethodCallException();
}

public function deleteMultiple(iterable $keys): bool
{
throw new BadMethodCallException();
}

public function has(string $key): bool
{
return array_key_exists($key, $this->values);
}
};
return new CacheChallengeManager($cache);
}
}
Loading