diff --git a/README.md b/README.md index b15a5cd..275ffbc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/composer-require-checker.json b/composer-require-checker.json index 81d083c..8c93a13 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,6 +1,7 @@ { "symbol-whitelist": [ "PHP_SESSION_ACTIVE", + "Psr\\SimpleCache\\CacheInterface", "session_status" ] } diff --git a/composer.json b/composer.json index fd47146..5d8c545 100644 --- a/composer.json +++ b/composer.json @@ -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": { @@ -54,5 +55,8 @@ "phpstan": "phpstan analyse", "phpstan-baseline": "phpstan analyse --generate-baseline", "phpcs": "phpcs" + }, + "suggest": { + "psr/simple-cache": "Allows use of CacheChallengeManager" } } diff --git a/src/CacheChallengeManager.php b/src/CacheChallengeManager.php new file mode 100644 index 0000000..1c51b16 --- /dev/null +++ b/src/CacheChallengeManager.php @@ -0,0 +1,110 @@ +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.'); + } + + // 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'); + } + + 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'); + } + + private function getKey(string $base64Url): string + { + return sprintf( + '%s%s', + $this->cacheKeyPrefix, + $base64Url, + ); + } +} diff --git a/tests/CacheChallengeManagerTest.php b/tests/CacheChallengeManagerTest.php new file mode 100644 index 0000000..d7496ac --- /dev/null +++ b/tests/CacheChallengeManagerTest.php @@ -0,0 +1,97 @@ +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 $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); + } +}