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

fix: use system time in OcspResponseValidator.validateCertificateStatusUpdateTime() #27

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ The following additional configuration options are available in `AuthTokenValida

- `withNonceDisabledOcspUrls(URI ...$urls)` – adds the given URLs to the list of OCSP responder access location URLs for which the nonce protocol extension will be disabled. Some OCSP responders don't support the nonce extension.

- `withAllowedOcspResponseTimeSkew(int $allowedTimeSkew)` – sets the allowed time skew for OCSP response's `thisUpdate` and `nextUpdate` times to allow discrepancies between the system clock and the OCSP responder's clock or revocation updates that are not published in real time. The default allowed time skew is 15 minutes. The relatively long default is specifically chosen to account for one particular OCSP responder that used CRLs for authoritative revocation info, these CRLs were updated every 15 minutes.

- `withMaxOcspResponseThisUpdateAge(int $maxThisUpdateAge)` – sets the maximum age for the OCSP response's `thisUpdate` time before it is considered too old to rely on. The default maximum age is 2 minutes.


Extended configuration example:

```php
Expand Down
35 changes: 0 additions & 35 deletions src/util/DateAndTime.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,38 +78,3 @@ public static function toUtcString(?DateTime $date): string
return ((clone $date)->setTimezone(new DateTimeZone("UTC")))->format("Y-m-d H:i:s e");
}
}

/**
* @copyright 2022 Petr Muzikant [email protected]
*/
final class DefaultClock
{
private static DefaultClock $instance;
private DateTime $mockedClock;

public static function getInstance()
{
if (!isset(self::$instance)) {
self::$instance = new DefaultClock();
}
return self::$instance;
}

public function now(): DateTime
{
if (isset($this->mockedClock)) {
return $this->mockedClock;
}
return new DateTime();
}

public function setClock(DateTime $mockedClock): void
{
$this->mockedClock = $mockedClock;
}

public function resetClock(): void
{
unset($this->mockedClock);
}
}
62 changes: 62 additions & 0 deletions src/util/DefaultClock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

/*
* Copyright (c) 2022-2024 Estonian Information System Authority
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

namespace web_eid\web_eid_authtoken_validation_php\util;

use DateTime;

/**
* @copyright 2022 Petr Muzikant [email protected]
*/
final class DefaultClock
{
private static DefaultClock $instance;
private DateTime $mockedClock;

public static function getInstance()
{
if (!isset(self::$instance)) {
self::$instance = new DefaultClock();
}
return self::$instance;
}

public function now(): DateTime
{
if (isset($this->mockedClock)) {
return $this->mockedClock;
}
return new DateTime();
}

public function setClock(DateTime $mockedClock): void
{
$this->mockedClock = $mockedClock;
}

public function resetClock(): void
{
unset($this->mockedClock);
}
}
26 changes: 25 additions & 1 deletion src/validator/AuthTokenValidationConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ final class AuthTokenValidationConfiguration
private ?Uri $siteOrigin = null;
private array $trustedCACertificates = [];
private bool $isUserCertificateRevocationCheckWithOcspEnabled = true;
private int $ocspRequestTimeout = 5;
private int $ocspRequestTimeout = 5; // In seconds
private int $allowedOcspResponseTimeSkew = 15; // In minutes
private int $maxOcspResponseThisUpdateAge = 2; // In minutes
private array $disallowedSubjectCertificatePolicies;
private UriCollection $nonceDisabledOcspUrls;
private ?DesignatedOcspServiceConfiguration $designatedOcspServiceConfiguration = null;
Expand Down Expand Up @@ -94,6 +96,26 @@ public function setOcspRequestTimeout(int $ocspRequestTimeout): void
$this->ocspRequestTimeout = $ocspRequestTimeout;
}

public function getAllowedOcspResponseTimeSkew(): int
{
return $this->allowedOcspResponseTimeSkew;
}

public function setAllowedOcspResponseTimeSkew(int $allowedOcspResponseTimeSkew): void
{
$this->allowedOcspResponseTimeSkew = $allowedOcspResponseTimeSkew;
}

public function getMaxOcspResponseThisUpdateAge(): int
{
return $this->maxOcspResponseThisUpdateAge;
}

public function setMaxOcspResponseThisUpdateAge(int $maxOcspResponseThisUpdateAge): void
{
$this->maxOcspResponseThisUpdateAge = $maxOcspResponseThisUpdateAge;
}

