A Python port of the Invisible Internet Project (I2P)
1"""Tests for i2p_crypto.chacha20 — ChaCha20 stream cipher.
2
3Covers:
4- RFC 7539 Section 2.4.2 test vector
5- Round-trip encrypt/decrypt
6- 16-byte nonce (counter prefix) variant
7- Invalid key/nonce rejection
8- Empty plaintext
9"""
10
11import os
12
13import pytest
14
15from i2p_crypto.chacha20 import ChaCha20
16
17
18# ---- RFC 7539 Section 2.4.2 test vector ----
19
20RFC7539_KEY = bytes(range(32)) # 00 01 02 ... 1f
21
22RFC7539_NONCE = bytes([
23 0x00, 0x00, 0x00, 0x00,
24 0x00, 0x00, 0x00, 0x4a,
25 0x00, 0x00, 0x00, 0x00,
26])
27
28RFC7539_PLAINTEXT = (
29 b"Ladies and Gentlemen of the class of '99: "
30 b"If I could offer you only one tip for the future, "
31 b"sunscreen would be it."
32)
33
34RFC7539_CIPHERTEXT_HEX = (
35 "6e2e359a2568f98041ba0728dd0d6981"
36 "e97e7aec1d4360c20a27afccfd9fae0b"
37 "f91b65c5524733ab8f593dabcd62b357"
38 "1639d624e65152ab8f530c359f0861d8"
39 "07ca0dbf500d6a6156a38e088a22b65e"
40 "52bc514d16ccf806818ce91ab7793736"
41 "5af90bbf74a35be6b40b8eedf2785e42"
42 "874d"
43)
44
45RFC7539_CIPHERTEXT = bytes.fromhex(RFC7539_CIPHERTEXT_HEX)
46
47
48class TestChaCha20RFC7539:
49 """RFC 7539 Section 2.4.2 test vectors."""
50
51 def test_encrypt_matches_rfc_vector(self) -> None:
52 ct = ChaCha20.encrypt(RFC7539_KEY, RFC7539_NONCE, RFC7539_PLAINTEXT)
53 assert ct == RFC7539_CIPHERTEXT
54
55 def test_decrypt_matches_rfc_vector(self) -> None:
56 pt = ChaCha20.decrypt(RFC7539_KEY, RFC7539_NONCE, RFC7539_CIPHERTEXT)
57 assert pt == RFC7539_PLAINTEXT
58
59
60class TestChaCha20RoundTrip:
61 """Encrypt then decrypt produces original plaintext."""
62
63 def test_round_trip_short(self) -> None:
64 key = os.urandom(32)
65 nonce = os.urandom(12)
66 plaintext = b"hello world"
67 ct = ChaCha20.encrypt(key, nonce, plaintext)
68 assert ct != plaintext
69 pt = ChaCha20.decrypt(key, nonce, ct)
70 assert pt == plaintext
71
72 def test_round_trip_1024_bytes(self) -> None:
73 key = os.urandom(32)
74 nonce = os.urandom(12)
75 plaintext = os.urandom(1024)
76 ct = ChaCha20.encrypt(key, nonce, plaintext)
77 pt = ChaCha20.decrypt(key, nonce, ct)
78 assert pt == plaintext
79
80 def test_round_trip_empty(self) -> None:
81 key = os.urandom(32)
82 nonce = os.urandom(12)
83 ct = ChaCha20.encrypt(key, nonce, b"")
84 assert ct == b""
85 pt = ChaCha20.decrypt(key, nonce, ct)
86 assert pt == b""
87
88 def test_round_trip_16_byte_nonce(self) -> None:
89 """16-byte nonce: 4-byte LE counter prefix + 12-byte nonce."""
90 key = os.urandom(32)
91 # counter=1 little-endian + 12-byte nonce
92 counter_prefix = (1).to_bytes(4, "little")
93 nonce_12 = os.urandom(12)
94 nonce_16 = counter_prefix + nonce_12
95 plaintext = b"sixteen-byte nonce test"
96 ct = ChaCha20.encrypt(key, nonce_16, plaintext)
97 pt = ChaCha20.decrypt(key, nonce_16, ct)
98 assert pt == plaintext
99
100 def test_12_and_16_byte_nonce_equivalence(self) -> None:
101 """12-byte nonce and 16-byte nonce with counter=1 produce the same output."""
102 key = os.urandom(32)
103 nonce_12 = os.urandom(12)
104 nonce_16 = (1).to_bytes(4, "little") + nonce_12
105 plaintext = b"equivalence check"
106 ct_12 = ChaCha20.encrypt(key, nonce_12, plaintext)
107 ct_16 = ChaCha20.encrypt(key, nonce_16, plaintext)
108 assert ct_12 == ct_16
109
110
111class TestChaCha20Validation:
112 """Input validation."""
113
114 def test_short_key_rejected(self) -> None:
115 with pytest.raises(ValueError, match="at least 32 bytes"):
116 ChaCha20.encrypt(b"\x00" * 16, b"\x00" * 12, b"data")
117
118 def test_bad_nonce_length_rejected(self) -> None:
119 with pytest.raises(ValueError, match="12 or 16 bytes"):
120 ChaCha20.encrypt(b"\x00" * 32, b"\x00" * 8, b"data")
121
122 def test_no_instantiation(self) -> None:
123 with pytest.raises(TypeError):
124 ChaCha20()
125
126 def test_different_keys_produce_different_ciphertext(self) -> None:
127 nonce = os.urandom(12)
128 plaintext = b"key sensitivity test"
129 ct1 = ChaCha20.encrypt(os.urandom(32), nonce, plaintext)
130 ct2 = ChaCha20.encrypt(os.urandom(32), nonce, plaintext)
131 assert ct1 != ct2
132
133 def test_different_nonces_produce_different_ciphertext(self) -> None:
134 key = os.urandom(32)
135 plaintext = b"nonce sensitivity test"
136 ct1 = ChaCha20.encrypt(key, os.urandom(12), plaintext)
137 ct2 = ChaCha20.encrypt(key, os.urandom(12), plaintext)
138 assert ct1 != ct2