A Python port of the Invisible Internet Project (I2P)
1"""I2NP message wire format serialization/deserialization.
2
3Provides functional encode/decode for I2NP message headers (short and standard)
4and individual message type payloads (DatabaseStore, DatabaseLookup,
5DatabaseSearchReply, DeliveryStatus).
6
7All multi-byte fields are big-endian (network byte order) per the I2P spec.
8"""
9
10import hashlib
11import hmac as _hmac
12import struct
13
14# I2NP message type constants (from I2P specification)
15MSG_TYPE_DATABASE_STORE = 1
16MSG_TYPE_DATABASE_LOOKUP = 2
17MSG_TYPE_DATABASE_SEARCH_REPLY = 3
18MSG_TYPE_DELIVERY_STATUS = 10
19MSG_TYPE_GARLIC = 11
20MSG_TYPE_TUNNEL_DATA = 18
21MSG_TYPE_TUNNEL_GATEWAY = 19
22MSG_TYPE_DATA = 20
23MSG_TYPE_TUNNEL_BUILD = 21
24MSG_TYPE_TUNNEL_BUILD_REPLY = 22
25MSG_TYPE_VARIABLE_TUNNEL_BUILD = 23
26MSG_TYPE_VARIABLE_TUNNEL_BUILD_REPLY = 24
27
28
29# ---------------------------------------------------------------------------
30# Short header (used inside NTCP2 I2NP blocks)
31# Format: type(1) + msg_id(4 BE) + expiration_seconds(4 BE) + size(2 BE) + payload
32# Total header: 11 bytes
33# ---------------------------------------------------------------------------
34
35def encode_i2np_short(msg_type: int, msg_id: int, expiration: int, payload: bytes) -> bytes:
36 """Encode I2NP message with short header (for NTCP2 blocks).
37
38 Format: type(1) + msg_id(4 BE) + expiration_seconds(4 BE signed) + size(2 BE) + payload
39 """
40 header = struct.pack("!BIiH", msg_type, msg_id, expiration, len(payload))
41 return header + payload
42
43
44def decode_i2np_short(data: bytes) -> tuple[int, int, int, bytes]:
45 """Decode I2NP message with short header.
46
47 Returns: (msg_type, msg_id, expiration_seconds, payload)
48 """
49 msg_type, msg_id, expiration, size = struct.unpack_from("!BIiH", data)
50 payload = data[11:11 + size]
51 return msg_type, msg_id, expiration, payload
52
53
54# ---------------------------------------------------------------------------
55# Standard header (used in tunnel messages)
56# Format: type(1) + msg_id(4 BE) + expiration_ms(8 BE) + size(2 BE) + checksum(1) + payload
57# Checksum = first byte of SHA-256(payload)
58# Total header: 16 bytes
59# ---------------------------------------------------------------------------
60
61def encode_i2np_standard(msg_type: int, msg_id: int, expiration_ms: int, payload: bytes) -> bytes:
62 """Encode I2NP message with standard header (for tunnel messages).
63
64 Format: type(1) + msg_id(4 BE) + expiration_ms(8 BE) + size(2 BE) + checksum(1) + payload
65 Checksum = first byte of SHA-256(payload).
66 """
67 checksum = hashlib.sha256(payload).digest()[0]
68 header = struct.pack("!BIQHB", msg_type, msg_id, expiration_ms, len(payload), checksum)
69 return header + payload
70
71
72def decode_i2np_standard(data: bytes) -> tuple[int, int, int, bytes]:
73 """Decode I2NP message with standard header.
74
75 Returns: (msg_type, msg_id, expiration_ms, payload)
76 Raises ValueError if checksum doesn't match.
77 """
78 msg_type, msg_id, expiration_ms, size, checksum = struct.unpack_from("!BIQHB", data)
79 payload = data[16:16 + size]
80 expected_checksum = hashlib.sha256(payload).digest()[0]
81 if not _hmac.compare_digest(bytes([checksum]), bytes([expected_checksum])):
82 raise ValueError(f"Checksum mismatch: got {checksum}, expected {expected_checksum}")
83 return msg_type, msg_id, expiration_ms, payload
84
85
86# ---------------------------------------------------------------------------
87# DatabaseStore (type 1)
88# Payload: key(32) + type_byte(1, 0=RI 1=LS) + reply_token(4) +
89# [if token!=0: reply_gateway(32) + reply_tunnel(4)] + data
90# ---------------------------------------------------------------------------
91
92def encode_database_store(key: bytes, store_type: int, data: bytes,
93 reply_token: int = 0, reply_gateway: bytes = b"",
94 reply_tunnel_id: int = 0) -> bytes:
95 """Encode DatabaseStore payload.
96
97 key: 32-byte hash of the item being stored
98 store_type: 0 = RouterInfo, 1 = LeaseSet
99 data: the RouterInfo or LeaseSet bytes
100 reply_token: if non-zero, request delivery acknowledgment
101 reply_gateway: 32-byte hash (required if reply_token != 0)
102 reply_tunnel_id: 4-byte tunnel ID (required if reply_token != 0)
103 """
104 parts = [key, struct.pack("!BI", store_type, reply_token)]
105 if reply_token != 0:
106 parts.append(reply_gateway)
107 parts.append(struct.pack("!I", reply_tunnel_id))
108 parts.append(data)
109 return b"".join(parts)
110
111
112def decode_database_store(payload: bytes) -> dict:
113 """Decode DatabaseStore payload.
114
115 Returns dict with: key, store_type, reply_token,
116 reply_gateway (if token!=0), reply_tunnel_id (if token!=0), data.
117 """
118 key = payload[:32]
119 store_type = payload[32]
120 reply_token = struct.unpack("!I", payload[33:37])[0]
121 offset = 37
122 result = {
123 "key": key,
124 "store_type": store_type,
125 "reply_token": reply_token,
126 }
127 if reply_token != 0:
128 result["reply_gateway"] = payload[offset:offset + 32]
129 offset += 32
130 result["reply_tunnel_id"] = struct.unpack("!I", payload[offset:offset + 4])[0]
131 offset += 4
132 result["data"] = payload[offset:]
133 return result
134
135
136# ---------------------------------------------------------------------------
137# DatabaseLookup (type 2)
138# Payload: key(32) + from_hash(32) + flags(1) + [reply_tunnel_id(4) if flag bit 0]
139# + size(1) + exclude_list(size * 32)
140# ---------------------------------------------------------------------------
141
142def encode_database_lookup(key: bytes, from_hash: bytes, flags: int = 0,
143 reply_tunnel_id: int = 0,
144 exclude_list: list[bytes] | None = None) -> bytes:
145 """Encode DatabaseLookup payload."""
146 if exclude_list is None:
147 exclude_list = []
148 parts = [key, from_hash, struct.pack("!B", flags)]
149 if flags & 0x01:
150 parts.append(struct.pack("!I", reply_tunnel_id))
151 parts.append(struct.pack("!B", len(exclude_list)))
152 for ex in exclude_list:
153 parts.append(ex)
154 return b"".join(parts)
155
156
157def decode_database_lookup(payload: bytes) -> dict:
158 """Decode DatabaseLookup payload.
159
160 Returns dict with: key, from_hash, flags, reply_tunnel_id, exclude_list.
161 """
162 key = payload[:32]
163 from_hash = payload[32:64]
164 flags = payload[64]
165 offset = 65
166 reply_tunnel_id = 0
167 if flags & 0x01:
168 reply_tunnel_id = struct.unpack("!I", payload[offset:offset + 4])[0]
169 offset += 4
170 num_excludes = payload[offset]
171 offset += 1
172 exclude_list = []
173 for _ in range(num_excludes):
174 exclude_list.append(payload[offset:offset + 32])
175 offset += 32
176 return {
177 "key": key,
178 "from_hash": from_hash,
179 "flags": flags,
180 "reply_tunnel_id": reply_tunnel_id,
181 "exclude_list": exclude_list,
182 }
183
184
185# ---------------------------------------------------------------------------
186# DatabaseSearchReply (type 3)
187# Payload: key(32) + num_peers(1) + [peer_hashes(32 each)] + from_hash(32)
188# ---------------------------------------------------------------------------
189
190def encode_database_search_reply(key: bytes, peer_hashes: list[bytes],
191 from_hash: bytes) -> bytes:
192 """Encode DatabaseSearchReply payload."""
193 parts = [key, struct.pack("!B", len(peer_hashes))]
194 for ph in peer_hashes:
195 parts.append(ph)
196 parts.append(from_hash)
197 return b"".join(parts)
198
199
200def decode_database_search_reply(payload: bytes) -> dict:
201 """Decode DatabaseSearchReply payload.
202
203 Returns dict with: key, peer_hashes, from_hash.
204 """
205 key = payload[:32]
206 num_peers = payload[32]
207 offset = 33
208 peer_hashes = []
209 for _ in range(num_peers):
210 peer_hashes.append(payload[offset:offset + 32])
211 offset += 32
212 from_hash = payload[offset:offset + 32]
213 return {
214 "key": key,
215 "peer_hashes": peer_hashes,
216 "from_hash": from_hash,
217 }
218
219
220# ---------------------------------------------------------------------------
221# DeliveryStatus (type 10)
222# Payload: msg_id(4 BE) + timestamp(8 BE)
223# ---------------------------------------------------------------------------
224
225def encode_delivery_status(msg_id: int, timestamp: int) -> bytes:
226 """Encode DeliveryStatus payload: msg_id(4 BE) + timestamp(8 BE)."""
227 return struct.pack("!IQ", msg_id, timestamp)
228
229
230def decode_delivery_status(payload: bytes) -> dict:
231 """Decode DeliveryStatus payload.
232
233 Returns dict with: msg_id, timestamp.
234 """
235 msg_id, timestamp = struct.unpack("!IQ", payload[:12])
236 return {"msg_id": msg_id, "timestamp": timestamp}