public function getDesignatedOcspServiceConfiguration(): ?DesignatedOcspServiceConfiguration
{
return $this->designatedOcspServiceConfiguration;
Expand Down Expand Up @@ -132,6 +154,8 @@ public function validate(): void
}

DateAndTime::requirePositiveDuration($this->ocspRequestTimeout, "OCSP request timeout");
DateAndTime::requirePositiveDuration($this->allowedOcspResponseTimeSkew, "Allowed OCSP response time-skew");
DateAndTime::requirePositiveDuration($this->maxOcspResponseThisUpdateAge, "Max OCSP response thisUpdate age");
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/validator/AuthTokenValidatorBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,40 @@ public function withOcspRequestTimeout(int $ocspRequestTimeout): AuthTokenValida
$this->logger?->debug("OCSP request timeout set to " . $ocspRequestTimeout);
return $this;
}

/**
* Sets the allowed time skew for OCSP response's thisUpdate and nextUpdate times.
* This parameter is used to allow discrepancies between the system clock and the OCSP responder's clock,
* which may occur due to clock drift, network delays or revocation updates that are not published in real time.
* <p>
* This is an optional configuration parameter, the default is 15 minutes.
* The relatively long default is specifically chosen to account for one particular OCSP responder that used
* CRLs for authoritative revocation info, these CRLs were updated every 15 minutes.
*
* @param integer $allowedTimeSkew the allowed time skew
* @return AuthTokenValidatorBuilder the builder instance for method chaining.
*/
public function withAllowedOcspResponseTimeSkew(int $allowedTimeSkew) : AuthTokenValidatorBuilder
{
$this->configuration->setAllowedOcspResponseTimeSkew($allowedTimeSkew);
$this->logger?->debug("Allowed OCSP response time skew set to " . $allowedTimeSkew);
return $this;
}

/**
* Sets the maximum age of the OCSP response's thisUpdate time before it is considered too old.
* <p>
* This is an optional configuration parameter, the default is 2 minutes.
*
* @param integer $maxThisUpdateAge the maximum age of the OCSP response's thisUpdate time
* @return AuthTokenValidatorBuilder the builder instance for method chaining.
*/
public function withMaxOcspResponseThisUpdateAge(int $maxThisUpdateAge) : AuthTokenValidatorBuilder
{
$this->configuration->setMaxOcspResponseThisUpdateAge($maxThisUpdateAge);
$this->logger?->debug("Maximum OCSP response thisUpdate age set to " . $maxThisUpdateAge);
return $this;
}

