Skip to content

Commit 8dca802

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

10 files changed

Lines changed: 640 additions & 3 deletions

.gitignore

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

55
/build/
6+
.venv/

Comparisson.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# SDK Comparison: Python vs TypeScript
2+
3+
This document compares the functionality available in `coti-sdk-python` against `coti-sdk-typescript`.
4+
5+
## Overview
6+
7+
Overall, both SDKs provide core capabilities for:
8+
- AES encryption/decryption (128-bit)
9+
- RSA key pair generation and decryption
10+
- User key recovery
11+
- Signing and building Input Text (IT) for smart contracts
12+
13+
**Key Difference:** The TypeScript SDK currently includes explicit support for **256-bit integer encryption/decryption** (splitting into high/low 128-bit blocks), whereas the Python SDK primarily targets 128-bit integers (32-byte ciphertext blobs containing cipher + random).
14+
15+
## Function Parity Table
16+
17+
| Feature / Functionality | Python SDK (`coti/crypto_utils.py`) | TypeScript SDK (`src/crypto_utils.ts`) | Notes |
18+
| :------------------------- | :---------------------------------- | :------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------- |
19+
| **AES Encryption** | `encrypt` | `encrypt` | Both use AES-128 (16-byte block/key). |
20+
| **AES Decryption** | `decrypt` | `decrypt` | TS `decrypt` supports optional `r2`/`ciphertext2` args for multi-block. Python matches strict 128-bit sig. |
21+
| **Generate AES Key** | `generate_aes_key` | `generateRandomAesKeySizeNumber` | Naming differs, same functionality (16 random bytes). |
22+
| **RSA KeyGen** | `generate_rsa_keypair` | `generateRSAKeyPair` | |
23+
| **RSA Decryption** | `decrypt_rsa` | `decryptRSA` | |
24+
| **Recover User Key** | `recover_user_key` | `recoverUserKey` | Decrypts shares & XORs them. |
25+
| **Sign Message** | `sign` | `sign` | |
26+
| **Sign Input Text** | `sign_input_text` | `signInputText` | |
27+
| **Build IT (Uint)** | `build_input_text` (128-bit) | `buildInputText` (64-bit), `prepareIT` (128-bit) | TS has separate methods for 64 and 128 bit. |
28+
| **Build IT (String)** | `build_string_input_text` | `buildStringInputText` | Handles string chunking into 64-bit blocks. |
29+
| **Decrypt Uint (128-bit)** | `decrypt_uint` | `decryptUint` | Standard 128-bit decryption (Cipher + R). |
30+
| **Decrypt Uint (256-bit)** | `decrypt_uint256` | `decryptUint256` | Splitting into High/Low parts. |
31+
| **Prepare IT (256-bit)** | `prepare_it_256` | `prepareIT256` | Primary entry point for 256-bit plaintexts. |
32+
| **Decrypt String** | `decrypt_string` | `decryptString` | |
33+
| **Encoding Helpers** | (Internal/Standard Lib) | `encodeString`, `encodeKey`, `encodeUint`, `decodeUint` | TS exposes these utils; Python relies on Standard Lib. |
34+
| **Encrypt Number** | (Internal steps) | `encryptNumber` | TS helper exposed (used internally in both). |
35+
36+
## Detailed Differences
37+
38+
### 256-bit Support
39+
The TypeScript SDK has specific functions (`prepareIT256`, `decryptUint256`) and logic in `decrypt` to handle 256-bit integers by splitting them into two 128-bit AES blocks. The Python SDK currently treats `CtUint` as a single 32-byte blob (16 bytes cipher + 16 bytes random), effectively limiting it to 128-bit integers.
40+
41+
### Naming Conventions
42+
- Python uses `snake_case` (e.g., `recover_user_key`).
43+
- TypeScript uses `camelCase` (e.g., `recoverUserKey`).
44+
45+
### Helper Functions
46+
The TypeScript SDK exports several low-level helper functions (`encode*`, `decode*`) which are useful for JS/TS type coercion. The Python SDK tends to keep these internal or rely on Python's rich standard library methods for bytes/int conversion.

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)