A Python port of the Invisible Internet Project (I2P)
1"""NTCP2 real listener and connector with AES-CBC handshake and SipHash transport.
2
3Uses the real NTCP2 handshake (ntcp2_real_handshake.py) with AES-CBC
4obfuscation and the real data-phase transport (ntcp2_real_connection.py)
5with SipHash-obfuscated frame lengths.
6
7Key difference from ntcp2_server.py: handshake messages are NOT
8length-framed -- they have fixed/known sizes on the wire:
9 - Msg1: 64 bytes + padding (padding length in options)
10 - Msg2: 64 bytes + padding (padding length in options)
11 - Msg3: 48 bytes (part1) + msg3p2len bytes (part2, declared in msg1)
12"""
13
14from __future__ import annotations
15
16import asyncio
17import hashlib
18from typing import Callable, Awaitable
19
20from i2p_crypto.aes_cbc import aes_cbc_decrypt
21from i2p_crypto.noise import HandshakeState
22from i2p_transport.ntcp2_blocks import decode_msg1_options
23from i2p_transport.ntcp2_real_handshake import NTCP2RealHandshake
24from i2p_transport.ntcp2_real_connection import NTCP2RealConnection
25
26
27def _peek_msg1_padlen(static_key: tuple[bytes, bytes],
28 ri_hash: bytes, iv: bytes,
29 msg1_header: bytes) -> int:
30 """Peek-decrypt msg1 options to learn padding length.
31
32 Creates a throwaway Noise handshake to decrypt the options from
33 the fixed 64-byte msg1 header. This lets us know how many padding
34 bytes to read from the wire before calling process_session_request
35 with the complete message buffer.
36
37 Returns the padlen1 value from the options.
38 """
39 # ri_hash is already SHA-256 of RouterIdentity — use directly as AES key
40 aes_key = ri_hash
41 raw_x = aes_cbc_decrypt(aes_key, iv, msg1_header[:32])
42
43 # Temporary Noise_XK responder handshake to decrypt options
44 tmp_hs = HandshakeState(
45 pattern="Noise_XK",
46 initiator=False,
47 s=static_key,
48 rs=None,
49 prologue=b"",
50 protocol_name=b"Noise_XKaesobfse+hs2+hs3_25519_ChaChaPoly_SHA256",
51 )
52 noise_msg = raw_x + msg1_header[32:64]
53 opts_bytes = tmp_hs.read_message(noise_msg)
54 opts = decode_msg1_options(opts_bytes)
55 return opts["padlen1"]
56
57
58class NTCP2RealListener:
59 """Listens for incoming NTCP2 connections using the real handshake.
60
61 Performs the responder side of the NTCP2 3-message handshake with
62 AES-CBC obfuscation, then creates NTCP2RealConnection instances
63 with SipHash-obfuscated length framing.
64 """
65
66 def __init__(
67 self,
68 host: str,
69 port: int,
70 our_static_key: tuple[bytes, bytes],
71 our_ri_hash: bytes,
72 our_iv: bytes,
73 on_connection: Callable[[NTCP2RealConnection], Awaitable[None]] | None = None,
74 ) -> None:
75 """
76 Args:
77 host: Bind address.
78 port: Bind port (0 for OS-assigned).
79 our_static_key: (private_key, public_key) X25519 keypair.
80 our_ri_hash: SHA-256 hash of our RouterInfo (32 bytes).
81 our_iv: 16-byte IV (the "i" parameter from our NTCP2 address).
82 on_connection: Optional async callback invoked with each new
83 NTCP2RealConnection after a successful handshake.
84 """
85 self._host = host
86 self._port = port
87 self._our_static = our_static_key
88 self._our_ri_hash = our_ri_hash
89 self._our_iv = our_iv
90 self._on_connection = on_connection
91 self._server: asyncio.Server | None = None
92
93 async def start(self) -> asyncio.Server:
94 """Start listening and return the asyncio.Server object."""
95 self._server = await asyncio.start_server(
96 self._handle_connection, self._host, self._port,
97 )
98 return self._server
99
100 async def _handle_connection(
101 self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
102 ) -> None:
103 """Handle incoming connection as responder.
104
105 Steps:
106 1. Read 64-byte msg1 header, peek-decrypt to learn padding length
107 2. Read padding bytes, assemble full msg1
108 3. Process msg1 with the real handshake
109 4. Create and send msg2 (no padding for simplicity)
110 5. Read msg3 (48 + msg3p2len bytes)
111 6. Process msg3, split to get transport keys
112 7. Create NTCP2RealConnection and invoke callback
113 """
114 try:
115 # --- Message 1: SessionRequest ---
116 msg1_header = await reader.readexactly(64)
117
118 # Peek-decrypt options to learn padding length
119 padlen1 = _peek_msg1_padlen(
120 self._our_static, self._our_ri_hash, self._our_iv, msg1_header,
121 )
122
123 # Read padding and assemble full msg1
124 if padlen1 > 0:
125 padding = await reader.readexactly(padlen1)
126 msg1_full = msg1_header + padding
127 else:
128 msg1_full = msg1_header
129
130 # Create the real handshake and process the complete msg1
131 hs = NTCP2RealHandshake(
132 our_static=self._our_static,
133 peer_static_pub=None,
134 peer_ri_hash=self._our_ri_hash,
135 peer_iv=self._our_iv,
136 initiator=False,
137 )
138 hs.process_session_request(msg1_full)
139
140 # --- Message 2: SessionCreated ---
141 msg2 = hs.create_session_created(padding_len=0)
142 writer.write(msg2)
143 await writer.drain()
144
145 # --- Message 3: SessionConfirmed ---
146 # msg3p2len was declared in msg1 options
147 msg3p2len = hs._msg3p2len
148 msg3 = await reader.readexactly(48 + msg3p2len)
149 hs.process_session_confirmed(msg3)
150
151 # --- Split into transport keys ---
152 keys = hs.split()
153
154 conn = NTCP2RealConnection(
155 reader=reader,
156 writer=writer,
157 cipher_send=keys.send_cipher,
158 cipher_recv=keys.recv_cipher,
159 siphash_send=keys.send_siphash,
160 siphash_recv=keys.recv_siphash,
161 remote_hash=hs.remote_static_key() or b"",
162 )
163
164 if self._on_connection is not None:
165 await self._on_connection(conn)
166
167 except Exception:
168 writer.close()
169
170 def close(self) -> None:
171 """Stop listening."""
172 if self._server is not None:
173 self._server.close()
174
175 async def wait_closed(self) -> None:
176 """Wait for the server to fully close."""
177 if self._server is not None:
178 await self._server.wait_closed()
179
180
181class NTCP2RealConnector:
182 """Connects to a remote NTCP2 peer using the real handshake."""
183
184 async def connect(
185 self,
186 host: str,
187 port: int,
188 our_static_key: tuple[bytes, bytes],
189 our_ri_bytes: bytes,
190 peer_static_pub: bytes,
191 peer_ri_hash: bytes,
192 peer_iv: bytes,
193 ) -> NTCP2RealConnection:
194 """Open a TCP connection and complete the real NTCP2 handshake as initiator.
195
196 Args:
197 host: Remote host address.
198 port: Remote port.
199 our_static_key: (private_key, public_key) X25519 keypair.
200 our_ri_bytes: Our RouterInfo serialized bytes (sent in msg3).
201 peer_static_pub: Peer's X25519 static public key (32 bytes).
202 peer_ri_hash: SHA-256 of peer's RouterInfo (32 bytes).
203 peer_iv: 16-byte IV from peer's "i" NTCP2 address option.
204
205 Returns:
206 An established NTCP2RealConnection ready for frame exchange.
207 """
208 reader, writer = await asyncio.open_connection(host, port)
209
210 try:
211 hs = NTCP2RealHandshake(
212 our_static=our_static_key,
213 peer_static_pub=peer_static_pub,
214 peer_ri_hash=peer_ri_hash,
215 peer_iv=peer_iv,
216 initiator=True,
217 )
218
219 # --- Message 1: SessionRequest (no padding) ---
220 msg1 = hs.create_session_request(padding_len=0, router_info=our_ri_bytes)
221 writer.write(msg1)
222 await writer.drain()
223
224 # --- Message 2: SessionCreated ---
225 # Read the fixed 64-byte header, decrypt options to learn padlen2,
226 # then read any padding bytes.
227 msg2_header = await reader.readexactly(64)
228 padlen2 = hs.process_session_created_header(msg2_header)
229 if padlen2 > 0:
230 msg2_padding = await reader.readexactly(padlen2)
231 else:
232 msg2_padding = b""
233 hs.process_session_created_padding(msg2_padding)
234
235 # --- Message 3: SessionConfirmed ---
236 msg3 = hs.create_session_confirmed(router_info=our_ri_bytes)
237 writer.write(msg3)
238 await writer.drain()
239
240 # --- Split into transport keys ---
241 keys = hs.split()
242
243 return NTCP2RealConnection(
244 reader=reader,
245 writer=writer,
246 cipher_send=keys.send_cipher,
247 cipher_recv=keys.recv_cipher,
248 siphash_send=keys.send_siphash,
249 siphash_recv=keys.recv_siphash,
250 remote_hash=peer_static_pub,
251 )
252
253 except Exception:
254 writer.close()
255 raise