A Python port of the Invisible Internet Project (I2P)
1"""Tests for i2p_crypto.aes_cbc — AES-256-CBC for NTCP2 handshake obfuscation.
2
3Covers:
4 - Round-trip encrypt/decrypt for 16, 32, 48 bytes
5 - 32-byte block specifically (the NTCP2 use case)
6 - Known NIST AES-256-CBC test vectors (SP 800-38A F.2.5/F.2.6)
7 - Ciphertext differs from plaintext
8 - Different IVs produce different ciphertext
9 - Wrong key/IV fails to decrypt correctly
10 - IV chaining: encrypting 32 bytes, bytes 16-31 of ciphertext become new IV
11 - Input validation (key size, IV size, block alignment)
12"""
13
14from __future__ import annotations
15
16import hashlib
17import os
18
19import pytest
20
21from i2p_crypto.aes_cbc import aes_cbc_decrypt, aes_cbc_encrypt
22
23
24# ---------------------------------------------------------------------------
25# Helpers
26# ---------------------------------------------------------------------------
27
28def _random_key() -> bytes:
29 """Return a random 32-byte AES-256 key."""
30 return os.urandom(32)
31
32
33def _random_iv() -> bytes:
34 """Return a random 16-byte IV."""
35 return os.urandom(16)
36
37
38# ---------------------------------------------------------------------------
39# Round-trip tests
40# ---------------------------------------------------------------------------
41
42class TestRoundTrip:
43 """encrypt then decrypt must return the original plaintext."""
44
45 def test_16_bytes(self):
46 key, iv = _random_key(), _random_iv()
47 plaintext = os.urandom(16)
48 ciphertext = aes_cbc_encrypt(key, iv, plaintext)
49 assert len(ciphertext) == 16
50 assert aes_cbc_decrypt(key, iv, ciphertext) == plaintext
51
52 def test_32_bytes(self):
53 key, iv = _random_key(), _random_iv()
54 plaintext = os.urandom(32)
55 ciphertext = aes_cbc_encrypt(key, iv, plaintext)
56 assert len(ciphertext) == 32
57 assert aes_cbc_decrypt(key, iv, ciphertext) == plaintext
58
59 def test_48_bytes(self):
60 key, iv = _random_key(), _random_iv()
61 plaintext = os.urandom(48)
62 ciphertext = aes_cbc_encrypt(key, iv, plaintext)
63 assert len(ciphertext) == 48
64 assert aes_cbc_decrypt(key, iv, ciphertext) == plaintext
65
66 def test_deterministic(self):
67 """Same key+IV+plaintext must produce identical ciphertext."""
68 key, iv = _random_key(), _random_iv()
69 plaintext = os.urandom(32)
70 c1 = aes_cbc_encrypt(key, iv, plaintext)
71 c2 = aes_cbc_encrypt(key, iv, plaintext)
72 assert c1 == c2
73
74
75# ---------------------------------------------------------------------------
76# NTCP2 32-byte use case
77# ---------------------------------------------------------------------------
78
79class TestNTCP2UseCase:
80 """NTCP2 handshake message 1: 32-byte ephemeral key X encrypted."""
81
82 def test_ntcp2_encrypt_decrypt_32_byte_key(self):
83 """Simulate NTCP2 message 1 obfuscation of ephemeral key X.
84
85 Key = SHA-256(responder RouterInfo hash)
86 IV = responder 'i' parameter (16 bytes)
87 Plaintext = 32 bytes (ephemeral key X, exactly 2 AES blocks)
88 """
89 router_info_hash = os.urandom(32)
90 aes_key = hashlib.sha256(router_info_hash).digest()
91 iv = os.urandom(16) # responder's "i" parameter
92 ephemeral_key_x = os.urandom(32)
93
94 ciphertext = aes_cbc_encrypt(aes_key, iv, ephemeral_key_x)
95 assert len(ciphertext) == 32
96 recovered = aes_cbc_decrypt(aes_key, iv, ciphertext)
97 assert recovered == ephemeral_key_x
98
99 def test_iv_chaining_for_message_2(self):
100 """After encrypting 32 bytes, bytes 16-31 of ciphertext become
101 the IV for message 2 encryption.
102
103 This tests that the IV chaining property of CBC holds: the last
104 ciphertext block is usable as an IV for subsequent encryption.
105 """
106 aes_key = _random_key()
107 iv1 = _random_iv()
108 plaintext_msg1 = os.urandom(32)
109
110 ciphertext_msg1 = aes_cbc_encrypt(aes_key, iv1, plaintext_msg1)
111
112 # In NTCP2, bytes 16-31 of the first ciphertext become IV for msg2
113 iv2 = ciphertext_msg1[16:32]
114
115 plaintext_msg2 = os.urandom(32)
116 ciphertext_msg2 = aes_cbc_encrypt(aes_key, iv2, plaintext_msg2)
117 recovered_msg2 = aes_cbc_decrypt(aes_key, iv2, ciphertext_msg2)
118 assert recovered_msg2 == plaintext_msg2
119
120 # The two ciphertexts should differ (different IVs, different plaintexts)
121 assert ciphertext_msg1 != ciphertext_msg2
122
123
124# ---------------------------------------------------------------------------
125# Known test vectors — NIST SP 800-38A AES-256-CBC (F.2.5 / F.2.6)
126# ---------------------------------------------------------------------------
127
128class TestKnownVectors:
129 """NIST SP 800-38A AES-256-CBC test vectors."""
130
131 NIST_KEY = bytes.fromhex(
132 "603deb1015ca71be2b73aef0857d7781"
133 "1f352c073b6108d72d9810a30914dff4"
134 )
135 NIST_IV = bytes.fromhex("000102030405060708090a0b0c0d0e0f")
136
137 NIST_PLAINTEXT = bytes.fromhex(
138 "6bc1bee22e409f96e93d7e117393172a"
139 "ae2d8a571e03ac9c9eb76fac45af8e51"
140 "30c81c46a35ce411e5fbc1191a0a52ef"
141 "f69f2445df4f9b17ad2b417be66c3710"
142 )
143
144 NIST_CIPHERTEXT = bytes.fromhex(
145 "f58c4c04d6e5f1ba779eabfb5f7bfbd6"
146 "9cfc4e967edb808d679f777bc6702c7d"
147 "39f23369a9d9bacfa530e26304231461"
148 "b2eb05e2c39be9fcda6c19078c6a9d1b"
149 )
150
151 def test_encrypt(self):
152 ct = aes_cbc_encrypt(self.NIST_KEY, self.NIST_IV, self.NIST_PLAINTEXT)
153 assert ct == self.NIST_CIPHERTEXT
154
155 def test_decrypt(self):
156 pt = aes_cbc_decrypt(self.NIST_KEY, self.NIST_IV, self.NIST_CIPHERTEXT)
157 assert pt == self.NIST_PLAINTEXT
158
159 def test_round_trip(self):
160 ct = aes_cbc_encrypt(self.NIST_KEY, self.NIST_IV, self.NIST_PLAINTEXT)
161 pt = aes_cbc_decrypt(self.NIST_KEY, self.NIST_IV, ct)
162 assert pt == self.NIST_PLAINTEXT
163
164
165# ---------------------------------------------------------------------------
166# Ciphertext differs from plaintext
167# ---------------------------------------------------------------------------
168
169class TestCiphertextDiffers:
170 """Ciphertext must not equal plaintext."""
171
172 def test_ciphertext_not_plaintext_16(self):
173 plaintext = os.urandom(16)
174 ct = aes_cbc_encrypt(_random_key(), _random_iv(), plaintext)
175 assert ct != plaintext
176
177 def test_ciphertext_not_plaintext_32(self):
178 plaintext = os.urandom(32)
179 ct = aes_cbc_encrypt(_random_key(), _random_iv(), plaintext)
180 assert ct != plaintext
181
182
183# ---------------------------------------------------------------------------
184# Different IVs produce different ciphertext
185# ---------------------------------------------------------------------------
186
187class TestDifferentIVs:
188 """Same key + plaintext but different IVs must yield different ciphertext."""
189
190 def test_different_ivs_different_ciphertext(self):
191 key = _random_key()
192 plaintext = os.urandom(32)
193 iv1 = _random_iv()
194 iv2 = _random_iv()
195 # Ensure IVs are actually different (astronomically unlikely to collide)
196 assert iv1 != iv2
197
198 ct1 = aes_cbc_encrypt(key, iv1, plaintext)
199 ct2 = aes_cbc_encrypt(key, iv2, plaintext)
200 assert ct1 != ct2
201
202
203# ---------------------------------------------------------------------------
204# Wrong key / wrong IV fails to decrypt correctly
205# ---------------------------------------------------------------------------
206
207class TestWrongKeyIV:
208 """Decrypting with wrong key or IV must not return original plaintext."""
209
210 def test_wrong_key(self):
211 key1, key2 = _random_key(), _random_key()
212 iv = _random_iv()
213 plaintext = os.urandom(32)
214 ciphertext = aes_cbc_encrypt(key1, iv, plaintext)
215 decrypted = aes_cbc_decrypt(key2, iv, ciphertext)
216 assert decrypted != plaintext
217
218 def test_wrong_iv(self):
219 key = _random_key()
220 iv1, iv2 = _random_iv(), _random_iv()
221 plaintext = os.urandom(32)
222 ciphertext = aes_cbc_encrypt(key, iv1, plaintext)
223 decrypted = aes_cbc_decrypt(key, iv2, ciphertext)
224 assert decrypted != plaintext
225
226
227# ---------------------------------------------------------------------------
228# Input validation
229# ---------------------------------------------------------------------------
230
231class TestValidation:
232 """Key, IV, and plaintext sizes must be enforced."""
233
234 def test_key_not_32_bytes(self):
235 with pytest.raises(ValueError, match="32 bytes"):
236 aes_cbc_encrypt(os.urandom(16), _random_iv(), os.urandom(16))
237
238 def test_iv_not_16_bytes(self):
239 with pytest.raises(ValueError, match="16 bytes"):
240 aes_cbc_encrypt(_random_key(), os.urandom(12), os.urandom(16))
241
242 def test_plaintext_not_aligned(self):
243 with pytest.raises(ValueError, match="multiple of 16"):
244 aes_cbc_encrypt(_random_key(), _random_iv(), os.urandom(15))
245
246 def test_plaintext_empty(self):
247 with pytest.raises(ValueError, match="empty"):
248 aes_cbc_encrypt(_random_key(), _random_iv(), b"")
249
250 def test_ciphertext_not_aligned(self):
251 with pytest.raises(ValueError, match="multiple of 16"):
252 aes_cbc_decrypt(_random_key(), _random_iv(), os.urandom(17))
253
254 def test_ciphertext_empty(self):
255 with pytest.raises(ValueError, match="empty"):
256 aes_cbc_decrypt(_random_key(), _random_iv(), b"")