Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
752ba1e
NFC-169 Fix PHP auth cert chain, format, SSRF, cookie and OCSP checks
SanderKondratjevNortal Jun 16, 2026
3789b0e
NFC-169 composer lock
SanderKondratjevNortal Jun 16, 2026
ff9bfbf
NFC-169 Review findings
SanderKondratjevNortal Jun 26, 2026
35fc2da
NFC-169 Versioning improvement
SanderKondratjevNortal Jul 3, 2026
b86e2ad
NFC-128 Handle missing token format as parse error
aarmam Jul 6, 2026
171cc5e
NFC-128 Increase maximum allowed token size limit
aarmam Jul 4, 2026
56ff0a2
NFC-128 Preserve the cause when certificate decoding fails
aarmam Jul 4, 2026
afb96d5
NFC-128 Add intermediate certificates to auth token model
aarmam Jul 4, 2026
57218a0
NFC-128 Build certification paths with token-supplied intermediate ce…
aarmam Jul 4, 2026
1ce18f5
NFC-128 Add revocation checking for token-supplied intermediate CA ce…
aarmam Jul 4, 2026
1f594f2
NFC-128 Authorize AIA OCSP responders against the subject certificate…
aarmam Jul 4, 2026
66b5ea7
NFC-128 Require the OCSP-signing extended key usage only for delegate…
aarmam Jul 4, 2026
b127ca6
NFC-128 Implement OCSP and CRL revocation checking for intermediate C…
aarmam Jul 4, 2026
a088986
NFC-128 Validate authentication certificate chains with token-supplie…
aarmam Jul 4, 2026
e18e286
NFC-128 Validate signing certificate chains with token-supplied inter…
aarmam Jul 4, 2026
f4c82bc
NFC-128 Document intermediate certificate token fields
aarmam Jul 4, 2026
8ae55a4
NFC-128 Add OCSP tests for Belgian and Finnish test cards
aarmam Jul 4, 2026
17f4bfa
NFC-128 Make AIA OCSP issuer matching configurable
aarmam Jul 5, 2026
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,17 @@ The authentication token validation process consists of two stages:
- First, **user certificate validation**: the validator parses the token and extracts the user certificate from the *unverifiedCertificate* field. Then it checks the certificate expiration, purpose and policies. Next it checks that the certificate is signed by a trusted CA and checks the certificate status with OCSP.
- Second, **token signature validation**: the validator validates that the token signature was created using the provided user certificate by reconstructing the signed data `hash(origin)+hash(challenge)` and using the public key from the certificate to verify the signature in the `signature` field. If the signature verification succeeds, then the origin and challenge nonce have been implicitly and correctly verified without the need to implement any additional security checks.

Starting from format version `web-eid:1.1`, the authentication token may additionally carry the intermediate CA certificates needed to build the trust chains of the certificates it contains, as well as the eID user's signing certificates:

- `unverifiedIntermediateCertificates`: an array of base64-encoded DER intermediate CA certificates that make up the trust chain of the authentication certificate in `unverifiedCertificate`. Like the authentication certificate, these certificates are received from the client side and cannot be trusted; they are only used as candidate certificates when building the certification path, which must still terminate at a trusted certificate authority. The field is optional, but when present it must not be empty. When it is present, `unverifiedSigningCertificates` may be omitted.
- `unverifiedSigningCertificates[].intermediateCertificates`: an optional array of base64-encoded DER intermediate CA certificates that make up the trust chain of that signing certificate. When present it must not be empty and, as with `unverifiedIntermediateCertificates`, the certificates are only used as candidates when building the certification path to a trusted CA.

When the token is in the `web-eid:1.1` format and contains `unverifiedSigningCertificates`, the validator additionally validates each signing certificate: that it has the same subject and issuer as the authentication certificate, is currently valid, is suitable for digital signatures (contains the non-repudiation key usage bit) and is signed by a trusted certificate authority, building the chain from the certificate's `intermediateCertificates` to a trusted CA. The signing certificate itself is deliberately not checked for revocation during authentication: its revocation status matters at signing time and is validated by the signature creation and validation services.

When a token supplies intermediate CA certificates and a certification path is built through them, the validator checks the revocation status of the intermediate CA certificates in the path with the library's revocation checker, which prefers OCSP and falls back to CRLs, and rejects the token when the status of an intermediate is revoked or cannot be established. This check runs during path validation for both the authentication and the signing certificate chains, independently of the user certificate OCSP check configuration, because token-supplied intermediates are untrusted input. Deployments that need to accept tokens with intermediate certificates must therefore allow the network access that OCSP or CRL fetching requires.