/**
* Adds the given URLs to the list of OCSP URLs for which the nonce protocol extension will be disabled.
Expand Down
7 changes: 6 additions & 1 deletion src/validator/AuthTokenValidatorImpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,12 @@ private function getCertTrustValidators(): SubjectCertificateValidatorBatch
if ($this->configuration->isUserCertificateRevocationCheckWithOcspEnabled()) {
$validatorBatch->addOptional(
$this->configuration->isUserCertificateRevocationCheckWithOcspEnabled(),
new SubjectCertificateNotRevokedValidator($certTrustedValidator, $this->ocspClient, $this->ocspServiceProvider, $this->logger)
new SubjectCertificateNotRevokedValidator($certTrustedValidator,
$this->ocspClient,
$this->ocspServiceProvider,
$this->configuration->getAllowedOcspResponseTimeSkew(),
$this->configuration->getMaxOcspResponseThisUpdateAge(),
$this->logger)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
use web_eid\web_eid_authtoken_validation_php\exceptions\UserCertificateOCSPCheckFailedException;
use web_eid\web_eid_authtoken_validation_php\validator\ocsp\service\OcspService;
use Psr\Log\LoggerInterface;
use web_eid\web_eid_authtoken_validation_php\util\DefaultClock;

final class SubjectCertificateNotRevokedValidator implements SubjectCertificateValidator
{
Expand All @@ -45,13 +46,22 @@ final class SubjectCertificateNotRevokedValidator implements SubjectCertificateV
private SubjectCertificateTrustedValidator $trustValidator;
private OcspClient $ocspClient;
private OcspServiceProvider $ocspServiceProvider;

public function __construct(SubjectCertificateTrustedValidator $trustValidator, OcspClient $ocspClient, OcspServiceProvider $ocspServiceProvider, LoggerInterface $logger = null)
private int $allowedOcspResponseTimeSkew;
private int $maxOcspResponseThisUpdateAge;

public function __construct(SubjectCertificateTrustedValidator $trustValidator,
OcspClient $ocspClient,
OcspServiceProvider $ocspServiceProvider,
int $allowedOcspResponseTimeSkew,
int $maxOcspResponseThisUpdateAge,
LoggerInterface $logger = null)
{
$this->logger = $logger;
$this->trustValidator = $trustValidator;
$this->ocspClient = $ocspClient;
$this->ocspServiceProvider = $ocspServiceProvider;
$this->allowedOcspResponseTimeSkew = $allowedOcspResponseTimeSkew;
$this->maxOcspResponseThisUpdateAge = $maxOcspResponseThisUpdateAge;
}

public function validate(X509 $subjectCertificate): void
Expand Down Expand Up @@ -128,17 +138,17 @@ private function verifyOcspResponse(OcspResponse $response, OcspService $ocspSer
// 4. The signer is currently authorized to provide a response for the
// certificate in question.

$producedAt = $basicResponse->getProducedAt();
$ocspService->validateResponderCertificate($responderCert, $producedAt);

// Use the DefaultClock instance so that the date can be mocked in tests.
$now = DefaultClock::getInstance()->now();
$ocspService->validateResponderCertificate($responderCert, $now);
// 5. The time at which the status being indicated is known to be
// correct (thisUpdate) is sufficiently recent.
//
// 6. When available, the time at or before which newer information will
// be available about the status of the certificate (nextUpdate) is
// greater than the current time.

OcspResponseValidator::validateCertificateStatusUpdateTime($basicResponse, $producedAt);
OcspResponseValidator::validateCertificateStatusUpdateTime($basicResponse, $this->allowedOcspResponseTimeSkew, $this->maxOcspResponseThisUpdateAge);

// Now we can accept the signed response as valid and validate the certificate status.
OcspResponseValidator::validateSubjectCertificateStatus($response);
Expand Down
43 changes: 29 additions & 14 deletions src/validator/ocsp/OcspResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@

use BadFunctionCallException;
use DateInterval;
use DateTime;
use web_eid\web_eid_authtoken_validation_php\exceptions\OCSPCertificateException;
use phpseclib3\File\X509;
use web_eid\ocsp_php\OcspBasicResponse;
use web_eid\ocsp_php\OcspResponse;
use web_eid\web_eid_authtoken_validation_php\exceptions\UserCertificateOCSPCheckFailedException;
use web_eid\web_eid_authtoken_validation_php\exceptions\UserCertificateRevokedException;
use web_eid\web_eid_authtoken_validation_php\util\DateAndTime;
use web_eid\web_eid_authtoken_validation_php\util\DefaultClock;

final class OcspResponseValidator
{
Expand All @@ -44,9 +44,7 @@ final class OcspResponseValidator
* https://oidref.com/1.3.6.1.5.5.7.3.9.
*/
private const OCSP_SIGNING = "id-kp-OCSPSigning";

private const ALLOWED_TIME_SKEW = 15;

private const ERROR_PREFIX = "Certificate status update time check failed: ";
public function __construct()
{
throw new BadFunctionCallException("Utility class");
Expand All @@ -72,7 +70,7 @@ public static function validateResponseSignature(OcspBasicResponse $basicRespons
}
}

