"""Tests for SipHash-2-4 implementation. Test vectors from the SipHash paper by Jean-Philippe Aumasson and Daniel J. Bernstein. Key = 00 01 02 ... 0f => k0 = 0x0706050403020100, k1 = 0x0f0e0d0c0b0a0908 """ import pytest from i2p_crypto.siphash import siphash_2_4, SipHashRatchet # Standard key from the SipHash paper K0 = 0x0706050403020100 K1 = 0x0f0e0d0c0b0a0908 # Official test vectors from the SipHash reference implementation. # Source: https://github.com/veorq/SipHash/blob/master/vectors.h # The reference outputs are given as byte arrays; the uint64_t value # is the LE interpretation of those bytes. # Key = 00 01 02 ... 0f, input of length n = 00 01 ... (n-1). _VEC_BYTES = [ [0x31, 0x0e, 0x0e, 0xdd, 0x47, 0xdb, 0x6f, 0x72], [0xfd, 0x67, 0xdc, 0x93, 0xc5, 0x39, 0xf8, 0x74], [0x5a, 0x4f, 0xa9, 0xd9, 0x09, 0x80, 0x6c, 0x0d], ] PAPER_VECTORS = [ (bytes(range(i)), int.from_bytes(bytes(v), "little")) for i, v in enumerate(_VEC_BYTES) ] class TestSipHash24: """Core SipHash-2-4 function tests.""" @pytest.mark.parametrize("data,expected", PAPER_VECTORS) def test_paper_vectors(self, data, expected): """Verify against the official SipHash paper test vectors.""" result = siphash_2_4(K0, K1, data) assert result == expected, ( f"siphash_2_4({K0:#x}, {K1:#x}, {data!r}) = {result:#018x}, " f"expected {expected:#018x}" ) def test_return_type_is_int(self): """Return value must be a Python int.""" result = siphash_2_4(K0, K1, b"hello") assert isinstance(result, int) def test_output_fits_64_bits(self): """Output must be a 64-bit unsigned integer.""" result = siphash_2_4(K0, K1, b"test data 12345678") assert 0 <= result < (1 << 64) def test_different_keys_produce_different_output(self): """Different keys must produce different hashes for the same data.""" data = b"same input" h1 = siphash_2_4(0, 0, data) h2 = siphash_2_4(1, 0, data) h3 = siphash_2_4(0, 1, data) assert h1 != h2 assert h1 != h3 assert h2 != h3 def test_different_data_produces_different_output(self): """Different data must produce different hashes for the same key.""" h1 = siphash_2_4(K0, K1, b"aaa") h2 = siphash_2_4(K0, K1, b"aab") assert h1 != h2 def test_deterministic(self): """Same inputs must always produce the same output.""" data = b"determinism check" h1 = siphash_2_4(K0, K1, data) h2 = siphash_2_4(K0, K1, data) assert h1 == h2 def test_zero_key_zero_data(self): """Zero key with empty data should still produce a valid hash.""" result = siphash_2_4(0, 0, b"") assert isinstance(result, int) assert 0 <= result < (1 << 64) def test_exactly_8_bytes(self): """Data that is exactly one 8-byte block (no padding needed beyond final).""" result = siphash_2_4(K0, K1, b"\x00\x01\x02\x03\x04\x05\x06\x07") assert isinstance(result, int) assert 0 <= result < (1 << 64) def test_16_bytes(self): """Data that is exactly two 8-byte blocks.""" data = bytes(range(16)) result = siphash_2_4(K0, K1, data) assert isinstance(result, int) assert 0 <= result < (1 << 64) def test_i2p_style_8byte_le_iv(self): """I2P NTCP2 usage: hash the 8-byte LE representation of an IV.""" iv = 42 data = iv.to_bytes(8, "little") result = siphash_2_4(K0, K1, data) assert isinstance(result, int) assert 0 <= result < (1 << 64) def test_extended_vectors(self): """Extended test vectors from the SipHash reference implementation. key = 00 01 02 ... 0f For input of length n, data = 00 01 02 ... (n-1). These are the first several entries from the 64 reference vectors. """ # Reference vectors from vectors.h (byte arrays), converted to # LE uint64 via int.from_bytes(..., 'little'). # Source: https://github.com/veorq/SipHash/blob/master/vectors.h vector_bytes = [ [0x31, 0x0e, 0x0e, 0xdd, 0x47, 0xdb, 0x6f, 0x72], # len 0 [0xfd, 0x67, 0xdc, 0x93, 0xc5, 0x39, 0xf8, 0x74], # len 1 [0x5a, 0x4f, 0xa9, 0xd9, 0x09, 0x80, 0x6c, 0x0d], # len 2 [0x2d, 0x7e, 0xfb, 0xd7, 0x96, 0x66, 0x67, 0x85], # len 3 [0xb7, 0x87, 0x71, 0x27, 0xe0, 0x94, 0x27, 0xcf], # len 4 [0x8d, 0xa6, 0x99, 0xcd, 0x64, 0x55, 0x76, 0x18], # len 5 [0xce, 0xe3, 0xfe, 0x58, 0x6e, 0x46, 0xc9, 0xcb], # len 6 [0x37, 0xd1, 0x01, 0x8b, 0xf5, 0x00, 0x02, 0xab], # len 7 [0x62, 0x24, 0x93, 0x9a, 0x79, 0xf5, 0xf5, 0x93], # len 8 [0xb0, 0xe4, 0xa9, 0x0b, 0xdf, 0x82, 0x00, 0x9e], # len 9 [0xf3, 0xb9, 0xdd, 0x94, 0xc5, 0xbb, 0x5d, 0x7a], # len 10 [0xa7, 0xad, 0x6b, 0x22, 0x46, 0x2f, 0xb3, 0xf4], # len 11 [0xfb, 0xe5, 0x0e, 0x86, 0xbc, 0x8f, 0x1e, 0x75], # len 12 [0x90, 0x3d, 0x84, 0xc0, 0x27, 0x56, 0xea, 0x14], # len 13 [0xee, 0xf2, 0x7a, 0x8e, 0x90, 0xca, 0x23, 0xf7], # len 14 [0xe5, 0x45, 0xbe, 0x49, 0x61, 0xca, 0x29, 0xa1], # len 15 ] vectors = [ (i, int.from_bytes(bytes(b), "little")) for i, b in enumerate(vector_bytes) ] for length, expected in vectors: data = bytes(range(length)) result = siphash_2_4(K0, K1, data) assert result == expected, ( f"Vector length={length}: got {result:#018x}, expected {expected:#018x}" ) class TestSipHashRatchet: """SipHashRatchet class tests.""" def test_construction(self): """Ratchet can be constructed with k0, k1, iv.""" r = SipHashRatchet(K0, K1, 0) assert r is not None def test_next_returns_int(self): """next() returns an integer.""" r = SipHashRatchet(K0, K1, 0) result = r.next() assert isinstance(result, int) def test_next_returns_64_bit(self): """next() returns a 64-bit unsigned integer.""" r = SipHashRatchet(K0, K1, 0) result = r.next() assert 0 <= result < (1 << 64) def test_next_hashes_iv_as_8byte_le(self): """next() computes SipHash of the IV as 8-byte little-endian data.""" iv = 0 r = SipHashRatchet(K0, K1, iv) expected = siphash_2_4(K0, K1, iv.to_bytes(8, "little")) result = r.next() assert result == expected def test_ratchet_advances_iv(self): """After next(), the IV is updated to the hash output.""" r = SipHashRatchet(K0, K1, 0) h1 = r.next() # The IV is now h1, so next call should hash h1 expected_h2 = siphash_2_4(K0, K1, h1.to_bytes(8, "little")) h2 = r.next() assert h2 == expected_h2 def test_ratchet_chain_three_steps(self): """Verify a chain of three ratchet steps.""" iv = 12345 r = SipHashRatchet(K0, K1, iv) h1 = siphash_2_4(K0, K1, iv.to_bytes(8, "little")) assert r.next() == h1 h2 = siphash_2_4(K0, K1, h1.to_bytes(8, "little")) assert r.next() == h2 h3 = siphash_2_4(K0, K1, h2.to_bytes(8, "little")) assert r.next() == h3 def test_different_iv_different_sequence(self): """Different initial IVs produce different sequences.""" r1 = SipHashRatchet(K0, K1, 0) r2 = SipHashRatchet(K0, K1, 1) assert r1.next() != r2.next() def test_different_keys_different_sequence(self): """Different keys produce different sequences.""" r1 = SipHashRatchet(0, 0, 42) r2 = SipHashRatchet(1, 0, 42) assert r1.next() != r2.next() def test_obfuscate_length_returns_bytes(self): """obfuscate_length returns 2 bytes.""" r = SipHashRatchet(K0, K1, 0) result = r.obfuscate_length(256) assert isinstance(result, bytes) assert len(result) == 2 def test_deobfuscate_length_returns_int(self): """deobfuscate_length returns an integer.""" r1 = SipHashRatchet(K0, K1, 0) obf = r1.obfuscate_length(256) r2 = SipHashRatchet(K0, K1, 0) result = r2.deobfuscate_length(obf) assert isinstance(result, int) def test_obfuscate_deobfuscate_roundtrip(self): """Obfuscating then deobfuscating returns the original length.""" for length in [0, 1, 255, 256, 1000, 65535]: r1 = SipHashRatchet(K0, K1, 0) obf = r1.obfuscate_length(length) r2 = SipHashRatchet(K0, K1, 0) recovered = r2.deobfuscate_length(obf) assert recovered == length, ( f"Roundtrip failed for length={length}: got {recovered}" ) def test_obfuscated_differs_from_plaintext(self): """Obfuscated bytes should differ from the plain big-endian length (unless the XOR mask happens to be zero, which is astronomically unlikely).""" r = SipHashRatchet(K0, K1, 42) length = 1000 obf = r.obfuscate_length(length) plain = length.to_bytes(2, "big") # With overwhelmingly high probability these differ assert obf != plain def test_obfuscate_sequential_lengths(self): """Consecutive obfuscated lengths from the same ratchet should differ because each call advances the ratchet.""" r = SipHashRatchet(K0, K1, 0) results = [r.obfuscate_length(100) for _ in range(5)] # All should be different because the ratchet advances each time assert len(set(results)) == 5 def test_deobfuscate_requires_2_bytes(self): """deobfuscate_length expects exactly 2 bytes of input.""" r = SipHashRatchet(K0, K1, 0) with pytest.raises((ValueError, IndexError, Exception)): r.deobfuscate_length(b"\x00")