A Python port of the Invisible Internet Project (I2P)
1"""I2CP message types — wire format and type registry."""
2
3import struct
4from abc import ABC, abstractmethod
5
6
7class I2CPMessage(ABC):
8 """Base class for I2CP messages.
9
10 Wire format: 4-byte big-endian length + 1-byte type + payload.
11 Length includes the type byte but not itself.
12 """
13
14 TYPE: int = -1
15 _registry: dict[int, type["I2CPMessage"]] = {}
16
17 @classmethod
18 def __init_subclass__(cls, **kwargs):
19 super().__init_subclass__(**kwargs)
20 if hasattr(cls, 'TYPE') and cls.TYPE >= 0:
21 I2CPMessage._registry[cls.TYPE] = cls
22
23 @abstractmethod
24 def payload_bytes(self) -> bytes:
25 """Serialize message payload (after type byte)."""
26
27 @classmethod
28 @abstractmethod
29 def _from_payload(cls, payload: bytes) -> "I2CPMessage":
30 """Deserialize from payload bytes."""
31
32 def to_wire(self) -> bytes:
33 payload = self.payload_bytes()
34 length = 1 + len(payload) # type byte + payload
35 return struct.pack("!IB", length, self.TYPE) + payload
36
37 @classmethod
38 def from_wire(cls, data: bytes) -> "I2CPMessage":
39 if len(data) < 5:
40 raise ValueError(f"I2CP message needs at least 5 bytes, got {len(data)}")
41 length = struct.unpack("!I", data[:4])[0]
42 msg_type = data[4]
43 payload = data[5:4 + length]
44 msg_cls = cls._registry.get(msg_type)
45 if msg_cls is None:
46 raise ValueError(f"Unknown I2CP message type: {msg_type}")
47 return msg_cls._from_payload(payload)
48
49
50class GetDateMessage(I2CPMessage):
51 """Type 32: C→R request current date. Payload: version string + optional options."""
52
53 TYPE = 32
54
55 def __init__(self, version: str = "0.9.62", options: dict[str, str] | None = None):
56 self.version = version
57 self.options = options or {}
58
59 def payload_bytes(self) -> bytes:
60 vb = self.version.encode("utf-8")
61 result = struct.pack("!B", len(vb)) + vb
62 if self.options:
63 result += _encode_properties(self.options)
64 return result
65
66 @classmethod
67 def _from_payload(cls, payload: bytes) -> "GetDateMessage":
68 vlen = payload[0]
69 version = payload[1:1 + vlen].decode("utf-8")
70 offset = 1 + vlen
71 options: dict[str, str] = {}
72 if offset < len(payload):
73 options, _ = _decode_properties(payload[offset:])
74 return cls(version, options)
75
76
77class SetDateMessage(I2CPMessage):
78 """Type 33: R→C send current date. Payload: date(8) + version."""
79
80 TYPE = 33
81
82 def __init__(self, date_ms: int, version: str = "0.9.62"):
83 self.date_ms = date_ms
84 self.version = version
85
86 def payload_bytes(self) -> bytes:
87 vb = self.version.encode("utf-8")
88 return struct.pack("!Q", self.date_ms) + struct.pack("!B", len(vb)) + vb
89
90 @classmethod
91 def _from_payload(cls, payload: bytes) -> "SetDateMessage":
92 date_ms = struct.unpack("!Q", payload[:8])[0]
93 vlen = payload[8]
94 version = payload[9:9 + vlen].decode("utf-8")
95 return cls(date_ms, version)
96
97
98class SessionStatusMessage(I2CPMessage):
99 """Type 20: R→C session creation result. session_id(2) + status(1)."""
100
101 TYPE = 20
102
103 STATUS_DESTROYED = 0
104 STATUS_CREATED = 1
105 STATUS_UPDATED = 2
106 STATUS_INVALID = 3
107 STATUS_REFUSED = 4
108
109 def __init__(self, session_id: int, status: int):
110 self.session_id = session_id
111 self.status = status
112
113 def payload_bytes(self) -> bytes:
114 return struct.pack("!HB", self.session_id, self.status)
115
116 @classmethod
117 def _from_payload(cls, payload: bytes) -> "SessionStatusMessage":
118 session_id, status = struct.unpack("!HB", payload[:3])
119 return cls(session_id, status)
120
121
122class CreateSessionMessage(I2CPMessage):
123 """Type 1: C→R create session. destination_data + options."""
124
125 TYPE = 1
126
127 def __init__(self, destination_data: bytes, options: dict[str, str] | None = None):
128 self.destination_data = destination_data
129 self.options = options or {}
130
131 def payload_bytes(self) -> bytes:
132 opts = _encode_properties(self.options)
133 return (struct.pack("!H", len(self.destination_data)) +
134 self.destination_data + opts)
135
136 @classmethod
137 def _from_payload(cls, payload: bytes) -> "CreateSessionMessage":
138 dest_len = struct.unpack("!H", payload[:2])[0]
139 dest_data = payload[2:2 + dest_len]
140 opts, _ = _decode_properties(payload[2 + dest_len:])
141 return cls(dest_data, opts)
142
143
144class DestroySessionMessage(I2CPMessage):
145 """Type 3: C→R destroy session. session_id(2)."""
146
147 TYPE = 3
148
149 def __init__(self, session_id: int):
150 self.session_id = session_id
151
152 def payload_bytes(self) -> bytes:
153 return struct.pack("!H", self.session_id)
154
155 @classmethod
156 def _from_payload(cls, payload: bytes) -> "DestroySessionMessage":
157 session_id = struct.unpack("!H", payload[:2])[0]
158 return cls(session_id)
159
160
161class SendMessageMessage(I2CPMessage):
162 """Type 5: C→R send message.
163
164 Wire format: session_id(2) + Destination(self-delimiting) +
165 Payload(4-byte size + data) + nonce(4).
166 """
167
168 TYPE = 5
169
170 def __init__(self, session_id: int, destination_data: bytes,
171 payload: bytes, nonce: int = 0):
172 self.session_id = session_id
173 self.destination_data = destination_data
174 self.payload = payload
175 self.nonce = nonce
176
177 def payload_bytes(self) -> bytes:
178 return (struct.pack("!H", self.session_id) +
179 self.destination_data +
180 struct.pack("!I", len(self.payload)) + self.payload +
181 struct.pack("!I", self.nonce))
182
183 @classmethod
184 def _from_payload(cls, payload: bytes) -> "SendMessageMessage":
185 session_id = struct.unpack("!H", payload[:2])[0]
186 dest_data, dest_len = _parse_destination_from(payload, 2)
187 off = 2 + dest_len
188 pl_len = struct.unpack("!I", payload[off:off + 4])[0]
189 off += 4
190 pl = payload[off:off + pl_len]
191 off += pl_len
192 nonce = struct.unpack("!I", payload[off:off + 4])[0]
193 return cls(session_id, dest_data, pl, nonce)
194
195
196class MessageStatusMessage(I2CPMessage):
197 """Type 22: R→C delivery status. session_id(2) + msg_id(4) + nonce(4) + status(1) + size(4)."""
198
199 TYPE = 22
200
201 STATUS_AVAILABLE = 0
202 STATUS_SEND_ACCEPTED = 1
203 STATUS_SEND_BEST_EFFORT_SUCCESS = 2
204 STATUS_SEND_BEST_EFFORT_FAILURE = 3
205 STATUS_SEND_GUARANTEED_SUCCESS = 4
206 STATUS_SEND_GUARANTEED_FAILURE = 5
207 STATUS_SEND_SUCCESS_LOCAL = 6
208 STATUS_SEND_FAILURE_LOCAL = 7
209 STATUS_SEND_FAILURE_ROUTER = 8
210 STATUS_SEND_FAILURE_NETWORK = 9
211 STATUS_SEND_FAILURE_BAD_SESSION = 10
212 STATUS_SEND_FAILURE_BAD_MESSAGE = 11
213 STATUS_SEND_FAILURE_BAD_OPTIONS = 12
214 STATUS_SEND_FAILURE_OVERFLOW = 13
215 STATUS_SEND_FAILURE_EXPIRED = 14
216 STATUS_SEND_FAILURE_LOCAL_LEASESET = 15
217 STATUS_SEND_FAILURE_NO_TUNNELS = 16
218 STATUS_SEND_FAILURE_UNSUPPORTED_ENCRYPTION = 17
219 STATUS_SEND_FAILURE_DESTINATION = 18
220 STATUS_SEND_FAILURE_BAD_LEASESET = 19
221 STATUS_SEND_FAILURE_EXPIRED_LEASESET = 20
222 STATUS_SEND_FAILURE_NO_LEASESET = 21
223
224 _SUCCESS_STATUSES = frozenset({1, 2, 4, 6})
225 _FAILURE_STATUSES = frozenset({3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21})
226
227 _STATUS_NAMES = {
228 0: "AVAILABLE",
229 1: "SEND_ACCEPTED",
230 2: "SEND_BEST_EFFORT_SUCCESS",
231 3: "SEND_BEST_EFFORT_FAILURE",
232 4: "SEND_GUARANTEED_SUCCESS",
233 5: "SEND_GUARANTEED_FAILURE",
234 6: "SEND_SUCCESS_LOCAL",
235 7: "SEND_FAILURE_LOCAL",
236 8: "SEND_FAILURE_ROUTER",
237 9: "SEND_FAILURE_NETWORK",
238 10: "SEND_FAILURE_BAD_SESSION",
239 11: "SEND_FAILURE_BAD_MESSAGE",
240 12: "SEND_FAILURE_BAD_OPTIONS",
241 13: "SEND_FAILURE_OVERFLOW",
242 14: "SEND_FAILURE_EXPIRED",
243 15: "SEND_FAILURE_LOCAL_LEASESET",
244 16: "SEND_FAILURE_NO_TUNNELS",
245 17: "SEND_FAILURE_UNSUPPORTED_ENCRYPTION",
246 18: "SEND_FAILURE_DESTINATION",
247 19: "SEND_FAILURE_BAD_LEASESET",
248 20: "SEND_FAILURE_EXPIRED_LEASESET",
249 21: "SEND_FAILURE_NO_LEASESET",
250 }
251
252 def __init__(self, session_id: int, msg_id: int, nonce: int, status: int, size: int):
253 self.session_id = session_id
254 self.msg_id = msg_id
255 self.nonce = nonce
256 self.status = status
257 self.size = size
258
259 def is_success(self) -> bool:
260 return self.status in self._SUCCESS_STATUSES
261
262 def is_failure(self) -> bool:
263 return self.status in self._FAILURE_STATUSES
264
265 def status_name(self) -> str:
266 return self._STATUS_NAMES.get(self.status, f"UNKNOWN_{self.status}")
267
268 def payload_bytes(self) -> bytes:
269 return struct.pack("!HIIBI", self.session_id, self.msg_id,
270 self.nonce, self.status, self.size)
271
272 @classmethod
273 def _from_payload(cls, payload: bytes) -> "MessageStatusMessage":
274 session_id, msg_id, nonce, status, size = struct.unpack("!HIIBI", payload[:15])
275 return cls(session_id, msg_id, nonce, status, size)
276
277
278class MessagePayloadMessage(I2CPMessage):
279 """Type 31: R→C message payload. session_id(2) + msg_id(4) + payload_len(4) + payload."""
280
281 TYPE = 31
282
283 def __init__(self, session_id: int, msg_id: int, payload: bytes):
284 self.session_id = session_id
285 self.msg_id = msg_id
286 self.payload = payload
287
288 def payload_bytes(self) -> bytes:
289 return (struct.pack("!HII", self.session_id, self.msg_id, len(self.payload)) +
290 self.payload)
291
292 @classmethod
293 def _from_payload(cls, payload: bytes) -> "MessagePayloadMessage":
294 session_id, msg_id, pl_len = struct.unpack("!HII", payload[:10])
295 pl = payload[10:10 + pl_len]
296 return cls(session_id, msg_id, pl)
297
298
299class ReceiveMessageBeginMessage(I2CPMessage):
300 """Type 6: R→C notify message available. session_id(2) + msg_id(4)."""
301
302 TYPE = 6
303
304 def __init__(self, session_id: int, msg_id: int):
305 self.session_id = session_id
306 self.msg_id = msg_id
307
308 def payload_bytes(self) -> bytes:
309 return struct.pack("!HI", self.session_id, self.msg_id)
310
311 @classmethod
312 def _from_payload(cls, payload: bytes) -> "ReceiveMessageBeginMessage":
313 session_id, msg_id = struct.unpack("!HI", payload[:6])
314 return cls(session_id, msg_id)
315
316
317class ReceiveMessageEndMessage(I2CPMessage):
318 """Type 7: C→R acknowledge message received. session_id(2) + msg_id(4)."""
319
320 TYPE = 7
321
322 def __init__(self, session_id: int, msg_id: int):
323 self.session_id = session_id
324 self.msg_id = msg_id
325
326 def payload_bytes(self) -> bytes:
327 return struct.pack("!HI", self.session_id, self.msg_id)
328
329 @classmethod
330 def _from_payload(cls, payload: bytes) -> "ReceiveMessageEndMessage":
331 session_id, msg_id = struct.unpack("!HI", payload[:6])
332 return cls(session_id, msg_id)
333
334
335class HostLookupMessage(I2CPMessage):
336 """Type 38: C→R resolve hostname.
337
338 Wire format: session_id(2) + request_id(4) + timeout(4) +
339 lookup_type(1) + [hash(32) | hostname(1-byte len + str)].
340 Java type codes: 0=LOOKUP_HASH, 1=LOOKUP_HOST.
341 """
342
343 TYPE = 38
344
345 LOOKUP_HASH = 0
346 LOOKUP_HOST = 1
347
348 def __init__(self, session_id: int, request_id: int,
349 hostname: str | None = None, dest_hash: bytes | None = None,
350 timeout: int = 10000):
351 self.session_id = session_id
352 self.request_id = request_id
353 self.hostname = hostname
354 self.dest_hash = dest_hash
355 self.timeout = timeout
356
357 def payload_bytes(self) -> bytes:
358 parts = [struct.pack("!HII", self.session_id, self.request_id, self.timeout)]
359 if self.dest_hash is not None:
360 parts.append(struct.pack("!B", self.LOOKUP_HASH)) # 0 = hash
361 parts.append(self.dest_hash)
362 elif self.hostname is not None:
363 hb = self.hostname.encode("utf-8")
364 parts.append(struct.pack("!BB", self.LOOKUP_HOST, len(hb))) # 1 = host, 1-byte len
365 parts.append(hb)
366 return b"".join(parts)
367
368 @classmethod
369 def _from_payload(cls, payload: bytes) -> "HostLookupMessage":
370 session_id, request_id, timeout = struct.unpack("!HII", payload[:10])
371 lookup_type = payload[10]
372 hostname = None
373 dest_hash = None
374 if lookup_type == cls.LOOKUP_HASH:
375 dest_hash = payload[11:11 + 32]
376 elif lookup_type == cls.LOOKUP_HOST:
377 name_len = payload[11]
378 hostname = payload[12:12 + name_len].decode("utf-8")
379 return cls(session_id, request_id, hostname=hostname,
380 dest_hash=dest_hash, timeout=timeout)
381
382
383class HostReplyMessage(I2CPMessage):
384 """Type 39: R→C hostname resolution result."""
385
386 TYPE = 39
387
388 RESULT_SUCCESS = 0
389 RESULT_FAILURE = 1
390 RESULT_SECRET_REQUIRED = 2
391 RESULT_KEY_REQUIRED = 3
392 RESULT_SECRET_AND_KEY_REQUIRED = 4
393 RESULT_DECRYPTION_FAILURE = 5
394
395 def __init__(self, session_id: int, request_id: int, result_code: int,
396 destination_data: bytes | None = None):
397 self.session_id = session_id
398 self.request_id = request_id
399 self.result_code = result_code
400 self.destination_data = destination_data
401
402 def payload_bytes(self) -> bytes:
403 parts = [struct.pack("!HIB", self.session_id, self.request_id, self.result_code)]
404 if self.result_code == 0 and self.destination_data is not None:
405 parts.append(self.destination_data) # self-delimiting, no length prefix
406 return b"".join(parts)
407
408 @classmethod
409 def _from_payload(cls, payload: bytes) -> "HostReplyMessage":
410 session_id, request_id, result_code = struct.unpack("!HIB", payload[:7])
411 dest_data = None
412 if result_code == 0 and len(payload) > 7:
413 dest_data = payload[7:] # remaining bytes are the destination
414 return cls(session_id, request_id, result_code, dest_data)
415
416
417class DisconnectMessage(I2CPMessage):
418 """Type 30: R→C disconnect. reason string."""
419
420 TYPE = 30
421
422 def __init__(self, reason: str = ""):
423 self.reason = reason
424
425 def payload_bytes(self) -> bytes:
426 rb = self.reason.encode("utf-8")
427 return struct.pack("!B", len(rb)) + rb
428
429 @classmethod
430 def _from_payload(cls, payload: bytes) -> "DisconnectMessage":
431 if not payload:
432 return cls("")
433 rlen = payload[0]
434 reason = payload[1:1 + rlen].decode("utf-8")
435 return cls(reason)
436
437
438class GetBandwidthLimitsMessage(I2CPMessage):
439 """Type 8: C→R request bandwidth limits. Empty payload."""
440
441 TYPE = 8
442
443 def payload_bytes(self) -> bytes:
444 return b""
445
446 @classmethod
447 def _from_payload(cls, payload: bytes) -> "GetBandwidthLimitsMessage":
448 return cls()
449
450
451class BandwidthLimitsMessage(I2CPMessage):
452 """Type 23: R→C bandwidth limits. 16 x int32 = 64 bytes."""
453
454 TYPE = 23
455
456 def __init__(self, limits: list[int]):
457 if len(limits) != 16:
458 raise ValueError(f"Expected 16 limit values, got {len(limits)}")
459 self.limits = list(limits)
460
461 def payload_bytes(self) -> bytes:
462 return struct.pack("!16i", *self.limits)
463
464 @classmethod
465 def _from_payload(cls, payload: bytes) -> "BandwidthLimitsMessage":
466 values = list(struct.unpack("!16i", payload[:64]))
467 return cls(values)
468
469 @property
470 def client_inbound(self) -> int:
471 return self.limits[0]
472
473 @property
474 def client_outbound(self) -> int:
475 return self.limits[1]
476
477 @property
478 def router_inbound(self) -> int:
479 return self.limits[2]
480
481 @property
482 def router_outbound(self) -> int:
483 return self.limits[4]
484
485
486class DestLookupMessage(I2CPMessage):
487 """Type 34: C→R legacy dest lookup. dest_hash(32)."""
488
489 TYPE = 34
490
491 def __init__(self, dest_hash: bytes):
492 if len(dest_hash) != 32:
493 raise ValueError(f"dest_hash must be 32 bytes, got {len(dest_hash)}")
494 self.dest_hash = dest_hash
495
496 def payload_bytes(self) -> bytes:
497 return self.dest_hash
498
499 @classmethod
500 def _from_payload(cls, payload: bytes) -> "DestLookupMessage":
501 return cls(payload[:32])
502
503
504class DestReplyMessage(I2CPMessage):
505 """Type 35: R→C dest lookup result. Variable length."""
506
507 TYPE = 35
508
509 def __init__(self, destination_data: bytes | None = None,
510 hash_data: bytes | None = None):
511 self.destination_data = destination_data
512 self.hash_data = hash_data
513
514 def payload_bytes(self) -> bytes:
515 if self.destination_data is not None:
516 return self.destination_data
517 if self.hash_data is not None:
518 return self.hash_data
519 return b""
520
521 @classmethod
522 def _from_payload(cls, payload: bytes) -> "DestReplyMessage":
523 if len(payload) == 0:
524 return cls() # failure, no data
525 if len(payload) == 32:
526 return cls(hash_data=payload) # hash echo (failure)
527 return cls(destination_data=payload) # success
528
529
530class ReportAbuseMessage(I2CPMessage):
531 """Type 29: bidirectional abuse report. session_id(2) + severity(1) + reason + msg_id(4)."""
532
533 TYPE = 29
534
535 def __init__(self, session_id: int, severity: int, reason: str, message_id: int):
536 self.session_id = session_id
537 self.severity = severity
538 self.reason = reason
539 self.message_id = message_id
540
541 def payload_bytes(self) -> bytes:
542 rb = self.reason.encode("utf-8")
543 return (struct.pack("!HB", self.session_id, self.severity) +
544 struct.pack("!B", len(rb)) + rb +
545 struct.pack("!I", self.message_id))
546
547 @classmethod
548 def _from_payload(cls, payload: bytes) -> "ReportAbuseMessage":
549 session_id, severity = struct.unpack("!HB", payload[:3])
550 rlen = payload[3]
551 reason = payload[4:4 + rlen].decode("utf-8")
552 message_id = struct.unpack("!I", payload[4 + rlen:8 + rlen])[0]
553 return cls(session_id, severity, reason, message_id)
554
555
556class RequestLeaseSetMessage(I2CPMessage):
557 """Type 21: R→C request lease set.
558
559 Wire format: session_id(2) + num_tunnels(1) +
560 [router_hash(32) + tunnel_id(4)] * N + end_date(8).
561 """
562
563 TYPE = 21
564
565 def __init__(self, session_id: int,
566 tunnels: list[tuple[bytes, int]],
567 end_date: int):
568 self.session_id = session_id
569 self.tunnels = tunnels
570 self.end_date = end_date
571
572 def payload_bytes(self) -> bytes:
573 parts = [struct.pack("!HB", self.session_id, len(self.tunnels))]
574 for router_hash, tunnel_id in self.tunnels:
575 parts.append(router_hash)
576 parts.append(struct.pack("!I", tunnel_id))
577 parts.append(struct.pack("!Q", self.end_date))
578 return b"".join(parts)
579
580 @classmethod
581 def _from_payload(cls, payload: bytes) -> "RequestLeaseSetMessage":
582 session_id, num_tunnels = struct.unpack("!HB", payload[:3])
583 offset = 3
584 tunnels = []
585 for _ in range(num_tunnels):
586 router_hash = payload[offset:offset + 32]
587 tunnel_id = struct.unpack("!I", payload[offset + 32:offset + 36])[0]
588 tunnels.append((router_hash, tunnel_id))
589 offset += 36
590 end_date = struct.unpack("!Q", payload[offset:offset + 8])[0]
591 return cls(session_id, tunnels, end_date)
592
593
594class RequestVariableLeaseSetMessage(I2CPMessage):
595 """Type 37: R→C request variable lease set.
596
597 Wire format: session_id(2) + num_leases(1) +
598 [tunnel_gw(32) + tunnel_id(4) + end_date(8)] * N.
599 """
600
601 TYPE = 37
602
603 def __init__(self, session_id: int,
604 leases: list[tuple[bytes, int, int]]):
605 self.session_id = session_id
606 self.leases = leases
607
608 def payload_bytes(self) -> bytes:
609 parts = [struct.pack("!HB", self.session_id, len(self.leases))]
610 for gw_hash, tunnel_id, end_date_ms in self.leases:
611 parts.append(gw_hash)
612 parts.append(struct.pack("!IQ", tunnel_id, end_date_ms))
613 return b"".join(parts)
614
615 @classmethod
616 def _from_payload(cls, payload: bytes) -> "RequestVariableLeaseSetMessage":
617 session_id, num_leases = struct.unpack("!HB", payload[:3])
618 offset = 3
619 leases = []
620 for _ in range(num_leases):
621 gw_hash = payload[offset:offset + 32]
622 tunnel_id, end_date_ms = struct.unpack(
623 "!IQ", payload[offset + 32:offset + 44])
624 leases.append((gw_hash, tunnel_id, end_date_ms))
625 offset += 44
626 return cls(session_id, leases)
627
628
629class CreateLeaseSetMessage(I2CPMessage):
630 """Type 4: C→R create lease set.
631
632 Wire format: session_id(2) + signing_private_key_len(2) +
633 signing_private_key + private_key_len(2) + private_key +
634 lease_set_data.
635 """
636
637 TYPE = 4
638
639 def __init__(self, session_id: int, signing_private_key: bytes,
640 private_key: bytes, lease_set_data: bytes):
641 self.session_id = session_id
642 self.signing_private_key = signing_private_key
643 self.private_key = private_key
644 self.lease_set_data = lease_set_data
645
646 def payload_bytes(self) -> bytes:
647 return (struct.pack("!HH", self.session_id,
648 len(self.signing_private_key)) +
649 self.signing_private_key +
650 struct.pack("!H", len(self.private_key)) +
651 self.private_key +
652 self.lease_set_data)
653
654 @classmethod
655 def _from_payload(cls, payload: bytes) -> "CreateLeaseSetMessage":
656 session_id, spk_len = struct.unpack("!HH", payload[:4])
657 offset = 4
658 signing_private_key = payload[offset:offset + spk_len]
659 offset += spk_len
660 pk_len = struct.unpack("!H", payload[offset:offset + 2])[0]
661 offset += 2
662 private_key = payload[offset:offset + pk_len]
663 offset += pk_len
664 lease_set_data = payload[offset:]
665 return cls(session_id, signing_private_key, private_key,
666 lease_set_data)
667
668
669class CreateLeaseSet2Message(I2CPMessage):
670 """Type 41: C→R create lease set 2.
671
672 Wire format: session_id(2) + ls_type(1) + lease_set_data_len(4) +
673 lease_set_data + num_keys(1) +
674 [enc_type(2) + enc_len(2) + priv_key(enc_len)] * N.
675 """
676
677 TYPE = 41
678
679 LS_TYPE_LEASESET = 1
680 LS_TYPE_LS2 = 3
681 LS_TYPE_ENCRYPTED_LS2 = 5
682 LS_TYPE_META_LS2 = 7
683
684 def __init__(self, session_id: int, ls_type: int,
685 lease_set_data: bytes,
686 private_keys: list[tuple[int, bytes]]):
687 self.session_id = session_id
688 self.ls_type = ls_type
689 self.lease_set_data = lease_set_data
690 self.private_keys = private_keys
691
692 def payload_bytes(self) -> bytes:
693 parts = [
694 struct.pack("!HBI", self.session_id, self.ls_type,
695 len(self.lease_set_data)),
696 self.lease_set_data,
697 struct.pack("!B", len(self.private_keys)),
698 ]
699 for enc_type, priv_key in self.private_keys:
700 parts.append(struct.pack("!HH", enc_type, len(priv_key)))
701 parts.append(priv_key)
702 return b"".join(parts)
703
704 @classmethod
705 def _from_payload(cls, payload: bytes) -> "CreateLeaseSet2Message":
706 session_id, ls_type, ls_data_len = struct.unpack(
707 "!HBI", payload[:7])
708 offset = 7
709 lease_set_data = payload[offset:offset + ls_data_len]
710 offset += ls_data_len
711 num_keys = payload[offset]
712 offset += 1
713 private_keys = []
714 for _ in range(num_keys):
715 enc_type, enc_len = struct.unpack(
716 "!HH", payload[offset:offset + 4])
717 offset += 4
718 priv_key = payload[offset:offset + enc_len]
719 offset += enc_len
720 private_keys.append((enc_type, priv_key))
721 return cls(session_id, ls_type, lease_set_data, private_keys)
722
723
724def _parse_destination_from(data: bytes, offset: int) -> tuple[bytes, int]:
725 """Parse a self-delimiting Destination from data at offset.
726
727 Returns (destination_bytes, total_dest_length).
728 The destination is: 256 (pub) + 128 (sig) + cert (3 + cert_payload_len).
729 """
730 cert_start = offset + 384 # 256 + 128
731 if len(data) < cert_start + 3:
732 raise ValueError("Data too short for destination")
733 cert_payload_len = struct.unpack("!H", data[cert_start + 1:cert_start + 3])[0]
734 dest_len = 384 + 3 + cert_payload_len
735 return data[offset:offset + dest_len], dest_len
736
737
738class DateAndFlags:
739 """Combined date + flags for SendMessageExpiresMessage.
740
741 Wire format: 8 bytes total. Upper 16 bits = flags, lower 48 bits = date_ms.
742 """
743
744 SEND_RELIABLE = 0x0001
745 REQUEST_LEASESET = 0x0002
746
747 def __init__(self, date_ms: int = 0, flags: int = 0):
748 self.date_ms = date_ms
749 self.flags = flags
750
751 def to_bytes(self) -> bytes:
752 combined = (self.flags << 48) | (self.date_ms & 0xFFFFFFFFFFFF)
753 return struct.pack("!Q", combined)
754
755 @classmethod
756 def from_bytes(cls, data: bytes) -> "DateAndFlags":
757 combined = struct.unpack("!Q", data[:8])[0]
758 flags = (combined >> 48) & 0xFFFF
759 date_ms = combined & 0xFFFFFFFFFFFF
760 return cls(date_ms, flags)
761
762
763class ReconfigureSessionMessage(I2CPMessage):
764 """Type 2: C→R reconfigure session. session_id(2) + SessionConfig(var)."""
765
766 TYPE = 2
767
768 def __init__(self, session_id: int, session_config):
769 self.session_id = session_id
770 self.session_config = session_config
771
772 def payload_bytes(self) -> bytes:
773 return struct.pack("!H", self.session_id) + self.session_config.to_bytes()
774
775 @classmethod
776 def _from_payload(cls, payload: bytes) -> "ReconfigureSessionMessage":
777 from i2p_client.session_config import WireSessionConfig
778 session_id = struct.unpack("!H", payload[:2])[0]
779 sc = WireSessionConfig.from_bytes(payload[2:])
780 return cls(session_id, sc)
781
782
783class SendMessageExpiresMessage(I2CPMessage):
784 """Type 36: C→R send message with expiration.
785
786 Wire format: session_id(2) + Destination(self-delimiting) +
787 Payload(4-byte size + data) + nonce(4) + expiration(8).
788 """
789
790 TYPE = 36
791
792 def __init__(self, session_id: int, destination_data: bytes,
793 payload: bytes, nonce: int = 0,
794 expiration: DateAndFlags | None = None):
795 self.session_id = session_id
796 self.destination_data = destination_data
797 self.payload = payload
798 self.nonce = nonce
799 self.expiration = expiration or DateAndFlags()
800
801 def payload_bytes(self) -> bytes:
802 return (struct.pack("!H", self.session_id) +
803 self.destination_data +
804 struct.pack("!I", len(self.payload)) + self.payload +
805 struct.pack("!I", self.nonce) +
806 self.expiration.to_bytes())
807
808 @classmethod
809 def _from_payload(cls, payload: bytes) -> "SendMessageExpiresMessage":
810 session_id = struct.unpack("!H", payload[:2])[0]
811 dest_data, dest_len = _parse_destination_from(payload, 2)
812 off = 2 + dest_len
813 pl_len = struct.unpack("!I", payload[off:off + 4])[0]
814 off += 4
815 pl = payload[off:off + pl_len]
816 off += pl_len
817 nonce = struct.unpack("!I", payload[off:off + 4])[0]
818 off += 4
819 expiration = DateAndFlags.from_bytes(payload[off:off + 8])
820 return cls(session_id, dest_data, pl, nonce, expiration)
821
822
823# SigType code -> public key length (bytes) for BlindingInfoMessage endpoint type 3
824_SIGTYPE_PUBKEY_LEN = {
825 0: 128, # DSA_SHA1
826 1: 64, # ECDSA_SHA256_P256
827 2: 96, # ECDSA_SHA384_P384
828 3: 132, # ECDSA_SHA512_P521
829 7: 32, # EdDSA_SHA512_Ed25519
830 11: 32, # RedDSA_SHA512_Ed25519
831}
832
833
834class BlindingInfoMessage(I2CPMessage):
835 """I2CP BlindingInfo message (Type 42, C->R).
836
837 Tells the router about blinding configuration for encrypted LeaseSet2.
838
839 Wire format:
840 session_id(2) + endpoint_type(1) + auth_type(1) + blind_type(2) +
841 expiration(4) + [endpoint_data] + [auth_key(32)]
842 """
843
844 TYPE = 42
845
846 # Endpoint type constants
847 ENDPOINT_HASH = 0
848 ENDPOINT_HOST = 1
849 ENDPOINT_DEST = 2
850 ENDPOINT_PUBKEY = 3
851
852 # Auth type constants
853 AUTH_NONE = 0
854 AUTH_DH = 1
855 AUTH_PSK = 2
856
857 def __init__(
858 self,
859 session_id: int,
860 endpoint_type: int,
861 auth_type: int,
862 blind_type: int,
863 expiration: int,
864 endpoint_data: bytes,
865 auth_key: bytes | None = None,
866 ) -> None:
867 self.session_id = session_id
868 self.endpoint_type = endpoint_type
869 self.auth_type = auth_type
870 self.blind_type = blind_type
871 self.expiration = expiration
872 self.endpoint_data = endpoint_data
873 self.auth_key = auth_key
874
875 def payload_bytes(self) -> bytes:
876 parts = [
877 struct.pack("!H", self.session_id),
878 struct.pack("!B", self.endpoint_type),
879 struct.pack("!B", self.auth_type),
880 struct.pack("!H", self.blind_type),
881 struct.pack("!I", self.expiration),
882 self.endpoint_data,
883 ]
884 if self.auth_type != self.AUTH_NONE and self.auth_key is not None:
885 parts.append(self.auth_key)
886 return b"".join(parts)
887
888 @classmethod
889 def _from_payload(cls, payload: bytes) -> "BlindingInfoMessage":
890 if len(payload) < 10:
891 raise ValueError("BlindingInfoMessage payload too short")
892
893 session_id = struct.unpack("!H", payload[0:2])[0]
894 endpoint_type = payload[2]
895 auth_type = payload[3]
896 blind_type = struct.unpack("!H", payload[4:6])[0]
897 expiration = struct.unpack("!I", payload[6:10])[0]
898
899 offset = 10
900
901 # Parse endpoint data based on type
902 if endpoint_type == cls.ENDPOINT_HASH:
903 endpoint_data = payload[offset:offset + 32]
904 offset += 32
905 elif endpoint_type == cls.ENDPOINT_HOST:
906 host_len = payload[offset]
907 endpoint_data = payload[offset:offset + 1 + host_len]
908 offset += 1 + host_len
909 elif endpoint_type == cls.ENDPOINT_DEST:
910 endpoint_data, dest_len = _parse_destination_from(payload, offset)
911 offset += dest_len
912 elif endpoint_type == cls.ENDPOINT_PUBKEY:
913 sig_type_code = struct.unpack("!H", payload[offset:offset + 2])[0]
914 pubkey_len = _SIGTYPE_PUBKEY_LEN.get(sig_type_code)
915 if pubkey_len is None:
916 raise ValueError(f"Unknown SigType code {sig_type_code}")
917 ep_len = 2 + pubkey_len
918 endpoint_data = payload[offset:offset + ep_len]
919 offset += ep_len
920 else:
921 raise ValueError(f"Unknown endpoint type {endpoint_type}")
922
923 # Parse auth key if present
924 auth_key = None
925 if auth_type != cls.AUTH_NONE:
926 auth_key = payload[offset:offset + 32]
927 offset += 32
928
929 return cls(
930 session_id=session_id,
931 endpoint_type=endpoint_type,
932 auth_type=auth_type,
933 blind_type=blind_type,
934 expiration=expiration,
935 endpoint_data=endpoint_data,
936 auth_key=auth_key,
937 )
938
939
940def _encode_properties(props: dict[str, str]) -> bytes:
941 """Encode properties as I2P wire format: count(2) + (key_len(2) + key + val_len(2) + val)*."""
942 parts = [struct.pack("!H", len(props))]
943 for k, v in sorted(props.items()):
944 kb = k.encode("utf-8")
945 vb = v.encode("utf-8")
946 parts.append(struct.pack("!H", len(kb)) + kb + struct.pack("!H", len(vb)) + vb)
947 return b"".join(parts)
948
949
950def _decode_properties(data: bytes) -> tuple[dict[str, str], int]:
951 """Decode properties from I2P wire format. Returns (dict, bytes_consumed)."""
952 if len(data) < 2:
953 return {}, 0
954 count = struct.unpack("!H", data[:2])[0]
955 offset = 2
956 props = {}
957 for _ in range(count):
958 klen = struct.unpack("!H", data[offset:offset + 2])[0]
959 offset += 2
960 key = data[offset:offset + klen].decode("utf-8")
961 offset += klen
962 vlen = struct.unpack("!H", data[offset:offset + 2])[0]
963 offset += 2
964 val = data[offset:offset + vlen].decode("utf-8")
965 offset += vlen
966 props[key] = val
967 return props, offset