From e0226cc225f4f5e04ead493e060e2562f1b5c3f4 Mon Sep 17 00:00:00 2001 From: Justin Lovell Date: Wed, 1 Apr 2026 12:26:03 +1100 Subject: [PATCH 1/4] Ensure consistent byte size padding for EC keys These changes are to conform to the explicit requirements of payload sizes according to section 6.2 on RFC7518. 6.2.1.2 and 6.2.1.3 > The length of this octet string MUST be the full size of a > coordinate for the curve specified in the "crv" parameter. 6.2.2.1 > The length of this octet string MUST be ceiling(log-base-2(n)/8) > octets (where n is the order of the curve) --- src/joserfc/_rfc7518/ec_key.py | 14 ++++++++----- src/joserfc/util.py | 9 ++++++--- tests/jwk/test_ec_key.py | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/joserfc/_rfc7518/ec_key.py b/src/joserfc/_rfc7518/ec_key.py index 49805d88..d1f17358 100644 --- a/src/joserfc/_rfc7518/ec_key.py +++ b/src/joserfc/_rfc7518/ec_key.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import math import typing as t from functools import cached_property from cryptography.hazmat.primitives import hashes @@ -74,11 +76,12 @@ def import_private_key(cls, obj: ECDictKey) -> EllipticCurvePrivateKey: @classmethod def export_private_key(cls, key: EllipticCurvePrivateKey) -> ECDictKey: numbers = key.private_numbers() + byte_count = math.ceil(key.key_size / 8) return { "crv": cls._curves_dss[key.curve.name], - "x": int_to_base64(numbers.public_numbers.x), - "y": int_to_base64(numbers.public_numbers.y), - "d": int_to_base64(numbers.private_value), + "x": int_to_base64(numbers.public_numbers.x, byte_count), + "y": int_to_base64(numbers.public_numbers.y, byte_count), + "d": int_to_base64(numbers.private_value, byte_count), } @classmethod @@ -94,10 +97,11 @@ def import_public_key(cls, obj: ECDictKey) -> EllipticCurvePublicKey: @classmethod def export_public_key(cls, key: EllipticCurvePublicKey) -> ECDictKey: numbers = key.public_numbers() + byte_count = math.ceil(key.key_size / 8) return { "crv": cls._curves_dss[numbers.curve.name], - "x": int_to_base64(numbers.x), - "y": int_to_base64(numbers.y), + "x": int_to_base64(numbers.x, byte_count), + "y": int_to_base64(numbers.y, byte_count), } diff --git a/src/joserfc/util.py b/src/joserfc/util.py index c287606c..db0db91b 100644 --- a/src/joserfc/util.py +++ b/src/joserfc/util.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Any +from typing import Any, Optional import base64 import struct import binascii @@ -48,11 +48,14 @@ def base64_to_int(s: str) -> int: return int("".join(["%02x" % byte for byte in buf]), 16) -def int_to_base64(num: int) -> str: +def int_to_base64(num: int, byte_count: Optional[int] = None) -> str: if num < 0: raise ValueError("Must be a positive integer") - s = num.to_bytes((num.bit_length() + 7) // 8, "big", signed=False) + if byte_count is None: + byte_count = (num.bit_length() + 7) // 8 + + s = num.to_bytes(byte_count, "big", signed=False) return urlsafe_b64encode(s).decode("utf-8", "strict") diff --git a/tests/jwk/test_ec_key.py b/tests/jwk/test_ec_key.py index d75e25c4..da6d3bea 100644 --- a/tests/jwk/test_ec_key.py +++ b/tests/jwk/test_ec_key.py @@ -129,3 +129,39 @@ def test_derive_key_with_different_hash(self): key1 = ECKey.derive_key("ec-secret-key", "P-256", kdf_options={"algorithm": hashes.SHA256()}) key2 = ECKey.derive_key("ec-secret-key", "P-256", kdf_options={"algorithm": hashes.SHA512()}) self.assertNotEqual(key1, key2) + + def run_verify_full_size(self, curve_name: str, expected_base64_count: int): + """ + Verifies that full base64url bytes is being emitted properly according to + https://datatracker.ietf.org/doc/html/rfc7518#section-6.2 + """ + private_key = ECKey.generate_key(curve_name) + # find the number which requires one less byte(octet) than a full padding + lower_cap = pow(2, private_key.curve_key_size - 8) + + # now generate keys until we find a parameter which could be truncated + while ( + private_key.public_key.public_numbers().x >= lower_cap + and private_key.public_key.public_numbers().y >= lower_cap + and private_key.private_key.private_numbers().private_value >= lower_cap + ): + private_key = ECKey.generate_key(curve_name) + + output_private = private_key.as_dict(private=True) + self.assertEqual(expected_base64_count, len(output_private["x"])) + self.assertEqual(expected_base64_count, len(output_private["y"])) + self.assertEqual(expected_base64_count, len(output_private["d"])) + + pub_key = ECKey.import_key(private_key.public_key) + output_public = pub_key.as_dict(private=False) + self.assertEqual(expected_base64_count, len(output_public["x"])) + self.assertEqual(expected_base64_count, len(output_public["y"])) + + def test_p256_full_size(self): + self.run_verify_full_size("P-256", 43) + + def test_p384_full_size(self): + self.run_verify_full_size("P-384", 64) + + def test_p521_full_size(self): + self.run_verify_full_size("P-521", 88) From fb58af901f52849ccd470ad640c5c3238c1301d8 Mon Sep 17 00:00:00 2001 From: Justin Lovell Date: Wed, 1 Apr 2026 13:06:16 +1100 Subject: [PATCH 2/4] Use integer arithmetic for computing key size --- src/joserfc/_rfc7518/ec_key.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/joserfc/_rfc7518/ec_key.py b/src/joserfc/_rfc7518/ec_key.py index d1f17358..25d4e6ff 100644 --- a/src/joserfc/_rfc7518/ec_key.py +++ b/src/joserfc/_rfc7518/ec_key.py @@ -1,6 +1,5 @@ from __future__ import annotations -import math import typing as t from functools import cached_property from cryptography.hazmat.primitives import hashes @@ -76,7 +75,7 @@ def import_private_key(cls, obj: ECDictKey) -> EllipticCurvePrivateKey: @classmethod def export_private_key(cls, key: EllipticCurvePrivateKey) -> ECDictKey: numbers = key.private_numbers() - byte_count = math.ceil(key.key_size / 8) + byte_count = (key.key_size + 7) // 8 return { "crv": cls._curves_dss[key.curve.name], "x": int_to_base64(numbers.public_numbers.x, byte_count), @@ -97,7 +96,7 @@ def import_public_key(cls, obj: ECDictKey) -> EllipticCurvePublicKey: @classmethod def export_public_key(cls, key: EllipticCurvePublicKey) -> ECDictKey: numbers = key.public_numbers() - byte_count = math.ceil(key.key_size / 8) + byte_count = (key.key_size + 7) // 8 return { "crv": cls._curves_dss[numbers.curve.name], "x": int_to_base64(numbers.x, byte_count), From 51510004d6c8d758434d00af86e8c638fbd25e68 Mon Sep 17 00:00:00 2001 From: Justin Lovell Date: Wed, 1 Apr 2026 13:09:42 +1100 Subject: [PATCH 3/4] Add validation to enforce byte count limits for encoding --- src/joserfc/util.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/joserfc/util.py b/src/joserfc/util.py index db0db91b..f0de753c 100644 --- a/src/joserfc/util.py +++ b/src/joserfc/util.py @@ -54,6 +54,8 @@ def int_to_base64(num: int, byte_count: Optional[int] = None) -> str: if byte_count is None: byte_count = (num.bit_length() + 7) // 8 + elif num.bit_length() > byte_count * 8: + raise ValueError("Number too large for byte count") s = num.to_bytes(byte_count, "big", signed=False) return urlsafe_b64encode(s).decode("utf-8", "strict") From f881c6a405388d6f6ab74a8085f82c5ab1838146 Mon Sep 17 00:00:00 2001 From: Justin Lovell Date: Wed, 1 Apr 2026 13:15:16 +1100 Subject: [PATCH 4/4] Updated test logic according to Copilot --- tests/jwk/test_ec_key.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/jwk/test_ec_key.py b/tests/jwk/test_ec_key.py index da6d3bea..443f63e5 100644 --- a/tests/jwk/test_ec_key.py +++ b/tests/jwk/test_ec_key.py @@ -132,12 +132,24 @@ def test_derive_key_with_different_hash(self): def run_verify_full_size(self, curve_name: str, expected_base64_count: int): """ - Verifies that full base64url bytes is being emitted properly according to - https://datatracker.ietf.org/doc/html/rfc7518#section-6.2 + Verifies that the full-size keys (private and public) generated using the specified curve conform to the expected + Base64-encoded string length for their respective components. The checks involve generating keys that could lead + to truncated values when encoded and ensuring their lengths match the specified expectation. + + See section: https://datatracker.ietf.org/doc/html/rfc7518#section-6.2 + + Parameters: + curve_name (str): The name of the elliptic curve to use for key generation. + expected_base64_count (int): The expected length of the Base64-encoded key components (x, y, d). + + Raises: + AssertionError: Raised if any of the generated private or public key components fail to match the expected lengths. """ private_key = ECKey.generate_key(curve_name) # find the number which requires one less byte(octet) than a full padding - lower_cap = pow(2, private_key.curve_key_size - 8) + byte_count = (private_key.curve_key_size + 7) // 8 + lower_cap = pow(2, 8 * (byte_count - 1)) + attempts_remaining = 1000000 # now generate keys until we find a parameter which could be truncated while ( @@ -146,6 +158,9 @@ def run_verify_full_size(self, curve_name: str, expected_base64_count: int): and private_key.private_key.private_numbers().private_value >= lower_cap ): private_key = ECKey.generate_key(curve_name) + attempts_remaining -= 1 + if attempts_remaining == 0: + raise AssertionError("Failed to find a key parameter that could be truncated") output_private = private_key.as_dict(private=True) self.assertEqual(expected_base64_count, len(output_private["x"]))