A Python port of the Invisible Internet Project (I2P)
1"""I2NP message types — header, base class, and concrete messages."""
2
3import hashlib
4import struct
5from abc import ABC, abstractmethod
6
7
8class I2NPHeader:
9 """I2NP message header: type(1) + msg_id(4) + expiration(8) + size(2) + checksum(1) = 16 bytes."""
10
11 SIZE = 16
12
13 def __init__(self, msg_type: int, msg_id: int, expiration: int, size: int, checksum: int):
14 self.msg_type = msg_type
15 self.msg_id = msg_id
16 self.expiration = expiration
17 self.size = size
18 self.checksum = checksum
19
20 def to_bytes(self) -> bytes:
21 return struct.pack("!BIQHB", self.msg_type, self.msg_id,
22 self.expiration, self.size, self.checksum)
23
24 @classmethod
25 def from_bytes(cls, data: bytes) -> "I2NPHeader":
26 if len(data) < cls.SIZE:
27 raise ValueError(f"I2NPHeader requires {cls.SIZE} bytes, got {len(data)}")
28 msg_type, msg_id, expiration, size, checksum = struct.unpack("!BIQHB", data[:cls.SIZE])
29 return cls(msg_type, msg_id, expiration, size, checksum)
30
31 @classmethod
32 def from_stream(cls, stream) -> "I2NPHeader":
33 data = stream.read(cls.SIZE)
34 if len(data) < cls.SIZE:
35 raise ValueError(f"I2NPHeader requires {cls.SIZE} bytes")
36 return cls.from_bytes(data)
37
38
39class I2NPMessage(ABC):
40 """Base class for I2NP messages with type registry."""
41
42 TYPE: int = -1
43 _registry: dict[int, type["I2NPMessage"]] = {}
44 _header_msg_id: int = 0
45 _header_expiration: int = 0
46
47 @classmethod
48 def __init_subclass__(cls, **kwargs):
49 super().__init_subclass__(**kwargs)
50 if hasattr(cls, 'TYPE') and cls.TYPE >= 0:
51 I2NPMessage._registry[cls.TYPE] = cls
52
53 @abstractmethod
54 def body_bytes(self) -> bytes:
55 """Serialize the message body (without header)."""
56
57 @staticmethod
58 def calculate_checksum(body: bytes) -> int:
59 return hashlib.sha256(body).digest()[0]
60
61 def to_bytes(self) -> bytes:
62 body = self.body_bytes()
63 checksum = self.calculate_checksum(body)
64 header = I2NPHeader(
65 msg_type=self.TYPE,
66 msg_id=getattr(self, '_header_msg_id', 0),
67 expiration=getattr(self, '_header_expiration', 0),
68 size=len(body),
69 checksum=checksum,
70 )
71 return header.to_bytes() + body
72
73 @classmethod
74 def from_bytes(cls, data: bytes) -> "I2NPMessage":
75 if len(data) < I2NPHeader.SIZE:
76 raise ValueError(f"Need at least {I2NPHeader.SIZE} bytes for I2NP message")
77 header = I2NPHeader.from_bytes(data)
78 body_start = I2NPHeader.SIZE
79 body_end = body_start + header.size
80 if len(data) < body_end:
81 raise ValueError(f"Message body truncated: need {header.size} bytes, got {len(data) - body_start}")
82 body = data[body_start:body_end]
83 expected_checksum = I2NPMessage.calculate_checksum(body)
84 if header.checksum != expected_checksum:
85 raise ValueError(f"Checksum mismatch: expected {expected_checksum}, got {header.checksum}")
86 msg_cls = cls._registry.get(header.msg_type)
87 if msg_cls is None:
88 raise ValueError(f"Unknown I2NP message type: {header.msg_type}")
89 msg = msg_cls._from_body(body)
90 msg._header_msg_id = header.msg_id
91 msg._header_expiration = header.expiration
92 return msg
93
94 @classmethod
95 @abstractmethod
96 def _from_body(cls, body: bytes) -> "I2NPMessage":
97 """Deserialize from body bytes."""
98
99
100class DeliveryStatusMessage(I2NPMessage):
101 """Type 10: msg_id(4) + arrival_time(8)."""
102
103 TYPE = 10
104
105 def __init__(self, msg_id: int, arrival_time: int):
106 self.msg_id = msg_id
107 self.arrival_time = arrival_time
108 self._header_msg_id = msg_id
109 self._header_expiration = 0
110
111 def body_bytes(self) -> bytes:
112 return struct.pack("!IQ", self.msg_id, self.arrival_time)
113
114 @classmethod
115 def _from_body(cls, body: bytes) -> "DeliveryStatusMessage":
116 msg_id, arrival_time = struct.unpack("!IQ", body[:12])
117 return cls(msg_id, arrival_time)
118
119
120class DataMessage(I2NPMessage):
121 """Type 20: length(4) + payload."""
122
123 TYPE = 20
124
125 def __init__(self, payload: bytes):
126 self.payload = payload
127 self._header_msg_id = 0
128 self._header_expiration = 0
129
130 def body_bytes(self) -> bytes:
131 return struct.pack("!I", len(self.payload)) + self.payload
132
133 @classmethod
134 def _from_body(cls, body: bytes) -> "DataMessage":
135 length = struct.unpack("!I", body[:4])[0]
136 return cls(body[4:4 + length])
137
138
139class DatabaseStoreMessage(I2NPMessage):
140 """Type 1: key(32) + type(1) + reply_token(4) + data."""
141
142 TYPE = 1
143
144 def __init__(self, key: bytes, ds_type: int, reply_token: int, data: bytes):
145 self.key = key
146 self.ds_type = ds_type
147 self.reply_token = reply_token
148 self.data = data
149 self._header_msg_id = 0
150 self._header_expiration = 0
151
152 def body_bytes(self) -> bytes:
153 return self.key + struct.pack("!BI", self.ds_type, self.reply_token) + self.data
154
155 @classmethod
156 def _from_body(cls, body: bytes) -> "DatabaseStoreMessage":
157 key = body[:32]
158 ds_type = body[32]
159 reply_token = struct.unpack("!I", body[33:37])[0]
160 data = body[37:]
161 return cls(key, ds_type, reply_token, data)
162
163
164class DatabaseLookupMessage(I2NPMessage):
165 """Type 2: key(32) + from_hash(32) + flags(1) + [reply_tunnel_id(4)] + size(1) + exclude_list."""
166
167 TYPE = 2
168
169 def __init__(self, key: bytes, from_hash: bytes, flags: int,
170 reply_tunnel_id: int = 0, exclude_list: list[bytes] | None = None):
171 self.key = key
172 self.from_hash = from_hash
173 self.flags = flags
174 self.reply_tunnel_id = reply_tunnel_id
175 self.exclude_list = exclude_list or []
176 self._header_msg_id = 0
177 self._header_expiration = 0
178
179 def body_bytes(self) -> bytes:
180 parts = [self.key, self.from_hash, struct.pack("!B", self.flags)]
181 if self.flags & 0x01:
182 parts.append(struct.pack("!I", self.reply_tunnel_id))
183 parts.append(struct.pack("!B", len(self.exclude_list)))
184 for ex in self.exclude_list:
185 parts.append(ex)
186 return b"".join(parts)
187
188 @classmethod
189 def _from_body(cls, body: bytes) -> "DatabaseLookupMessage":
190 key = body[:32]
191 from_hash = body[32:64]
192 flags = body[64]
193 offset = 65
194 reply_tunnel_id = 0
195 if flags & 0x01:
196 reply_tunnel_id = struct.unpack("!I", body[offset:offset + 4])[0]
197 offset += 4
198 num_excludes = body[offset]
199 offset += 1
200 exclude_list = []
201 for _ in range(num_excludes):
202 exclude_list.append(body[offset:offset + 32])
203 offset += 32
204 return cls(key, from_hash, flags, reply_tunnel_id, exclude_list)