From d8bc05fd59c2399f77e97002162970e28e6b7623 Mon Sep 17 00:00:00 2001 From: Percival Lucena Date: Wed, 18 Feb 2026 13:22:14 -0300 Subject: [PATCH] feat: Implement 256-bit encryption and expand test coverage --- .gitignore | 4 + coti/crypto_utils.py | 170 +++++++++++- coti/types.py | 17 +- .../conftest.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 2055 bytes ..._crypto_utils.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 16577 bytes ...pto_utils_256.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 5570 bytes tests/conftest.py | 34 +++ tests/test_crypto_utils.py | 134 ++++++++++ tests/test_crypto_utils_256.py | 241 ++++++++++++++++++ tests/test_integration_256.py | 202 +++++++++++++++ 10 files changed, 799 insertions(+), 3 deletions(-) create mode 100644 tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_crypto_utils.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_crypto_utils_256.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_crypto_utils.py create mode 100644 tests/test_crypto_utils_256.py create mode 100644 tests/test_integration_256.py diff --git a/.gitignore b/.gitignore index 26b2b77..d69001d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ /coti_sdk.egg-info/ /build/ +.venv/ +__pycache__/ +*.pyc +Comparisson.md diff --git a/coti/crypto_utils.py b/coti/crypto_utils.py index 08b69fa..9ce626d 100644 --- a/coti/crypto_utils.py +++ b/coti/crypto_utils.py @@ -5,7 +5,7 @@ from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives.asymmetric import rsa from eth_keys import keys -from .types import CtString, CtUint, ItString, ItUint +from .types import CtUint256, ItUint256, CtString, CtUint, ItString, ItUint block_size = AES.block_size address_size = 20 @@ -94,6 +94,46 @@ def sign_input_text(sender_address: str, contract_address: str, function_selecto return sign(message, key) +def sign_input_text_256(sender_address: str, contract_address: str, function_selector: str, ct, key): + """ + Sign input text for 256-bit encrypted values. + + Similar to sign_input_text but accepts 64-byte ciphertext (CtUint256 blob). + + Args: + sender_address: Address of the sender (20 bytes, without 0x prefix) + contract_address: Address of the contract (20 bytes, without 0x prefix) + function_selector: Function selector (hex string with 0x prefix, e.g., '0x12345678') + ct: Ciphertext bytes (must be 64 bytes for uint256) + key: Signing key (32 bytes) + + Returns: + bytes: The signature + + Raises: + ValueError: If any input has invalid length + """ + function_selector_bytes = bytes.fromhex(function_selector[2:]) + + if len(sender_address) != address_size: + raise ValueError(f"Invalid sender address length: {len(sender_address)} bytes, must be {address_size} bytes") + if len(contract_address) != address_size: + raise ValueError(f"Invalid contract address length: {len(contract_address)} bytes, must be {address_size} bytes") + if len(function_selector_bytes) != function_selector_size: + raise ValueError(f"Invalid signature size: {len(function_selector_bytes)} bytes, must be {function_selector_size} bytes") + + # 256-bit IT has 64 bytes CT + if len(ct) != 64: + raise ValueError(f"Invalid ct length: {len(ct)} bytes, must be 64 bytes for uint256") + + if len(key) != key_size: + raise ValueError(f"Invalid key length: {len(key)} bytes, must be {key_size} bytes") + + message = sender_address + contract_address + function_selector_bytes + ct + + return sign(message, key) + + def sign(message, key): # Sign the message pk = keys.PrivateKey(key) @@ -122,7 +162,7 @@ def build_input_text(plaintext: int, user_aes_key: str, sender_address: str, con } -def build_string_input_text(plaintext: int, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItString: +def build_string_input_text(plaintext: str, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItString: input_text = { 'ciphertext': { 'value': [] @@ -169,6 +209,130 @@ def decrypt_uint(ciphertext: CtUint, user_aes_key: str) -> int: return decrypted_uint +def create_ciphertext_256(plaintext_int: int, user_aes_key_bytes: bytes) -> bytes: + """ + Create a 256-bit ciphertext by encrypting high and low 128-bit parts separately. + + Args: + plaintext_int: Integer value to encrypt (must fit in 256 bits) + user_aes_key_bytes: AES encryption key (16 bytes) + + Returns: + bytes: 64-byte ciphertext blob formatted as: + [high_ciphertext(16) | high_r(16) | low_ciphertext(16) | low_r(16)] + + Raises: + ValueError: If plaintext exceeds 256 bits + """ + # Convert 256-bit int to 32 bytes (Big Endian) + try: + plaintext_bytes = plaintext_int.to_bytes(32, 'big') + except OverflowError: + raise ValueError("Plaintext size must be 256 bits or smaller.") + + # Split into High and Low 128-bit parts + high_bytes = plaintext_bytes[:16] + low_bytes = plaintext_bytes[16:] + + # Encrypt High + high_ct, high_r = encrypt(user_aes_key_bytes, high_bytes) + + # Encrypt Low + low_ct, low_r = encrypt(user_aes_key_bytes, low_bytes) + + # Construct format: high.ciphertext + high.r + low.ciphertext + low.r + return high_ct + high_r + low_ct + low_r + + +def prepare_it_256(plaintext: int, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItUint256: + """ + Prepare Input Text for a 256-bit encrypted integer. + + Encrypts a 256-bit integer and creates a signed Input Text structure + suitable for smart contract interaction. + + Args: + plaintext: Integer value to encrypt (must fit in 256 bits) + user_aes_key: AES encryption key (hex string without 0x prefix, 32 hex chars) + sender_address: Address of the sender (hex string with 0x prefix, 40 hex chars) + contract_address: Address of the contract (hex string with 0x prefix, 40 hex chars) + function_selector: Function selector (hex string with 0x prefix, e.g., '0x12345678') + signing_key: Private key for signing (32 bytes) + + Returns: + ItUint256: Dictionary containing ciphertext (ciphertextHigh, ciphertextLow) and signature + + Raises: + ValueError: If plaintext exceeds 256 bits + """ + if plaintext.bit_length() > 256: + raise ValueError("Plaintext size must be 256 bits or smaller.") + + user_aes_key_bytes = bytes.fromhex(user_aes_key) + + ct_blob = create_ciphertext_256(plaintext, user_aes_key_bytes) + + # Split for types + # ct_blob is 64 bytes: [high_ct(16) | high_r(16) | low_ct(16) | low_r(16)] + high_blob = ct_blob[:32] + low_blob = ct_blob[32:] + + # Sign the full 64-byte blob + signature = sign_input_text_256( + bytes.fromhex(sender_address[2:]), + bytes.fromhex(contract_address[2:]), + function_selector, + ct_blob, + signing_key + ) + + return { + 'ciphertext': { + 'ciphertextHigh': int.from_bytes(high_blob, 'big'), + 'ciphertextLow': int.from_bytes(low_blob, 'big') + }, + 'signature': signature + } + + +def decrypt_uint256(ciphertext: CtUint256, user_aes_key: str) -> int: + """ + Decrypt a 256-bit encrypted integer. + + Decrypts both high and low 128-bit parts and combines them back into + a single 256-bit integer. + + Args: + ciphertext: CtUint256 dictionary containing ciphertextHigh and ciphertextLow + user_aes_key: AES decryption key (hex string without 0x prefix, 32 hex chars) + + Returns: + int: The decrypted 256-bit integer value + """ + user_aes_key_bytes = bytes.fromhex(user_aes_key) + + # Process High + ct_high_int = ciphertext['ciphertextHigh'] + ct_high_bytes = ct_high_int.to_bytes(32, 'big') + cipher_high = ct_high_bytes[:block_size] + r_high = ct_high_bytes[block_size:] + + plaintext_high = decrypt(user_aes_key_bytes, r_high, cipher_high) + + # Process Low + ct_low_int = ciphertext['ciphertextLow'] + ct_low_bytes = ct_low_int.to_bytes(32, 'big') + cipher_low = ct_low_bytes[:block_size] + r_low = ct_low_bytes[block_size:] + + plaintext_low = decrypt(user_aes_key_bytes, r_low, cipher_low) + + # Combine back to 256-bit int + # High part is MSB + full_bytes = plaintext_high + plaintext_low + return int.from_bytes(full_bytes, 'big') + + def decrypt_string(ciphertext: CtString, user_aes_key: str) -> str: if 'value' in ciphertext or hasattr(ciphertext, 'value'): # format when reading ciphertext from an event __ciphertext = ciphertext['value'] @@ -231,6 +395,7 @@ def decrypt_rsa(private_key_bytes: bytes, ciphertext: bytes): ) return plaintext + #This function recovers a user's key by decrypting two encrypted key shares with the given private key, #and then XORing the two key shares together. def recover_user_key(private_key_bytes: bytes, encrypted_key_share0: bytes, encrypted_key_share1: bytes): @@ -239,3 +404,4 @@ def recover_user_key(private_key_bytes: bytes, encrypted_key_share0: bytes, encr # XOR both key shares to get the user key return bytes([a ^ b for a, b in zip(key_share0, key_share1)]) + diff --git a/coti/types.py b/coti/types.py index 487c5eb..657e897 100644 --- a/coti/types.py +++ b/coti/types.py @@ -19,4 +19,19 @@ class ItStringCiphertext(TypedDict): class ItString(TypedDict): ciphertext: ItStringCiphertext - signature: List[bytes] \ No newline at end of file + signature: List[bytes] + + +class CtUint256(TypedDict): + ciphertextHigh: int + ciphertextLow: int + + +class ItUint256Ciphertext(TypedDict): + ciphertextHigh: int + ciphertextLow: int + + +class ItUint256(TypedDict): + ciphertext: ItUint256Ciphertext + signature: bytes diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07cb215cc5698f66c61531332edac928705f2ea6 GIT binary patch literal 2055 zcmbtU%}*Og6rWuiuRk!DHc}yqFpw(Lgsk~G5L!@LfeVd_EQcT^qO6wn04vOTH8Upo z5Xn_5wN6zzmsb1>z4h2Xq6eprL^Vy69D1QAQhMy6Z)UxYp-@HYNc-l^n>TOX{(kSx z^{#jn!8mGsXa5{P$Px>Gs7>YMZ%`^|9p#iI#1uKJi-D4wM2kVzk@R*#EF^a{ropT( zg%=|%BKHDI(Zv{xCDCW0Y_A{u^_&a7gQl8G7Qeu!qs^!50-sQuPxl2rTANSe0-tc3 zPft#{ic*myk>7Gk;lUkTrZ6YAu!D)ouwi0qfbkS^fFu&%Nd-g&t%3AL*2mO0z?|)9 zTQt=ino>OVePy_5+v-E;3QD3i&G+|F%SL?5>)GFf>=hngS-7)e+`Y4qGj1>3OLcIy zNV$fcJ)0CA9@xT+h;(^i8}I*sPDB(wo4!jip=lQr%ic2!dZA=t$4pyAW=nB{=67zm zWwu>((hO5Z!Rl-ZO?S&YT!PaA7!rqQA^7Mhhzj~EggU!wGu4^eTy^ds-H44G#xnI- z=BYl{&~F{;^H22ohMuVhGb9EF&d2o+8S4Mw>Y`QTBb%QV$gZMn3t0%?967~jqU8-8 zBfN9!=w;svp-+_+E)~oCFD|#cI zu4GtY}!<*xI3`UBk@h38plqN?f?=?nwNuHSz1B*vUf|G~Gx) z1il0xy#_*dLbR)ve3Y#BjWi{Y@(3O_{7BIL3-g2Q5{Z*d<&yRR-ObU;U zh^G_$fY=Q4NXx`E-xh_#!rd?lRK8SY-|#SI+k#x`QQsq;INc&;7q~7lyFk6yQQNfl zS&3lzqDjUjqeLJj^FIq}_=DYIzEr?-Bn>WtIQlJ!j^A-{>M literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_crypto_utils.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_crypto_utils.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e219eb1399a465cc924a2bfad6ff6eb377fbad36 GIT binary patch literal 16577 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 0 + + # Decrypt + decrypted = decrypt_string(it['ciphertext'], user_key) + assert decrypted == plaintext + +def test_build_string_input_text_empty(user_key, sender_address, contract_address, function_selector, private_key_bytes): + plaintext = "" + it = build_string_input_text( + plaintext, + user_key, + sender_address, + contract_address, + function_selector, + private_key_bytes + ) + + assert len(it['ciphertext']['value']) == 0 + + decrypted = decrypt_string(it['ciphertext'], user_key) + assert decrypted == plaintext + +def test_build_string_input_text_long(user_key, sender_address, contract_address, function_selector, private_key_bytes): + # Long string that spans multiple blocks + plaintext = "A" * 100 + + it = build_string_input_text( + plaintext, + user_key, + sender_address, + contract_address, + function_selector, + private_key_bytes + ) + + # 100 bytes / 8 bytes per chunk = 13 chunks (12 full, 1 partial) + assert len(it['ciphertext']['value']) == 13 + + decrypted = decrypt_string(it['ciphertext'], user_key) + assert decrypted == plaintext diff --git a/tests/test_crypto_utils_256.py b/tests/test_crypto_utils_256.py new file mode 100644 index 0000000..1325bfa --- /dev/null +++ b/tests/test_crypto_utils_256.py @@ -0,0 +1,241 @@ + +from coti.crypto_utils import ( + build_input_text, decrypt_uint, prepare_it_256, decrypt_uint256, + generate_aes_key, sign_input_text_256 +) +import pytest +import os + +# Mock keys usually 32 bytes hex +MOCK_AES_KEY = generate_aes_key().hex() +MOCK_SENDER = "0x" + "1" * 40 +MOCK_CONTRACT = "0x" + "2" * 40 +MOCK_SELECTOR = "0x12345678" +# Using a dummy signing key (private key) - we can use an account from brownie or generate one +# For simple unit testing of crypto logic without full eth keys lib dependency, we might need to mock signature +# But crypto_utils imports 'keys' from 'eth_keys'. +# Let's try to use a dummy 32-byte key for signing +MOCK_SIGNING_KEY = os.urandom(32) + + +def test_encryption_decryption_256(): + # Test with a value > 128 bits + plaintext_256 = (1 << 240) + 123456789 + + # Prepare IT (Encrypt) + # We use a mocked signing key, actual signature validity doesn't matter for decrypt unit test + it = prepare_it_256( + plaintext_256, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + # Check structure + assert 'ciphertext' in it + assert 'ciphertextHigh' in it['ciphertext'] + assert 'ciphertextLow' in it['ciphertext'] + assert 'signature' in it + + # Decrypt + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + + assert decrypted_val == plaintext_256 + + +def test_encryption_decryption_256_small_value(): + # Test with a small value fitting in 128 bits, but using 256 pipeline + plaintext_small = 42 + + it = prepare_it_256( + plaintext_small, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + + assert decrypted_val == plaintext_small + + +def test_overflow_check(): + # Test value > 256 bits + plaintext_large = 1 << 257 + + with pytest.raises(ValueError): + prepare_it_256( + plaintext_large, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + +def test_max_256_bit_value(): + """Test with exactly 256-bit value (2^256 - 1)""" + plaintext_max = (1 << 256) - 1 + + it = prepare_it_256( + plaintext_max, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + + assert decrypted_val == plaintext_max + + +def test_various_bit_lengths(): + """Test round-trip with various bit lengths (129-bit, 200-bit, 255-bit)""" + test_values = [ + (1 << 128) + 1, # 129-bit value + (1 << 199) + 12345, # 200-bit value + (1 << 254) + 9876543210, # 255-bit value + ] + + for plaintext in test_values: + it = prepare_it_256( + plaintext, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + assert decrypted_val == plaintext, f"Failed for {plaintext.bit_length()}-bit value" + + +def test_sign_input_text_256_invalid_sender_address(): + """Test sign_input_text_256 with invalid sender address length""" + ct_blob = b'0' * 64 + + # Invalid sender address (19 bytes instead of 20) + invalid_sender = b'1' * 19 + valid_contract = b'2' * 20 + + with pytest.raises(ValueError, match="Invalid sender address length"): + sign_input_text_256( + invalid_sender, + valid_contract, + MOCK_SELECTOR, + ct_blob, + MOCK_SIGNING_KEY + ) + + +def test_sign_input_text_256_invalid_contract_address(): + """Test sign_input_text_256 with invalid contract address length""" + ct_blob = b'0' * 64 + + valid_sender = b'1' * 20 + invalid_contract = b'2' * 21 # 21 bytes instead of 20 + + with pytest.raises(ValueError, match="Invalid contract address length"): + sign_input_text_256( + valid_sender, + invalid_contract, + MOCK_SELECTOR, + ct_blob, + MOCK_SIGNING_KEY + ) + + +def test_sign_input_text_256_invalid_ct_length(): + """Test sign_input_text_256 with invalid ciphertext length""" + ct_blob = b'0' * 32 # 32 bytes instead of 64 + + valid_sender = b'1' * 20 + valid_contract = b'2' * 20 + + with pytest.raises(ValueError, match="Invalid ct length.*must be 64 bytes"): + sign_input_text_256( + valid_sender, + valid_contract, + MOCK_SELECTOR, + ct_blob, + MOCK_SIGNING_KEY + ) + + +def test_sign_input_text_256_invalid_key_length(): + """Test sign_input_text_256 with invalid signing key length""" + ct_blob = b'0' * 64 + + valid_sender = b'1' * 20 + valid_contract = b'2' * 20 + invalid_key = os.urandom(31) # 31 bytes instead of 32 + + with pytest.raises(ValueError, match="Invalid key length"): + sign_input_text_256( + valid_sender, + valid_contract, + MOCK_SELECTOR, + ct_blob, + invalid_key + ) + + +def test_boundary_values(): + """Test boundary values around 128-bit threshold""" + boundary_values = [ + (1 << 128) - 1, # Max 128-bit value + 1 << 128, # Min 129-bit value (boundary) + 0, # Zero + 1, # One + ] + + for plaintext in boundary_values: + it = prepare_it_256( + plaintext, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + assert decrypted_val == plaintext + + +def test_high_low_split(): + """Test that high and low parts are correctly split and combined""" + # Value where high part is non-zero and low part is non-zero + high_value = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF # Max 128-bit value + low_value = 0x123456789ABCDEF0123456789ABCDEF0 # Another 128-bit value + + # Combine: high_value << 128 | low_value + plaintext = (high_value << 128) | low_value + + it = prepare_it_256( + plaintext, + MOCK_AES_KEY, + MOCK_SENDER, + MOCK_CONTRACT, + MOCK_SELECTOR, + MOCK_SIGNING_KEY + ) + + decrypted_val = decrypt_uint256(it['ciphertext'], MOCK_AES_KEY) + assert decrypted_val == plaintext + + # Verify the split is correct by checking bit patterns + decrypted_high = decrypted_val >> 128 + decrypted_low = decrypted_val & ((1 << 128) - 1) + + assert decrypted_high == high_value + assert decrypted_low == low_value + diff --git a/tests/test_integration_256.py b/tests/test_integration_256.py new file mode 100644 index 0000000..b3d019b --- /dev/null +++ b/tests/test_integration_256.py @@ -0,0 +1,202 @@ +""" +Integration tests for 256-bit encryption compatibility. + +These tests verify that the Python SDK's 256-bit implementation is compatible +with the TypeScript SDK by using known test vectors and edge cases. +""" + +import pytest +from coti.crypto_utils import prepare_it_256, decrypt_uint256, generate_aes_key, create_ciphertext_256 +import os + + +# Test vectors for cross-SDK compatibility +# These should match the TypeScript SDK's expected behavior +class TestCrossSdkCompatibility: + """Tests to ensure compatibility with TypeScript SDK""" + + def test_ciphertext_structure(self): + """Verify ciphertext structure matches TypeScript SDK format""" + plaintext = 123456789012345678901234567890 + aes_key = generate_aes_key().hex() + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + + # Verify structure matches expected TypeScript format + assert 'ciphertext' in it + assert 'signature' in it + assert 'ciphertextHigh' in it['ciphertext'] + assert 'ciphertextLow' in it['ciphertext'] + + # Verify both parts are integers + assert isinstance(it['ciphertext']['ciphertextHigh'], int) + assert isinstance(it['ciphertext']['ciphertextLow'], int) + assert isinstance(it['signature'], bytes) + + def test_ciphertext_blob_size(self): + """Verify the internal ciphertext blob is 64 bytes as expected""" + plaintext = (1 << 200) + 42 + aes_key_bytes = generate_aes_key() + + ct_blob = create_ciphertext_256(plaintext, aes_key_bytes) + + # Should be 64 bytes: high_ct(16) + high_r(16) + low_ct(16) + low_r(16) + assert len(ct_blob) == 64 + + # Verify it can be split into 4 equal parts + assert len(ct_blob[:16]) == 16 # high_ct + assert len(ct_blob[16:32]) == 16 # high_r + assert len(ct_blob[32:48]) == 16 # low_ct + assert len(ct_blob[48:64]) == 16 # low_r + + def test_deterministic_encryption_with_same_plaintext(self): + """Verify that encrypting the same plaintext produces different ciphertexts (due to random 'r')""" + plaintext = 987654321 + aes_key = generate_aes_key().hex() + sender = "0x" + "a" * 40 + contract = "0x" + "b" * 40 + selector = "0x11223344" + signing_key = os.urandom(32) + + it1 = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + it2 = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + + # Ciphertexts should be different due to random 'r' + assert (it1['ciphertext']['ciphertextHigh'] != it2['ciphertext']['ciphertextHigh'] or + it1['ciphertext']['ciphertextLow'] != it2['ciphertext']['ciphertextLow']) + + # But both should decrypt to the same plaintext + decrypted1 = decrypt_uint256(it1['ciphertext'], aes_key) + decrypted2 = decrypt_uint256(it2['ciphertext'], aes_key) + + assert decrypted1 == plaintext + assert decrypted2 == plaintext + + def test_known_test_vectors(self): + """ + Test with known values that could be shared between SDKs. + + Note: In a real integration test, these would be test vectors + generated by the TypeScript SDK and verified here. + """ + test_cases = [ + 0, + 1, + 255, + 256, + 65535, + 65536, + (1 << 128) - 1, # Max 128-bit + 1 << 128, # Min 129-bit + (1 << 256) - 1, # Max 256-bit + ] + + aes_key = generate_aes_key().hex() + + for plaintext in test_cases: + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + decrypted = decrypt_uint256(it['ciphertext'], aes_key) + + assert decrypted == plaintext, f"Failed for test case: {plaintext}" + + def test_high_part_only_nonzero(self): + """Test value where only high 128 bits are non-zero""" + # Value = 0xFF...FF << 128 (high part all ones, low part all zeros) + plaintext = ((1 << 128) - 1) << 128 + + aes_key = generate_aes_key().hex() + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + decrypted = decrypt_uint256(it['ciphertext'], aes_key) + + assert decrypted == plaintext + + def test_low_part_only_nonzero(self): + """Test value where only low 128 bits are non-zero""" + # Value = 0xFF...FF (low part all ones, high part all zeros) + plaintext = (1 << 128) - 1 + + aes_key = generate_aes_key().hex() + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + decrypted = decrypt_uint256(it['ciphertext'], aes_key) + + assert decrypted == plaintext + + def test_alternating_bit_pattern(self): + """Test with alternating bit patterns""" + + high = 0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + low = 0x55555555555555555555555555555555 + plaintext = (high << 128) | low + + aes_key = generate_aes_key().hex() + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, aes_key, sender, contract, selector, signing_key) + decrypted = decrypt_uint256(it['ciphertext'], aes_key) + + assert decrypted == plaintext + + +class TestMultipleKeyScenarios: + """Test encryption/decryption with different AES keys""" + + def test_different_keys_produce_different_results(self): + """Verify that different AES keys produce different ciphertexts""" + plaintext = 123456789 + key1 = generate_aes_key().hex() + key2 = generate_aes_key().hex() + + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it1 = prepare_it_256(plaintext, key1, sender, contract, selector, signing_key) + it2 = prepare_it_256(plaintext, key2, sender, contract, selector, signing_key) + + # Different keys should produce different ciphertexts + assert (it1['ciphertext']['ciphertextHigh'] != it2['ciphertext']['ciphertextHigh'] or + it1['ciphertext']['ciphertextLow'] != it2['ciphertext']['ciphertextLow']) + + def test_wrong_key_produces_wrong_plaintext(self): + """Verify that decrypting with wrong key doesn't recover original plaintext""" + plaintext = 987654321 + correct_key = generate_aes_key().hex() + wrong_key = generate_aes_key().hex() + + sender = "0x" + "1" * 40 + contract = "0x" + "2" * 40 + selector = "0x12345678" + signing_key = os.urandom(32) + + it = prepare_it_256(plaintext, correct_key, sender, contract, selector, signing_key) + + # Decrypt with correct key + decrypted_correct = decrypt_uint256(it['ciphertext'], correct_key) + assert decrypted_correct == plaintext + + # Decrypt with wrong key + decrypted_wrong = decrypt_uint256(it['ciphertext'], wrong_key) + assert decrypted_wrong != plaintext