public static function validateCertificateStatusUpdateTime(OcspBasicResponse $basicResponse, DateTime $producedAt): void
public static function validateCertificateStatusUpdateTime(OcspBasicResponse $basicResponse, int $allowedOcspResponseTimeSkew, int $maxOcspResponseThisUpdateAge): void
{
// From RFC 2560, https://www.ietf.org/rfc/rfc2560.txt:
// 4.2.2. Notes on OCSP Responses
Expand All @@ -83,20 +81,37 @@ public static function validateCertificateStatusUpdateTime(OcspBasicResponse $ba
// SHOULD be considered unreliable.
// If nextUpdate is not set, the responder is indicating that newer
// revocation information is available all the time.

$notAllowedBefore = (clone $producedAt)->sub(new DateInterval('PT' . self::ALLOWED_TIME_SKEW . 'S'));
$notAllowedAfter = (clone $producedAt)->add(new DateInterval('PT' . self::ALLOWED_TIME_SKEW . 'S'));
$now = DefaultClock::getInstance()->now();
$earliestAcceptableTimeSkew = (clone $now)->sub(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M'));
$latestAcceptableTimeSkew = (clone $now)->add(new DateInterval('PT' . $allowedOcspResponseTimeSkew . 'M'));
$minimumValidThisUpdateTime = (clone $now)->sub(new DateInterval('PT' . $maxOcspResponseThisUpdateAge . 'M'));

$thisUpdate = $basicResponse->getThisUpdate();
if ($thisUpdate > $latestAcceptableTimeSkew) {
throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
"thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too far in the future, " .
"latest allowed: '" . DateAndTime::toUtcString($latestAcceptableTimeSkew) . "'");
}

if ($thisUpdate < $minimumValidThisUpdateTime) {
throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
"thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "' is too old, " .
"minimum time allowed: '" . DateAndTime::toUtcString($minimumValidThisUpdateTime) . "'");
}

$nextUpdate = $basicResponse->getNextUpdate();
if (is_null($nextUpdate)) {
return;
}

if ($notAllowedAfter < $thisUpdate || $notAllowedBefore > (!is_null($nextUpdate) ? $nextUpdate : $thisUpdate)) {
if ($nextUpdate < $earliestAcceptableTimeSkew) {
throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
"nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is in the past");
}

throw new UserCertificateOCSPCheckFailedException("Certificate status update time check failed: " .
"notAllowedBefore: " . DateAndTime::toUtcString($notAllowedBefore) .
", notAllowedAfter: " . DateAndTime::toUtcString($notAllowedAfter) .
", thisUpdate: " . DateAndTime::toUtcString($thisUpdate) .
", nextUpdate: " . DateAndTime::toUtcString($nextUpdate));
if ($nextUpdate < $thisUpdate) {
throw new UserCertificateOCSPCheckFailedException(self::ERROR_PREFIX .
"nextUpdate '" . DateAndTime::toUtcString($nextUpdate) . "' is before thisUpdate '" . DateAndTime::toUtcString($thisUpdate) . "'");
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/validator/ocsp/service/AiaOcspService.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ public function getAccessLocation(): Uri
return $this->url;
}

public function validateResponderCertificate(X509 $cert, DateTime $producedAt): void
public function validateResponderCertificate(X509 $cert, DateTime $now): void
{
CertificateValidator::certificateIsValidOnDate($cert, $producedAt, "AIA OCSP responder");
CertificateValidator::certificateIsValidOnDate($cert, $now, "AIA OCSP responder");
// Trusted certificates' validity has been already verified in validateCertificateExpiry().
OcspResponseValidator::validateHasSigningExtension($cert);
CertificateValidator::validateIsSignedByTrustedCA($cert, $this->trustedCACertificates);
Expand Down
4 changes: 2 additions & 2 deletions src/validator/ocsp/service/DesignatedOcspService.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ public function supportsIssuerOf(X509 $certificate): bool
return $this->configuration->supportsIssuerOf($certificate);
}

public function validateResponderCertificate(X509 $cert, DateTime $producedAt): void
public function validateResponderCertificate(X509 $cert, DateTime $now): void
{
// Certificate pinning is implemented simply by comparing the certificates or their public keys,
// see https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning.
if ($this->configuration->getResponderCertificate()->getCurrentCert() != $cert->getCurrentCert()) {
throw new OCSPCertificateException("Responder certificate from the OCSP response is not equal to the configured designated OCSP responder certificate");
}
CertificateValidator::certificateIsValidOnDate($cert, $producedAt, "Designated OCSP responder");
CertificateValidator::certificateIsValidOnDate($cert, $now, "Designated OCSP responder");
}
}
5 changes: 5 additions & 0 deletions tests/testutil/AuthTokenValidators.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public static function getAuthTokenValidatorWithDesignatedOcspCheck()
return (self::getAuthTokenValidatorBuilder(self::TOKEN_ORIGIN_URL, self::getCACertificates()))->withDesignatedOcspServiceConfiguration(OcspServiceMaker::getDesignatedOcspServiceConfiguration())->build();
}

public static function getDefaultAuthTokenValidatorBuilder(): AuthTokenValidatorBuilder
{
return self::getAuthTokenValidatorBuilder(self::TOKEN_ORIGIN_URL, self::getCACertificates());
}

private static function getAuthTokenValidatorBuilder(string $uri, array $certificates): AuthTokenValidatorBuilder
{
return (new AuthTokenValidatorBuilder(new Logger()))
Expand Down
Loading