Skip to content

Commit

Permalink
feat: tolerance window in token lifetime validation (#359)
Browse files Browse the repository at this point in the history
* feat: clock skew in payload verification library

* feat: clock skew in verification helper

* chore: docs, typo
  • Loading branch information
Zicchio authored Feb 6, 2025
1 parent 015d7ed commit ea3a917
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 29 deletions.
23 changes: 12 additions & 11 deletions README.SATOSA.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ To install the OpenID4VP SATOSA backend you just need to:

| Parameter | Description | Example value |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| config.authorization.client_id | client_id to be set only in the request object; if not set default to entity id value | IT-PYEUDIRP (as requested by Potential LSP) |
| config.authorization.client_id | client_id to be set only in the request object; if not set default to entity id value | IT-PYEUDIRP (as requested by Potential LSP) |
| config.authorization.url_scheme | Either a custom URL scheme for the authorization, or a universal link | haip, https://wallet.example |
| config.authorization.scopes | The list of scopes for the authorization | [pid-sd-jwt:unique_id+given_name+family_name] |
| config.authorization.default_acr_value | The default authentication context class reference value for the authorization | https://www.spid.gov.it/SpidL2 |
Expand Down Expand Up @@ -190,21 +190,22 @@ remember to customize and add any additional parameter to your preferred httpd c

The following environment variables can be used to configure the `metadata_jwks` and `federation_jwks` dynamically. These variables accept JSON-formatted strings representing the keys.

| Environment Variable | Description | Example Value |
| ------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PYEUDIW_METADATA_JWKS` | Contains the private JSON Web Keys (JWK) used for metadata. Each key must be represented as a JSON object. | `[{"kty":"EC", "crv":"P-256", "x":"TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", "y":"ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7", "d":"KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc", "use":"sig", "kid":"signing-key-id"}, {"kty":"EC", "crv":"P-256", "x":"TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", "y":"ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7", "d":"KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc", "use":"enc", "kid":"encryption-key-id"}]` |
| Environment Variable | Description | Example Value |
| ------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PYEUDIW_METADATA_JWKS` | Contains the private JSON Web Keys (JWK) used for metadata. Each key must be represented as a JSON object. | `[{"kty":"EC", "crv":"P-256", "x":"TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", "y":"ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7", "d":"KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc", "use":"sig", "kid":"signing-key-id"}, {"kty":"EC", "crv":"P-256", "x":"TSO-KOqdnUj5SUuasdlRB2VVFSqtJOxuR5GftUTuBdk", "y":"ByWgQt1wGBSnF56jQqLdoO1xKUynMY-BHIDB3eXlR7", "d":"KzQBowMMoPmSZe7G8QsdEWc1IvR2nsgE8qTOYmMcLtc", "use":"enc", "kid":"encryption-key-id"}]` |
| `PYEUDIW_FEDERATION_JWKS` | Contains the private JSON Web Keys (JWK) used for federation. Each key must be represented as a JSON object. | `[{"kty":"RSA", "n":"utqtxbs-jnK0cPsV7aRkkZKA9t4S-WSZa3nCZtYIKDpgLnR_qcpeF0diJZvKOqXmj2cXaKFUE-8uHKAHo7BL7T-Rj2x3vGESh7SG1pE0thDGlXj4yNsg0qNvCXtk703L2H3i1UXwx6nq1uFxD2EcOE4a6qDYBI16Zl71TUZktJwmOejoHl16CPWqDLGo9GUSk_MmHOV20m4wXWkB4qbvpWVY8H6b2a0rB1B1YPOs5ZLYarSYZgjDEg6DMtZ4NgiwZ-4N1aaLwyO-GLwt9Vf-NBKwoxeRyD3zWE2FXRFBbhKGksMrCGnFDsNl5JTlPja", "e":"AQAB","d":"QUZsh1NqvpueootsdSjFQz-BUvxwd3Qnzm5qNb-WeOsvt3rWMEv0Q8CZrla2tndHTJhwioo1U4NuQey7znijhZ177bUwPPxSW1r68dEnL2U74nKwwoYeeMdEXnUfZSPxzs7nY6b7vtyCoA-AjiVYFOlgKNAItspv1HxeyGCLhLYhKvS_YoTdAeLuegETU5D6K1xGQIuw0nS13Icjz79Y8jC10TX4FdZwdX-NmuIEDP5-s95V9DMENtVqJAVE3L-wO-NdDilyjyOmAbntgsCzYVGH9U3W_djh4t3qVFCv3r0S-DA2FD3THvlrFi655L0QHR3gu_Fbj3b9Ybtajpue_Q", "kid":"private-signing-key-id"}]` |


## Environment Variables Configuration
SATOSA supports the following environment variables to customize its behavior:
| **Variable Name** | **Description** | **Default Value** | **Allowed Values** | **Context** |
| -------------------------------- | -------------------------------------------------------------------------------- | ------------------- | -------------------------- | ------------------------- |
| `PYEUDIW_MONGO_TEST_AUTH_INLINE` | MongoDB connection string used for SATOSA integration tests. | `""` (empty string) | Valid MongoDB URI | Integration Testing |
| `PYEUDIW_LRU_CACHE_MAXSIZE` | Configures the maximum number of elements to store in the Least Recently Used (LRU) cache. | `2048` | Integer | Cache Management |
| `PYEUDIW_HTTPC_SSL` | Enables or disables SSL verification for HTTP client requests. | `True` | `True`, `False` | HTTP Client Configuration |
| `PYEUDIW_HTTPC_TIMEOUT` | Sets the timeout for HTTP client requests. | `6` seconds | Integer | HTTP Client Configuration |
| `SD_JWT_HEADER` | Specifies the type of SD-JWT header to use when generating or verifying SD-JWTs. | `dc+sd-jwt` | Custom values as per usage | SD-JWT Configuration |
| **Variable Name** | **Description** | **Default Value** | **Allowed Values** | **Context** |
| -------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------- | -------------------------- | ------------------------- |
| `PYEUDIW_MONGO_TEST_AUTH_INLINE` | MongoDB connection string used for SATOSA integration tests. | `""` (empty string) | Valid MongoDB URI | Integration Testing |
| `PYEUDIW_LRU_CACHE_MAXSIZE` | Configures the maximum number of elements to store in the Least Recently Used (LRU) cache. | `2048` | Integer | Cache Management |
| `PYEUDIW_HTTPC_SSL` | Enables or disables SSL verification for HTTP client requests. | `True` | `True`, `False` | HTTP Client Configuration |
| `PYEUDIW_HTTPC_TIMEOUT` | Sets the timeout for HTTP client requests. | `6` seconds | Integer | HTTP Client Configuration |
| `PYEUDIW_TOKEN_TIME_TOLERANCE` | Global default tolerance windows to be used when validating token lifetime claims such as `iat`. | `60` seconds | Integer | Tokens (JWT) validation
| `SD_JWT_HEADER` | Specifies the type of SD-JWT header to use when generating or verifying SD-JWTs. | `dc+sd-jwt` | Custom values as per usage | SD-JWT Configuration |

### Notes:
1. These variables are optional and, if not explicitly set, default values will be used.
Expand Down
24 changes: 18 additions & 6 deletions pyeudiw/jwt/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,24 +112,36 @@ def is_jwt_expired(token: str) -> bool:
return is_payload_expired(payload)


def validate_jwt_timestamps_claims(payload: dict) -> None:
def validate_jwt_timestamps_claims(payload: dict, tolerance_s: int = 0) -> None:
"""
Validates the 'iat', 'exp', and 'nbf' claims in a JWT payload.
Validates the 'iat', 'exp', and 'nbf' claims in a JWT payload, comparing
them with the current time.
The function assumes that the time in the payload claims is expressed as
seconds since the epoch, as required by rfc 7519.
To account for a clock skew between the token issuer and the token
verifier, the optional argument tolerance_s can be used. As suggested by
rfc 7519, it is recommended to keep the tolerance window to no more than
a few minutes.
:param payload: The decoded JWT payload.
:type payload: dict
:raises ValueError: If any of the claims are invalid.
:param tolerance_s: optional tolerance window, in seconds, which can be \
used to account for some clock skew between the token issuer and the \
token verifier.
:type tolerance_s: int
:raises LifetimeException: If any of the claims are invalid.
"""
current_time = iat_now()

if 'iat' in payload:
if payload['iat'] > current_time:
if payload['iat'] - tolerance_s > current_time:
raise LifetimeException("Future issue time, token is invalid.")

if 'exp' in payload:
if payload['exp'] <= current_time:
if payload['exp'] + tolerance_s <= current_time:
raise LifetimeException("Token has expired.")

if 'nbf' in payload:
if payload['nbf'] > current_time:
if payload['nbf'] - tolerance_s > current_time:
raise LifetimeException("Token not yet valid.")
20 changes: 14 additions & 6 deletions pyeudiw/jwt/jws_helper.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import binascii
from copy import deepcopy
import logging
import os
from typing import Any, Literal, Union

from cryptojwt import JWS
from cryptojwt.jwk.jwk import key_from_jwk_dict

from pyeudiw.jwk.exceptions import KidError
from pyeudiw.jwk.jwks import find_jwk_by_kid, find_jwk_by_thumbprint
from pyeudiw.jwt.exceptions import JWEEncryptionError, JWSSigningError, JWSVerificationError
from pyeudiw.jwt.exceptions import JWEEncryptionError, JWSSigningError, JWSVerificationError, LifetimeException
from pyeudiw.jwt.helper import JWHelperInterface, find_self_contained_key, serialize_payload, validate_jwt_timestamps_claims

from pyeudiw.jwk import JWK
Expand Down Expand Up @@ -40,6 +41,8 @@
"EC": "A256GCM"
}

