A Python port of the Invisible Internet Project (I2P)
at main 255 lines 9.2 kB view raw
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