Skip to content

Commit d8bc05f

Browse files
committed
feat: Implement 256-bit encryption and expand test coverage
1 parent dda63ef commit d8bc05f

10 files changed

Lines changed: 799 additions & 3 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
/coti_sdk.egg-info/
44

55
/build/
6+
.venv/
7+
__pycache__/
8+
*.pyc
9+
Comparisson.md

coti/crypto_utils.py

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from cryptography.hazmat.primitives.asymmetric import padding
66
from cryptography.hazmat.primitives.asymmetric import rsa
77
from eth_keys import keys
8-
from .types import CtString, CtUint, ItString, ItUint
8+
from .types import CtUint256, ItUint256, CtString, CtUint, ItString, ItUint
99

1010
block_size = AES.block_size
1111
address_size = 20
@@ -94,6 +94,46 @@ def sign_input_text(sender_address: str, contract_address: str, function_selecto
9494
return sign(message, key)
9595

9696

97+
def sign_input_text_256(sender_address: str, contract_address: str, function_selector: str, ct, key):
98+
"""
99+
Sign input text for 256-bit encrypted values.
100+
101+
Similar to sign_input_text but accepts 64-byte ciphertext (CtUint256 blob).
102+
103+
Args:
104+
sender_address: Address of the sender (20 bytes, without 0x prefix)
105+
contract_address: Address of the contract (20 bytes, without 0x prefix)
106+
function_selector: Function selector (hex string with 0x prefix, e.g., '0x12345678')
107+
ct: Ciphertext bytes (must be 64 bytes for uint256)
108+
key: Signing key (32 bytes)
109+
110+
Returns:
111+
bytes: The signature
112+
113+
Raises:
114+
ValueError: If any input has invalid length
115+
"""
116+
function_selector_bytes = bytes.fromhex(function_selector[2:])
117+
118+
if len(sender_address) != address_size:
119+
raise ValueError(f"Invalid sender address length: {len(sender_address)} bytes, must be {address_size} bytes")
120+
if len(contract_address) != address_size:
121+
raise ValueError(f"Invalid contract address length: {len(contract_address)} bytes, must be {address_size} bytes")
122+
if len(function_selector_bytes) != function_selector_size:
123+
raise ValueError(f"Invalid signature size: {len(function_selector_bytes)} bytes, must be {function_selector_size} bytes")
124+
125+
# 256-bit IT has 64 bytes CT
126+
if len(ct) != 64:
127+
raise ValueError(f"Invalid ct length: {len(ct)} bytes, must be 64 bytes for uint256")
128+
129+
if len(key) != key_size:
130+
raise ValueError(f"Invalid key length: {len(key)} bytes, must be {key_size} bytes")
131+
132+
message = sender_address + contract_address + function_selector_bytes + ct
133+
134+
return sign(message, key)
135+
136+
97137
def sign(message, key):
98138
# Sign the message
99139
pk = keys.PrivateKey(key)
@@ -122,7 +162,7 @@ def build_input_text(plaintext: int, user_aes_key: str, sender_address: str, con
122162
}
123163

124164

125-
def build_string_input_text(plaintext: int, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItString:
165+
def build_string_input_text(plaintext: str, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItString:
126166
input_text = {
127167
'ciphertext': {
128168
'value': []
@@ -169,6 +209,130 @@ def decrypt_uint(ciphertext: CtUint, user_aes_key: str) -> int:
169209
return decrypted_uint
170210

171211

212+
def create_ciphertext_256(plaintext_int: int, user_aes_key_bytes: bytes) -> bytes:
213+
"""
214+
Create a 256-bit ciphertext by encrypting high and low 128-bit parts separately.
215+
216+
Args:
217+
plaintext_int: Integer value to encrypt (must fit in 256 bits)
218+
user_aes_key_bytes: AES encryption key (16 bytes)
219+
220+
Returns:
221+
bytes: 64-byte ciphertext blob formatted as:
222+
[high_ciphertext(16) | high_r(16) | low_ciphertext(16) | low_r(16)]
223+
224+
Raises:
225+
ValueError: If plaintext exceeds 256 bits
226+
"""
227+
# Convert 256-bit int to 32 bytes (Big Endian)
228+
try:
229+
plaintext_bytes = plaintext_int.to_bytes(32, 'big')
230+
except OverflowError:
231+
raise ValueError("Plaintext size must be 256 bits or smaller.")
232+
233+
# Split into High and Low 128-bit parts
234+
high_bytes = plaintext_bytes[:16]
235+
low_bytes = plaintext_bytes[16:]
236+
237+
# Encrypt High
238+
high_ct, high_r = encrypt(user_aes_key_bytes, high_bytes)
239+
240+
# Encrypt Low
241+
low_ct, low_r = encrypt(user_aes_key_bytes, low_bytes)
242+
243+
# Construct format: high.ciphertext + high.r + low.ciphertext + low.r
244+
return high_ct + high_r + low_ct + low_r
245+
246+
247+
def prepare_it_256(plaintext: int, user_aes_key: str, sender_address: str, contract_address: str, function_selector: str, signing_key: str) -> ItUint256:
248+
"""
249+
Prepare Input Text for a 256-bit encrypted integer.
250+
251+
Encrypts a 256-bit integer and creates a signed Input Text structure
252+
suitable for smart contract interaction.
253+
254+
Args:
255+
plaintext: Integer value to encrypt (must fit in 256 bits)
256+
user_aes_key: AES encryption key (hex string without 0x prefix, 32 hex chars)
257+
sender_address: Address of the sender (hex string with 0x prefix, 40 hex chars)
258+
contract_address: Address of the contract (hex string with 0x prefix, 40 hex chars)
259+
function_selector: Function selector (hex string with 0x prefix, e.g., '0x12345678')
260+
signing_key: Private key for signing (32 bytes)
261+
262+
Returns:
263+
ItUint256: Dictionary containing ciphertext (ciphertextHigh, ciphertextLow) and signature
264+
265+
Raises:
266+
ValueError: If plaintext exceeds 256 bits
267+
"""
268+
if plaintext.bit_length() > 256:
269+
raise ValueError("Plaintext size must be 256 bits or smaller.")
270+
271+
user_aes_key_bytes = bytes.fromhex(user_aes_key)
272+
273+
ct_blob = create_ciphertext_256(plaintext, user_aes_key_bytes)
274+
275+
# Split for types
276+
# ct_blob is 64 bytes: [high_ct(16) | high_r(16) | low_ct(16) | low_r(16)]
277+
high_blob = ct_blob[:32]
278+
low_blob = ct_blob[32:]
279+
280+
# Sign the full 64-byte blob
281+
signature = sign_input_text_256(
282+
bytes.fromhex(sender_address[2:]),
283+
bytes.fromhex(contract_address[2:]),
284+
function_selector,
285+
ct_blob,
286+
signing_key
287+
)
288+
289+
return {
290+
'ciphertext': {
291+
'ciphertextHigh': int.from_bytes(high_blob, 'big'),
292+
'ciphertextLow': int.from_bytes(low_blob, 'big')
293+
},
294+
'signature': signature
295+
}
296+
297+
298+
def decrypt_uint256(ciphertext: CtUint256, user_aes_key: str) -> int:
299+
"""
300+
Decrypt a 256-bit encrypted integer.
301+
302+
Decrypts both high and low 128-bit parts and combines them back into
303+
a single 256-bit integer.
304+
305+
Args:
306+
ciphertext: CtUint256 dictionary containing ciphertextHigh and ciphertextLow
307+
user_aes_key: AES decryption key (hex string without 0x prefix, 32 hex chars)
308+
309+
Returns:
310+
int: The decrypted 256-bit integer value
311+
"""
312+
user_aes_key_bytes = bytes.fromhex(user_aes_key)
313+
314+
# Process High
315+
ct_high_int = ciphertext['ciphertextHigh']
316+
ct_high_bytes = ct_high_int.to_bytes(32, 'big')
317+
cipher_high = ct_high_bytes[:block_size]
318+
r_high = ct_high_bytes[block_size:]
319+
320+
plaintext_high = decrypt(user_aes_key_bytes, r_high, cipher_high)
321+
322+
# Process Low
323+
ct_low_int = ciphertext['ciphertextLow']
324+
ct_low_bytes = ct_low_int.to_bytes(32, 'big')
325+
cipher_low = ct_low_bytes[:block_size]
326+
r_low = ct_low_bytes[block_size:]
327+
328+
plaintext_low = decrypt(user_aes_key_bytes, r_low, cipher_low)
329+
330+
# Combine back to 256-bit int
331+
# High part is MSB
332+
full_bytes = plaintext_high + plaintext_low
333+
return int.from_bytes(full_bytes, 'big')
334+
335+
172336
def decrypt_string(ciphertext: CtString, user_aes_key: str) -> str:
173337
if 'value' in ciphertext or hasattr(ciphertext, 'value'): # format when reading ciphertext from an event
174338
__ciphertext = ciphertext['value']
@@ -231,6 +395,7 @@ def decrypt_rsa(private_key_bytes: bytes, ciphertext: bytes):
231395
)
232396
return plaintext
233397

398+
234399
#This function recovers a user's key by decrypting two encrypted key shares with the given private key,
235400
#and then XORing the two key shares together.
236401
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
239404

240405
# XOR both key shares to get the user key
241406
return bytes([a ^ b for a, b in zip(key_share0, key_share1)])
407+

coti/types.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,19 @@ class ItStringCiphertext(TypedDict):
1919

2020
class ItString(TypedDict):
2121
ciphertext: ItStringCiphertext
22-
signature: List[bytes]
22+
signature: List[bytes]
23+
24+
25+
class CtUint256(TypedDict):
26+
ciphertextHigh: int
27+
ciphertextLow: int
28+
29+
30+
class ItUint256Ciphertext(TypedDict):
31+
ciphertextHigh: int
32+
ciphertextLow: int
33+
34+
35+
class ItUint256(TypedDict):
36+
ciphertext: ItUint256Ciphertext
37+
signature: bytes
2.01 KB
Binary file not shown.
16.2 KB
Binary file not shown.
Binary file not shown.

tests/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import pytest
2+
import os
3+
from eth_keys import keys
4+
from coti.crypto_utils import generate_aes_key
5+
6+
@pytest.fixture
7+
def user_key():
8+
# Return hex string of the key
9+
return os.environ.get("TEST_USER_KEY") or generate_aes_key().hex()
10+
11+
@pytest.fixture
12+
def private_key_bytes():
13+
pk_hex = os.environ.get("TEST_PRIVATE_KEY")
14+
if pk_hex:
15+
# Handle 0x prefix if present
16+
clean_hex = pk_hex[2:] if pk_hex.startswith("0x") else pk_hex
17+
return bytes.fromhex(clean_hex)
18+
return os.urandom(32)
19+
20+
@pytest.fixture
21+
def sender_address(private_key_bytes):
22+
# Derive address from private key to ensure consistency in signing checks if needed
23+
pk = keys.PrivateKey(private_key_bytes)
24+
return pk.public_key.to_checksum_address()
25+
26+
@pytest.fixture
27+
def contract_address():
28+
# Dummy contract address
29+
return "0x1000000000000000000000000000000000000001"
30+
31+
@pytest.fixture
32+
def function_selector():
33+
# Dummy function selector
34+
return "0x11223344"

0 commit comments

Comments
 (0)