DEFAULT_TOKEN_TIME_TOLERANCE = int(os.getenv("PYEUDIW_TOKEN_TIME_TOLERANCE", "60"), base=10)


class JWSHelper(JWHelperInterface):
"""
Expand Down Expand Up @@ -121,7 +124,7 @@ def sign(
protected["typ"] = "sd-jwt" if self.is_sd_jwt(
plain_dict) else "JWT"

# Include the signing key's kid in the header if required
# Include the signing key's kid in the header if required
if kid_in_header and signer_kid:
# note that is actually redundant as the underlying library auto-update the header with the kid
protected["kid"] = signer_kid
Expand Down Expand Up @@ -199,12 +202,17 @@ def _select_key_by_kid(self, headers: tuple[dict, dict]) -> dict | None:
return None
return find_jwk_by_kid([key.to_dict() for key in self.jwks], kid)

def verify(self, jwt: str) -> (str | Any | bytes):
"""Verify a JWS with one of the initialized keys.
def verify(self, jwt: str, tolerance_s: int = DEFAULT_TOKEN_TIME_TOLERANCE) -> (str | Any | bytes):
"""Verify a JWS with one of the initialized keys and validate standard
standard claims if possible, such as 'iat' and 'exp'.
Verification of tokens in JSON serialization format is not supported.
:param jwt: The JWS token to be verified.
:type jws: str
:param tolerance_s: optional tolerance window, in seconds, which can be \
used to account for some clock skew between the token issuer and the \
token verifier when validating lifetime claims.
:type tolerance_s: int
:raises JWSVerificationError: if jws field is not in compact jws
format or if the signature is invalid
Expand Down Expand Up @@ -242,8 +250,8 @@ def verify(self, jwt: str) -> (str | Any | bytes):

# Validate JWT claims
try:
validate_jwt_timestamps_claims(msg)
except ValueError as e:
validate_jwt_timestamps_claims(msg, tolerance_s)
except LifetimeException as e:
raise JWSVerificationError(f"Invalid JWT claims: {e}")

return msg
Expand Down
8 changes: 3 additions & 5 deletions pyeudiw/sd_jwt/sd_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,12 @@ def _verify_sd_hash(token_without_hkb: str, sd_hash_alg: str, expected_digest: s


def _verify_iat(payload: dict) -> None:
# we check that 'iat' claim exists, according to sd-jwt specs, but since its a standard claim,
# its value is validated by the general purpose token verification tool JWSHelper accordidng to
# its own rules
iat: int | None = payload.get("iat", None)
if not isinstance(iat, int):
raise ValueError("missing or invalid parameter [iat] in kbjwt")
now = iat_now()
if iat > now:
raise InvalidKeyBinding(
"invalid parameter [iat] in kbjwt: issuance after present time")
return


def _verify_key_binding(token_without_hkb: str, sd_hash_alg: str, hkb: DecodedJwt, challenge: VerifierChallenge):
Expand Down
82 changes: 82 additions & 0 deletions pyeudiw/tests/jwt/test_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from pyeudiw.jwt.helper import validate_jwt_timestamps_claims
from pyeudiw.tools.utils import iat_now


def test_validate_jwt_timestamps_claims_ok():
now = iat_now()
payload = {
"iat": now - 10,
"nbf": now - 10,
"exp": now + 9999
}
try:
validate_jwt_timestamps_claims(payload)
except Exception as e:
assert True, f"encountered unexpeted error when validating the lifetime of a good token payload: {e}"


def test_validate_jwt_timestamps_claims_bad_iat():
now = iat_now()
payload = {
"iat": now + 100,
"exp": now + 9999
}
try:
validate_jwt_timestamps_claims(payload)
assert False, "failed to raise exception when validating a token payload with bad iat"
except Exception:
pass


def test_validate_jwt_timestamps_claims_bad_nbf():
now = iat_now()
payload = {
"nbf": now + 100,
"exp": now + 9999
}
try:
validate_jwt_timestamps_claims(payload)
assert False, "failed to raise exception when validating a token payload with bad nbf"
except Exception:
pass


def test_validate_jwt_timestamps_claims_bad_exp():
now = iat_now()
payload = {
"iat": now - 100,
"exp": now - 10
}
try:
validate_jwt_timestamps_claims(payload)
assert False, "failed to raise exception when validating a token payload with bad exp"
except Exception:
pass


def test_test_validate_jwt_timestamps_claims_tolerance_window():
tolerance_window = 30 # in seconds

# case 0: tolerance window covers a token issuer "slightly" in the future
now = iat_now()
payload = {
"iat": now + 15,
"nbf": now + 15,
"exp": now + 9999
}
try:
validate_jwt_timestamps_claims(payload, tolerance_window)
except Exception as e:
assert False, f"encountered unexpeted error when validating the lifetime of a token payload with a tolerance window (for iat, nbf): {e}"

# case 1: tolerance window covers a token "slightly" expired
now = iat_now()
payload = {
"iat": now - 100,
"nbf": now - 100,
"exp": now - 15
}
try:
validate_jwt_timestamps_claims(payload, tolerance_window)
except Exception as e:
assert False, f"encountered unexpeted error when validating the lifetime of a token payload with a tolerance window (for exp): {e}"
Loading

0 comments on commit ea3a917

Please sign in to comment.