A Python port of the Invisible Internet Project (I2P)
1"""Tests for SipHash-2-4 implementation.
2
3Test vectors from the SipHash paper by Jean-Philippe Aumasson and Daniel J. Bernstein.
4Key = 00 01 02 ... 0f => k0 = 0x0706050403020100, k1 = 0x0f0e0d0c0b0a0908
5"""
6
7import pytest
8
9from i2p_crypto.siphash import siphash_2_4, SipHashRatchet
10
11
12# Standard key from the SipHash paper
13K0 = 0x0706050403020100
14K1 = 0x0f0e0d0c0b0a0908
15
16# Official test vectors from the SipHash reference implementation.
17# Source: https://github.com/veorq/SipHash/blob/master/vectors.h
18# The reference outputs are given as byte arrays; the uint64_t value
19# is the LE interpretation of those bytes.
20# Key = 00 01 02 ... 0f, input of length n = 00 01 ... (n-1).
21_VEC_BYTES = [
22 [0x31, 0x0e, 0x0e, 0xdd, 0x47, 0xdb, 0x6f, 0x72],
23 [0xfd, 0x67, 0xdc, 0x93, 0xc5, 0x39, 0xf8, 0x74],
24 [0x5a, 0x4f, 0xa9, 0xd9, 0x09, 0x80, 0x6c, 0x0d],
25]
26
27PAPER_VECTORS = [
28 (bytes(range(i)), int.from_bytes(bytes(v), "little"))
29 for i, v in enumerate(_VEC_BYTES)
30]
31
32
33class TestSipHash24:
34 """Core SipHash-2-4 function tests."""
35
36 @pytest.mark.parametrize("data,expected", PAPER_VECTORS)
37 def test_paper_vectors(self, data, expected):
38 """Verify against the official SipHash paper test vectors."""
39 result = siphash_2_4(K0, K1, data)
40 assert result == expected, (
41 f"siphash_2_4({K0:#x}, {K1:#x}, {data!r}) = {result:#018x}, "
42 f"expected {expected:#018x}"
43 )
44
45 def test_return_type_is_int(self):
46 """Return value must be a Python int."""
47 result = siphash_2_4(K0, K1, b"hello")
48 assert isinstance(result, int)
49
50 def test_output_fits_64_bits(self):
51 """Output must be a 64-bit unsigned integer."""
52 result = siphash_2_4(K0, K1, b"test data 12345678")
53 assert 0 <= result < (1 << 64)
54
55 def test_different_keys_produce_different_output(self):
56 """Different keys must produce different hashes for the same data."""
57 data = b"same input"
58 h1 = siphash_2_4(0, 0, data)
59 h2 = siphash_2_4(1, 0, data)
60 h3 = siphash_2_4(0, 1, data)
61 assert h1 != h2
62 assert h1 != h3
63 assert h2 != h3
64
65 def test_different_data_produces_different_output(self):
66 """Different data must produce different hashes for the same key."""
67 h1 = siphash_2_4(K0, K1, b"aaa")
68 h2 = siphash_2_4(K0, K1, b"aab")
69 assert h1 != h2
70
71 def test_deterministic(self):
72 """Same inputs must always produce the same output."""
73 data = b"determinism check"
74 h1 = siphash_2_4(K0, K1, data)
75 h2 = siphash_2_4(K0, K1, data)
76 assert h1 == h2
77
78 def test_zero_key_zero_data(self):
79 """Zero key with empty data should still produce a valid hash."""
80 result = siphash_2_4(0, 0, b"")
81 assert isinstance(result, int)
82 assert 0 <= result < (1 << 64)
83
84 def test_exactly_8_bytes(self):
85 """Data that is exactly one 8-byte block (no padding needed beyond final)."""
86 result = siphash_2_4(K0, K1, b"\x00\x01\x02\x03\x04\x05\x06\x07")
87 assert isinstance(result, int)
88 assert 0 <= result < (1 << 64)
89
90 def test_16_bytes(self):
91 """Data that is exactly two 8-byte blocks."""
92 data = bytes(range(16))
93 result = siphash_2_4(K0, K1, data)
94 assert isinstance(result, int)
95 assert 0 <= result < (1 << 64)
96
97 def test_i2p_style_8byte_le_iv(self):
98 """I2P NTCP2 usage: hash the 8-byte LE representation of an IV."""
99 iv = 42
100 data = iv.to_bytes(8, "little")
101 result = siphash_2_4(K0, K1, data)
102 assert isinstance(result, int)
103 assert 0 <= result < (1 << 64)
104
105 def test_extended_vectors(self):
106 """Extended test vectors from the SipHash reference implementation.
107
108 key = 00 01 02 ... 0f
109 For input of length n, data = 00 01 02 ... (n-1).
110 These are the first several entries from the 64 reference vectors.
111 """
112 # Reference vectors from vectors.h (byte arrays), converted to
113 # LE uint64 via int.from_bytes(..., 'little').
114 # Source: https://github.com/veorq/SipHash/blob/master/vectors.h
115 vector_bytes = [
116 [0x31, 0x0e, 0x0e, 0xdd, 0x47, 0xdb, 0x6f, 0x72], # len 0
117 [0xfd, 0x67, 0xdc, 0x93, 0xc5, 0x39, 0xf8, 0x74], # len 1
118 [0x5a, 0x4f, 0xa9, 0xd9, 0x09, 0x80, 0x6c, 0x0d], # len 2
119 [0x2d, 0x7e, 0xfb, 0xd7, 0x96, 0x66, 0x67, 0x85], # len 3
120 [0xb7, 0x87, 0x71, 0x27, 0xe0, 0x94, 0x27, 0xcf], # len 4
121 [0x8d, 0xa6, 0x99, 0xcd, 0x64, 0x55, 0x76, 0x18], # len 5
122 [0xce, 0xe3, 0xfe, 0x58, 0x6e, 0x46, 0xc9, 0xcb], # len 6
123 [0x37, 0xd1, 0x01, 0x8b, 0xf5, 0x00, 0x02, 0xab], # len 7
124 [0x62, 0x24, 0x93, 0x9a, 0x79, 0xf5, 0xf5, 0x93], # len 8
125 [0xb0, 0xe4, 0xa9, 0x0b, 0xdf, 0x82, 0x00, 0x9e], # len 9
126 [0xf3, 0xb9, 0xdd, 0x94, 0xc5, 0xbb, 0x5d, 0x7a], # len 10
127 [0xa7, 0xad, 0x6b, 0x22, 0x46, 0x2f, 0xb3, 0xf4], # len 11
128 [0xfb, 0xe5, 0x0e, 0x86, 0xbc, 0x8f, 0x1e, 0x75], # len 12
129 [0x90, 0x3d, 0x84, 0xc0, 0x27, 0x56, 0xea, 0x14], # len 13
130 [0xee, 0xf2, 0x7a, 0x8e, 0x90, 0xca, 0x23, 0xf7], # len 14
131 [0xe5, 0x45, 0xbe, 0x49, 0x61, 0xca, 0x29, 0xa1], # len 15
132 ]
133 vectors = [
134 (i, int.from_bytes(bytes(b), "little"))
135 for i, b in enumerate(vector_bytes)
136 ]
137 for length, expected in vectors:
138 data = bytes(range(length))
139 result = siphash_2_4(K0, K1, data)
140 assert result == expected, (
141 f"Vector length={length}: got {result:#018x}, expected {expected:#018x}"
142 )
143
144
145class TestSipHashRatchet:
146 """SipHashRatchet class tests."""
147
148 def test_construction(self):
149 """Ratchet can be constructed with k0, k1, iv."""
150 r = SipHashRatchet(K0, K1, 0)
151 assert r is not None
152
153 def test_next_returns_int(self):
154 """next() returns an integer."""
155 r = SipHashRatchet(K0, K1, 0)
156 result = r.next()
157 assert isinstance(result, int)
158
159 def test_next_returns_64_bit(self):
160 """next() returns a 64-bit unsigned integer."""
161 r = SipHashRatchet(K0, K1, 0)
162 result = r.next()
163 assert 0 <= result < (1 << 64)
164
165 def test_next_hashes_iv_as_8byte_le(self):
166 """next() computes SipHash of the IV as 8-byte little-endian data."""
167 iv = 0
168 r = SipHashRatchet(K0, K1, iv)
169 expected = siphash_2_4(K0, K1, iv.to_bytes(8, "little"))
170 result = r.next()
171 assert result == expected
172
173 def test_ratchet_advances_iv(self):
174 """After next(), the IV is updated to the hash output."""
175 r = SipHashRatchet(K0, K1, 0)
176 h1 = r.next()
177 # The IV is now h1, so next call should hash h1
178 expected_h2 = siphash_2_4(K0, K1, h1.to_bytes(8, "little"))
179 h2 = r.next()
180 assert h2 == expected_h2
181
182 def test_ratchet_chain_three_steps(self):
183 """Verify a chain of three ratchet steps."""
184 iv = 12345
185 r = SipHashRatchet(K0, K1, iv)
186
187 h1 = siphash_2_4(K0, K1, iv.to_bytes(8, "little"))
188 assert r.next() == h1
189
190 h2 = siphash_2_4(K0, K1, h1.to_bytes(8, "little"))
191 assert r.next() == h2
192
193 h3 = siphash_2_4(K0, K1, h2.to_bytes(8, "little"))
194 assert r.next() == h3
195
196 def test_different_iv_different_sequence(self):
197 """Different initial IVs produce different sequences."""
198 r1 = SipHashRatchet(K0, K1, 0)
199 r2 = SipHashRatchet(K0, K1, 1)
200 assert r1.next() != r2.next()
201
202 def test_different_keys_different_sequence(self):
203 """Different keys produce different sequences."""
204 r1 = SipHashRatchet(0, 0, 42)
205 r2 = SipHashRatchet(1, 0, 42)
206 assert r1.next() != r2.next()
207
208 def test_obfuscate_length_returns_bytes(self):
209 """obfuscate_length returns 2 bytes."""
210 r = SipHashRatchet(K0, K1, 0)
211 result = r.obfuscate_length(256)
212 assert isinstance(result, bytes)
213 assert len(result) == 2
214
215 def test_deobfuscate_length_returns_int(self):
216 """deobfuscate_length returns an integer."""
217 r1 = SipHashRatchet(K0, K1, 0)
218 obf = r1.obfuscate_length(256)
219 r2 = SipHashRatchet(K0, K1, 0)
220 result = r2.deobfuscate_length(obf)
221 assert isinstance(result, int)
222
223 def test_obfuscate_deobfuscate_roundtrip(self):
224 """Obfuscating then deobfuscating returns the original length."""
225 for length in [0, 1, 255, 256, 1000, 65535]:
226 r1 = SipHashRatchet(K0, K1, 0)
227 obf = r1.obfuscate_length(length)
228 r2 = SipHashRatchet(K0, K1, 0)
229 recovered = r2.deobfuscate_length(obf)
230 assert recovered == length, (
231 f"Roundtrip failed for length={length}: got {recovered}"
232 )
233
234 def test_obfuscated_differs_from_plaintext(self):
235 """Obfuscated bytes should differ from the plain big-endian length
236 (unless the XOR mask happens to be zero, which is astronomically unlikely)."""
237 r = SipHashRatchet(K0, K1, 42)
238 length = 1000
239 obf = r.obfuscate_length(length)
240 plain = length.to_bytes(2, "big")
241 # With overwhelmingly high probability these differ
242 assert obf != plain
243
244 def test_obfuscate_sequential_lengths(self):
245 """Consecutive obfuscated lengths from the same ratchet should differ
246 because each call advances the ratchet."""
247 r = SipHashRatchet(K0, K1, 0)
248 results = [r.obfuscate_length(100) for _ in range(5)]
249 # All should be different because the ratchet advances each time
250 assert len(set(results)) == 5
251
252 def test_deobfuscate_requires_2_bytes(self):
253 """deobfuscate_length expects exactly 2 bytes of input."""
254 r = SipHashRatchet(K0, K1, 0)
255 with pytest.raises((ValueError, IndexError, Exception)):
256 r.deobfuscate_length(b"\x00")