When the user certificate OCSP check uses an AIA OCSP responder, the responder is authorized according to [RFC 6960 section 4.2.2.2](https://datatracker.ietf.org/doc/html/rfc6960#section-4.2.2.2): the response must be signed either by the CA that issued the user certificate, in which case the OCSP-signing extended key usage is not required, or by a responder whose certificate contains the id-kp-OCSPSigning extended key usage and chains to a trusted CA through the certificate that issued the user certificate. Responder certificates are deliberately not checked for revocation, following the id-pkix-ocsp-nocheck convention of RFC 6960 section 4.2.2.2.1 and because querying an OCSP service about its own signer would be circular.

The website back end must lookup the challenge nonce from its local store using an identifier specific to the browser session, to guarantee that the authentication token was received from the same browser to which the corresponding challenge nonce was issued. The website back end must guarantee that the challenge nonce lifetime is limited and that its expiration is checked, and that it can be used only once by removing it from the store during validation.

## Basic usage
Expand Down Expand Up @@ -298,6 +309,8 @@ 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.

- `withAiaOcspResponderIssuerMatchingPolicy(ResponderIssuerMatchingPolicy $matchingPolicy)` – controls how an AIA OCSP responder's issuer is matched against the user certificate's issuer. The default `EXACT_CERTIFICATE` policy requires the same X.509 certificate. Use `SUBJECT_AND_PUBLIC_KEY` to accept equivalent cross-certificates with the same subject and public key; this also enables revocation checking for non-anchor intermediate certificates in the responder's certification path.

- `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.
Expand All @@ -319,6 +332,8 @@ $validator = new AuthTokenValidatorBuilder()

Unless a designated OCSP responder service is in use, it is required that the AIA extension that contains the certificate’s OCSP responder access location is present in the user certificate. The AIA OCSP URL will be used to check the certificate revocation status with OCSP.

By default, the certificate that directly signs an AIA OCSP response, or issues a delegated AIA OCSP responder, must exactly match the certificate that issued the user certificate. Deployments that require equivalent cross-certificates can explicitly select `ResponderIssuerMatchingPolicy::SUBJECT_AND_PUBLIC_KEY` with `withAiaOcspResponderIssuerMatchingPolicy()`. This policy also requires the revocation status of every non-anchor intermediate certificate in the responder's certification path to be established.

Note that there may be limitations to using AIA URLs as the services behind these URLs provide different security and SLA guarantees than dedicated OCSP responder services. In case you need a SLA guarantee, use a designated OCSP responder service.

## Possible validation errors
Expand Down
183 changes: 170 additions & 13 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions example/public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@

ini_set('display_errors', '0');

session_name('__Host-PHPSESSID');

session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);

session_start();

// Uncomment following line to define the custom log location (by default the server log is used)
Expand Down
49 changes: 49 additions & 0 deletions src/authtoken/UnverifiedSigningCertificate.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ class UnverifiedSigningCertificate
/** @var SupportedSignatureAlgorithm[] */
private array $supportedSignatureAlgorithms = [];

/**
* Unverified intermediate CA certificates of the signing certificate,
* used only as candidate certificates during certification path building.
*
* @var string[]|null
*/
private ?array $intermediateCertificates = null;

public static function fromArray(array $data): self
{
$result = new self();
Expand All @@ -46,6 +54,20 @@ public static function fromArray(array $data): self
$result->certificate = self::filterString('certificate', $data['certificate']);
}

if (isset($data['intermediateCertificates'])) {
if (!is_array($data['intermediateCertificates'])) {
$type = gettype($data['intermediateCertificates']);
throw new UnexpectedValueException(
"Error parsing Web eID authentication token: " .
"'intermediateCertificates' is {$type}, array expected"
);
}

$result->intermediateCertificates = self::parseIntermediateCertificates(
$data['intermediateCertificates']
);
}

if (isset($data['supportedSignatureAlgorithms'])) {
if (!is_array($data['supportedSignatureAlgorithms'])) {
$type = gettype($data['supportedSignatureAlgorithms']);
Expand Down Expand Up @@ -73,6 +95,14 @@ public function getSupportedSignatureAlgorithms(): array
return $this->supportedSignatureAlgorithms;
}

/**
* @return string[]|null
*/
public function getIntermediateCertificates(): ?array
{
return $this->intermediateCertificates;
}

private static function filterString(string $key, $data): string
{
$type = gettype($data);
Expand All @@ -85,6 +115,25 @@ private static function filterString(string $key, $data): string
return $data;
}

/**
* Entries are kept as-is (including null and empty strings); their content is
* validated by the version validators with format-specific error messages.
*/
private static function parseIntermediateCertificates(array $list): array
{
foreach ($list as $item) {
if ($item !== null && !is_string($item)) {
$type = gettype($item);
throw new UnexpectedValueException(
"Error parsing Web eID authentication token: " .
"'intermediateCertificates' entry is {$type}, string expected"
);
}
}

return array_values($list);
}

private static function parseSupportedSignatureAlgorithms(array $list): array
{
$result = [];
Expand Down
Loading