From 4c465e45b0133bcd525ae5761de765818c51aeaa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 21:35:28 +0000 Subject: [PATCH 1/4] Add PKCS#8 private key parsing and tests --- mauth_client/rsa_signer.py | 22 +++++++++++++++++++++- mauth_client/utils.py | 17 ++++++++++++++--- tests/common.py | 2 +- tests/keys/fake_mauth.pkcs8.key | 28 ++++++++++++++++++++++++++++ tests/signer_test.py | 7 +++++++ tests/utils_test.py | 10 ++++++++++ 6 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 tests/keys/fake_mauth.pkcs8.key diff --git a/mauth_client/rsa_signer.py b/mauth_client/rsa_signer.py index ef085db..d919115 100644 --- a/mauth_client/rsa_signer.py +++ b/mauth_client/rsa_signer.py @@ -5,6 +5,7 @@ import rsa from .utils import make_bytes, hexdigest +from pyasn1.codec.der import decoder class RSASigner: @@ -16,7 +17,26 @@ def __init__(self, private_key_data): """ :param private_key_data: """ - self.private_key = rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") + self.private_key = self.load_private_key(private_key_data) + + @staticmethod + def load_private_key(private_key_data): + private_key_data = make_bytes(private_key_data) + try: + return rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") + except ValueError: + return RSASigner.load_pkcs8_private_key(private_key_data) + + @staticmethod + def load_pkcs8_private_key(private_key_data): + private_key_der = rsa.pem.load_pem(private_key_data, "PRIVATE KEY") + private_key_info, _ = decoder.decode(private_key_der) + + algorithm = str(private_key_info[1][0]) + if algorithm != "1.2.840.113549.1.1.1": + raise ValueError("Only RSA private keys are supported") + + return rsa.PrivateKey.load_pkcs1(bytes(private_key_info[2]), "DER") def sign_v2(self, string_to_sign): """Signs the data using SHA512 for V2 protocol diff --git a/mauth_client/utils.py b/mauth_client/utils.py index 80e19f8..02b6e96 100644 --- a/mauth_client/utils.py +++ b/mauth_client/utils.py @@ -5,6 +5,12 @@ HEADER = '-----BEGIN RSA PRIVATE KEY-----' FOOTER = '-----END RSA PRIVATE KEY-----' +PKCS8_HEADER = '-----BEGIN PRIVATE KEY-----' +PKCS8_FOOTER = '-----END PRIVATE KEY-----' +SUPPORTED_PRIVATE_KEY_FORMATS = ( + (HEADER, FOOTER), + (PKCS8_HEADER, PKCS8_FOOTER), +) def make_bytes(val): @@ -41,11 +47,16 @@ def decode(byte_string: bytes) -> str: def to_rsa_format(key: str) -> str: """Convert a private key to RSA format with proper newlines.""" - if "\n" in key and HEADER in key and FOOTER in key: + header, footer = next( + ((hdr, ftr) for hdr, ftr in SUPPORTED_PRIVATE_KEY_FORMATS if hdr in key and ftr in key), + (HEADER, FOOTER), + ) + + if "\n" in key and header in key and footer in key: return key body = key.strip() - body = body.replace(HEADER, "").replace(FOOTER, "").strip() + body = body.replace(header, "").replace(footer, "").strip() # Replace whitespace with newlines or chunk into 64-char lines if " " in body or "\t" in body: @@ -54,4 +65,4 @@ def to_rsa_format(key: str) -> str: # PEM-encoded keys are typically split into lines of 64 characters as per RFC 7468 (section 2) body = '\n'.join(body[i:i + 64] for i in range(0, len(body), 64)) - return f"{HEADER}\n{body}\n{FOOTER}" + return f"{header}\n{body}\n{footer}" diff --git a/tests/common.py b/tests/common.py index f93c6ff..93bff8a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -8,7 +8,7 @@ def load_key(keytype="pub"): :return: key content :rtype: str """ - assert keytype in ("pub", "rsapub", "priv") + assert keytype in ("pub", "rsapub", "priv", "pkcs8") content = "" with open( os.path.join(os.path.dirname(__file__), "keys", "fake_mauth.{}.key".format(keytype)), "r" diff --git a/tests/keys/fake_mauth.pkcs8.key b/tests/keys/fake_mauth.pkcs8.key new file mode 100644 index 0000000..1628f74 --- /dev/null +++ b/tests/keys/fake_mauth.pkcs8.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDnKhtl4TR+RYem +ogVBPqKlFq6t7K+AJYdwniTKdTaobGPOvrd+DBbSzIQatwYCE0pwnpASR3KWpPK3 +KCBUV4FHYo7VpUlRi9AH60DKo/D+QclTvqD7PHl8pohRZtoBjy8JPWHLg8i+doj6 +GGJFRX6U198vtqfcC/sxjmC/BJOWuTe0IpVSsdqIZPd8s4YfZkkEafvbgSJ10a07 +h5JWJhS2qC1yzxQyiKmtFFnnffTRIhiCy9xTX6VLfdC+BOlYqsvdiJj5UKfPGCPZ ++4y35RasW7bfGF7p6gHIhFLV/aQ56auh3KYv830aCkTBQkIbIMIc/6+HKBtLWQgs +EG+WtZSxAgMBAAECggEAfzhpQ7Shkyzo7wgUJEg4cur8z7OHEtuUoqImVVYU8rRU +aaAElFZK6VCtE5bOs62yNjDN0YGIyueUvMBUqpZOC99uBJ6mrz5nCzSpv9xh8Ux7 +ZJ9xMVOi+LseIs6cBGes10T04XBxtoC9+gYZb05Dz97Ocg/waeYeJLPbAsWeBEB3 +ZFivx22bNdL3dh9ow0L6Zq3ewCyIbY6FsESeSddZpTZBxDKwvH5hVrltxyNBwItz +nf9lAQQq/3aKxKEs9ohIiHXESjyCXCE5BfnFKRYvkNJh8tUMpkRVMDy6ry69G4Kn +7IJCSwa+MU40z0AARumAys4OZLh8q8Lyd6Te6NzhUQKBgQD1ygrmZ+dGZTnE1+HE +jA+80IYXsE3mQCYAUzZQtbocs887uZKCBjzvS6gSoDIhLNzxz0AQbvvtQD9wS8wz +96FXx92F6mFPPQ4EW2CqFkqeaQ6zavLcThIOWB4u5XTFTO/GfjmFaz3eWzapQhjn +E5Ui8iQLVLZIu1IExuOE2X0s8wKBgQDwxIfpSbEmcHYj9UIxE2Hrq1QU5roAi7PF +rEEj/2CduW3yTWewpGUufbzDSRsaDWSM3p85DUrZNP7hCLi9YyhK4mDMeo8RiDgi +6Do4fkLvroTlNXd6aJ6a+B8zC0wOPLpGeAcSeouX0VWmllKcXU0p/w8iQs5AzWk6 +y7MLob1QywKBgDhZftA+B/7SnEDtwXvfJIiEQgnSSLm+AThX2377etSzwJIPxG/8 +4CF4der3QSbr8yeY/TYHS8ijzyd+qS1M23eVgcr+5hpVhA4pqOq1u5u/uDgYD9/E +Ik2ZlCnqPzAwqFQYgjV9VazRuqFpXu4zRiDB5NOYM0ZPc8lWk7jUZ+dTAoGBAKZ4 +LESnmB2synKIRDDsZtrsheFBuj9gWHcHZ669Bw9mw1cyH8xpji/77gTUSjHUKr6f +ulVXFHZkBwqO3jTMF3LXXPhkkQEdPzsbeA3RdvSDpQ1Zz3dKWMdrYR8I45hAcscA +fFtRca0RpvfcndfRRuYPnWYh7Luvit+FMhGLzfrLAoGBALkSY5Y/AhgWOf3U83Rg +5MlYBMN0id1ae9ZnTjmFsmnNRvTmQSSdMNuqM8Gqws7iXQPWAknKrWKc2bIQ15Yz ++FLKAMd0zpRwgwh2oVLmCydUKDDQJM6WNIBYI+dNPX4NR28WckeHT3eYOZjVHw3L +f0/HiT3RZ22EI8lwkeP7E+GP +-----END PRIVATE KEY----- diff --git a/tests/signer_test.py b/tests/signer_test.py index de546bc..329cd0f 100644 --- a/tests/signer_test.py +++ b/tests/signer_test.py @@ -25,7 +25,10 @@ class SignerTest(unittest.TestCase): def setUp(self): with open(os.path.join(os.path.dirname(__file__), "keys", "fake_mauth.priv.key"), "r") as key_file: self.private_key = key_file.read() + with open(os.path.join(os.path.dirname(__file__), "keys", "fake_mauth.pkcs8.key"), "r") as key_file: + self.private_key_pkcs8 = key_file.read() self.signer = Signer(APP_UUID, self.private_key, "v1,v2") + self.signer_pkcs8 = Signer(APP_UUID, self.private_key_pkcs8, "v1,v2") self.signer_v1_only = Signer(APP_UUID, self.private_key, "v1") self.signer_v2_only = Signer(APP_UUID, self.private_key, "v2") self.signable = RequestSignable(**REQUEST_ATTRIBUTES) @@ -137,3 +140,7 @@ def test_sign_versions_bad_version(self): self.assertEqual( str(exc.exception), "SIGN_VERSIONS must be comma-separated MAuth protocol versions (e.g. 'v1,v2')" ) + + def test_signature_pkcs8_private_key(self): + self.assertEqual(self.signer_pkcs8.signature_v1("Hello world"), self.signer.signature_v1("Hello world")) + self.assertEqual(self.signer_pkcs8.signature_v2("Hello world"), self.signer.signature_v2("Hello world")) diff --git a/tests/utils_test.py b/tests/utils_test.py index 81673d9..fed0dc7 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -4,6 +4,7 @@ from mauth_client.utils import to_rsa_format PRIVATE_KEY = load_key("priv").strip() +PRIVATE_KEY_PKCS8 = load_key("pkcs8").strip() class TestToRsaFormat(unittest.TestCase): @@ -20,3 +21,12 @@ def test_newlines_removed(self): key_no_newlines = PRIVATE_KEY.replace("\n", "") key = to_rsa_format(key_no_newlines) self.assertEqual(key, PRIVATE_KEY) + + def test_proper_format_pkcs8(self): + key = to_rsa_format(PRIVATE_KEY_PKCS8) + self.assertEqual(key, PRIVATE_KEY_PKCS8) + + def test_newlines_replaced_with_spaces_pkcs8(self): + key_no_newlines = PRIVATE_KEY_PKCS8.replace("\n", " ") + key = to_rsa_format(key_no_newlines) + self.assertEqual(key, PRIVATE_KEY_PKCS8) From 8abfbcf0c39f617bfb5500c757b28f1976553713 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 21:38:46 +0000 Subject: [PATCH 2/4] Apply remaining changes --- mauth_client/rsa_signer.py | 17 +++++++++++------ tests/signer_test.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/mauth_client/rsa_signer.py b/mauth_client/rsa_signer.py index d919115..36c04e2 100644 --- a/mauth_client/rsa_signer.py +++ b/mauth_client/rsa_signer.py @@ -7,6 +7,8 @@ from .utils import make_bytes, hexdigest from pyasn1.codec.der import decoder +RSA_ALGORITHM_OID = "1.2.840.113549.1.1.1" + class RSASigner: """ @@ -25,18 +27,21 @@ def load_private_key(private_key_data): try: return rsa.PrivateKey.load_pkcs1(private_key_data, "PEM") except ValueError: - return RSASigner.load_pkcs8_private_key(private_key_data) + try: + return RSASigner.load_pkcs8_private_key(private_key_data) + except ValueError as pkcs8_error: + raise ValueError("Unable to load private key as PKCS#1 or PKCS#8 PEM") from pkcs8_error @staticmethod def load_pkcs8_private_key(private_key_data): private_key_der = rsa.pem.load_pem(private_key_data, "PRIVATE KEY") - private_key_info, _ = decoder.decode(private_key_der) + pkcs8_structure, _ = decoder.decode(private_key_der) - algorithm = str(private_key_info[1][0]) - if algorithm != "1.2.840.113549.1.1.1": - raise ValueError("Only RSA private keys are supported") + algorithm = str(pkcs8_structure[1][0]) + if algorithm != RSA_ALGORITHM_OID: + raise ValueError(f"Expected RSA algorithm OID {RSA_ALGORITHM_OID}, but got: {algorithm}") - return rsa.PrivateKey.load_pkcs1(bytes(private_key_info[2]), "DER") + return rsa.PrivateKey.load_pkcs1(bytes(pkcs8_structure[2]), "DER") def sign_v2(self, string_to_sign): """Signs the data using SHA512 for V2 protocol diff --git a/tests/signer_test.py b/tests/signer_test.py index 329cd0f..dd6ae4b 100644 --- a/tests/signer_test.py +++ b/tests/signer_test.py @@ -141,6 +141,6 @@ def test_sign_versions_bad_version(self): str(exc.exception), "SIGN_VERSIONS must be comma-separated MAuth protocol versions (e.g. 'v1,v2')" ) - def test_signature_pkcs8_private_key(self): + def test_pkcs8_and_pkcs1_signatures_match(self): self.assertEqual(self.signer_pkcs8.signature_v1("Hello world"), self.signer.signature_v1("Hello world")) self.assertEqual(self.signer_pkcs8.signature_v2("Hello world"), self.signer.signature_v2("Hello world")) From dfe9a18b0f98fbc29a1c7e2c5684520d9e064924 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 22:09:19 +0000 Subject: [PATCH 3/4] Add pyasn1 as direct dependency and update to_rsa_format docstring --- mauth_client/utils.py | 7 ++++++- pyproject.toml | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/mauth_client/utils.py b/mauth_client/utils.py index 02b6e96..71af038 100644 --- a/mauth_client/utils.py +++ b/mauth_client/utils.py @@ -45,7 +45,12 @@ def decode(byte_string: bytes) -> str: def to_rsa_format(key: str) -> str: - """Convert a private key to RSA format with proper newlines.""" + """Normalize a private key PEM string with proper newlines. + + Supports both PKCS#1 (``-----BEGIN RSA PRIVATE KEY-----``) and + PKCS#8 (``-----BEGIN PRIVATE KEY-----``) PEM formats, preserving + the original header and footer markers. + """ header, footer = next( ((hdr, ftr) for hdr, ftr in SUPPORTED_PRIVATE_KEY_FORMATS if hdr in key and ftr in key), diff --git a/pyproject.toml b/pyproject.toml index 922accf..d979c01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ python = "^3.9" requests = "^2.31.0" cachetools = "^5.3.3" rsa = "^4.9" +pyasn1 = ">=0.1.3" asgiref = "^3.8.1" charset-normalizer = "^3.3.2" importlib = "^1.0.4" From 3f07c4241b8eabd64694739c53afc029f8e31e17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 22:22:43 +0000 Subject: [PATCH 4/4] Update poetry.lock to include pyasn1 as a direct dependency --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1ee2823..a500427 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -1173,10 +1173,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "six" @@ -1421,4 +1421,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "53dd6a8cf83b7739540b670337ac3814bf2a990d9b0ab85c31ec3d000b71a196" +content-hash = "e97b12c09e03405bc1a5a1034a89342f2e1f60beff97a7ef7faaa59b75dc3dfa"