Skip to content

Commit

Permalink
Add class for automatically-expiring challenges (#15)
Browse files Browse the repository at this point in the history
This adds a utility class to make best practices around short-lived
challenges easier to follow.
  • Loading branch information
Firehed committed Sep 9, 2022
1 parent 33b6f22 commit 5abb983
Show file tree
Hide file tree
Showing 14 changed files with 370 additions and 51 deletions.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ Send it to the user as base64.
```php
<?php

use Firehed\WebAuthn\Challenge;
use Firehed\WebAuthn\ExpiringChallenge;

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

// Store server-side; adjust to your app's needs
session_start();
Expand Down Expand Up @@ -199,8 +199,8 @@ This assumes the same schema from the previous Registration example.
<?php

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

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

$challenge = Challenge::random();
$challenge = ExpiringChallenge::withLifetime(120);
$_SESSION['webauthn_challenge'] = $challenge;

// Send to user
Expand Down Expand Up @@ -400,7 +400,7 @@ Nice to haves/Future scope:
- [x] Refactor FIDO attestation to not need AD.getAttestedCredentialData
- grab credential from AD
- check PK type
- [ ] ExpiringChallenge & ChallengeInterface
- [x] ExpiringChallenge & ChallengeInterface
- [ ] JSON generators:
- [ ] PublicKeyCredentialCreationOptions
- [ ] PublicKeyCredentialRequestOptions
Expand Down Expand Up @@ -433,6 +433,10 @@ 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.

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.
Note: the W3C specification recommends a timeout in the range of 15-120 seconds.

### Error Handling

Expand Down
4 changes: 2 additions & 2 deletions examples/readmeLoginStep1.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
require __DIR__ . '/vendor/autoload.php';

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

session_start();
Expand All @@ -19,7 +19,7 @@

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

$challenge = Challenge::random();
$challenge = ExpiringChallenge::withLifetime(120);
$_SESSION['webauthn_challenge'] = $challenge;

// Send to user
Expand Down
4 changes: 2 additions & 2 deletions examples/readmeRegisterStep1.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

require __DIR__ . '/vendor/autoload.php';

use Firehed\WebAuthn\Challenge;
use Firehed\WebAuthn\ExpiringChallenge;

session_start();

Expand All @@ -13,7 +13,7 @@
$_SESSION['user_id'] = $user['id'];

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

// Store server-side; adjust to your app's needs
$_SESSION['webauthn_challenge'] = $challenge;
Expand Down
16 changes: 9 additions & 7 deletions src/Challenge.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
* Methods marked as @internal are not for public use. The magic methods
* pertaining to object serialization are only to be called through the
* serialization functions `serialize` and `unserialize`, not directly.
*
* @phpstan-type SerializationFormat array{
* b64: string,
* }
*/
class Challenge
class Challenge implements ChallengeInterface
{
/**
* @internal
Expand All @@ -34,13 +38,11 @@ public static function random(): Challenge
}

/**
* Caution: this returns raw binary
*
* @internal
*/
public function getUnwrappedBinary(): string
public function getBinary(): BinaryString
{
return $this->wrapped->unwrap();
return $this->wrapped;
}

/**
Expand Down Expand Up @@ -69,15 +71,15 @@ public function getBase64(): string
}

/**
* @return array{b64: string}
* @return SerializationFormat
*/
public function __serialize(): array
{
return ['b64' => $this->getBase64()];
}

/**
* @param array{b64: string} $serialized
* @param SerializationFormat $serialized
*/
public function __unserialize(array $serialized): void
{
Expand Down
18 changes: 18 additions & 0 deletions src/ChallengeInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

interface ChallengeInterface
{
/**
* @api
*/
public function getBase64(): string;

/**
* @internal
*/
public function getBinary(): BinaryString;
}
4 changes: 2 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(
Challenge $challenge,
ChallengeInterface $challenge,
RelyingParty $rp,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
): CredentialInterface {
Expand All @@ -47,7 +47,7 @@ public function verify(
}

// 7.1.8
$b64u = Codecs\Base64Url::encode($challenge->getUnwrappedBinary());
$b64u = Codecs\Base64Url::encode($challenge->getBinary()->unwrap());
if (!hash_equals($b64u, $C['challenge'])) {
$this->fail('7.1.8', 'C.challenge');
}
Expand Down
11 changes: 11 additions & 0 deletions src/Errors/ExpiredChallengeError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn\Errors;

use RuntimeException;

class ExpiredChallengeError extends RuntimeException implements WebAuthnErrorInterface
{
}
97 changes: 97 additions & 0 deletions src/ExpiringChallenge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Firehed\WebAuthn;

use DateTimeInterface;
use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;

/**
* This class provides a straightforward way to have short-lived challenges
* without manual management. If the challenge is used after expiration, it
* will throw an exception preventing additional progress.
*
* @api
*
* @phpstan-type SerializationFormat array{
* c: string,
* e: int,
* }
*/
class ExpiringChallenge implements ChallengeInterface
{
private ChallengeInterface $wrapped;
private DateTimeInterface $expiration;

/**
* @internal
*/
public function __construct(DateInterval $duration)
{
// TODO: If duration->invert && not in unit tests, throw?
$this->wrapped = Challenge::random();
$this->expiration = (new DateTimeImmutable())->add($duration);
}

/**
* @param positive-int $seconds
*
* @api
*/
public static function withLifetime(int $seconds): ChallengeInterface
{
if ($seconds <= 0) { // @phpstan-ignore-line Still need the runtime check here
throw new InvalidArgumentException('Lifetime must be a postive integer');
}
$duration = sprintf('PT%dS', $seconds);
return new ExpiringChallenge(new DateInterval($duration));
}

public function getBase64(): string
{
if ($this->isExpired()) {
throw new Errors\ExpiredChallengeError();
}
return $this->wrapped->getBase64();
}

public function getBinary(): BinaryString
{
if ($this->isExpired()) {
throw new Errors\ExpiredChallengeError();
}
return $this->wrapped->getBinary();
}

private function isExpired(): bool
{
$diff = $this->expiration->diff(new DateTimeImmutable());

return $diff->invert === 0;
}

/**
* @return SerializationFormat
*/
public function __serialize(): array
{
return [
'c' => $this->wrapped->getBase64(),
'e' => $this->expiration->getTimestamp(),
];
}

/**
* @param SerializationFormat $serialized
*/
public function __unserialize(array $serialized): void
{
$bin = base64_decode($serialized['c'], true);
assert($bin !== false);
$this->wrapped = new Challenge(new BinaryString($bin));
$this->expiration = new DateTimeImmutable('@' . $serialized['e']);
}
}
4 changes: 2 additions & 2 deletions src/GetResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function getUsedCredentialId(): BinaryString
* @link https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion
*/
public function verify(
Challenge $challenge,
ChallengeInterface $challenge,
RelyingParty $rp,
CredentialContainer | CredentialInterface $credential,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
Expand Down Expand Up @@ -89,7 +89,7 @@ public function verify(
}

// 7.2.12
$b64u = Codecs\Base64Url::encode($challenge->getUnwrappedBinary());
$b64u = Codecs\Base64Url::encode($challenge->getBinary()->unwrap());
if (!hash_equals($b64u, $C['challenge'])) {
$this->fail('7.2.12', 'C.challenge');
}
Expand Down
4 changes: 2 additions & 2 deletions src/Responses/AssertionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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

use Firehed\WebAuthn\{
Challenge,
ChallengeInterface,
CredentialInterface,
RelyingParty,
UserVerificationRequirement,
Expand All @@ -20,7 +20,7 @@
interface AttestationInterface
{
public function verify(
Challenge $challenge,
ChallengeInterface $challenge,
RelyingParty $rp,
UserVerificationRequirement $uv = UserVerificationRequirement::Preferred,
): CredentialInterface;
Expand Down
Loading

0 comments on commit 5abb983

Please sign in to comment.