"""NTCP2 real listener and connector with AES-CBC handshake and SipHash transport. Uses the real NTCP2 handshake (ntcp2_real_handshake.py) with AES-CBC obfuscation and the real data-phase transport (ntcp2_real_connection.py) with SipHash-obfuscated frame lengths. Key difference from ntcp2_server.py: handshake messages are NOT length-framed -- they have fixed/known sizes on the wire: - Msg1: 64 bytes + padding (padding length in options) - Msg2: 64 bytes + padding (padding length in options) - Msg3: 48 bytes (part1) + msg3p2len bytes (part2, declared in msg1) """ from __future__ import annotations import asyncio import hashlib from typing import Callable, Awaitable from i2p_crypto.aes_cbc import aes_cbc_decrypt from i2p_crypto.noise import HandshakeState from i2p_transport.ntcp2_blocks import decode_msg1_options from i2p_transport.ntcp2_real_handshake import NTCP2RealHandshake from i2p_transport.ntcp2_real_connection import NTCP2RealConnection def _peek_msg1_padlen(static_key: tuple[bytes, bytes], ri_hash: bytes, iv: bytes, msg1_header: bytes) -> int: """Peek-decrypt msg1 options to learn padding length. Creates a throwaway Noise handshake to decrypt the options from the fixed 64-byte msg1 header. This lets us know how many padding bytes to read from the wire before calling process_session_request with the complete message buffer. Returns the padlen1 value from the options. """ # ri_hash is already SHA-256 of RouterIdentity — use directly as AES key aes_key = ri_hash raw_x = aes_cbc_decrypt(aes_key, iv, msg1_header[:32]) # Temporary Noise_XK responder handshake to decrypt options tmp_hs = HandshakeState( pattern="Noise_XK", initiator=False, s=static_key, rs=None, prologue=b"", protocol_name=b"Noise_XKaesobfse+hs2+hs3_25519_ChaChaPoly_SHA256", ) noise_msg = raw_x + msg1_header[32:64] opts_bytes = tmp_hs.read_message(noise_msg) opts = decode_msg1_options(opts_bytes) return opts["padlen1"] class NTCP2RealListener: """Listens for incoming NTCP2 connections using the real handshake. Performs the responder side of the NTCP2 3-message handshake with AES-CBC obfuscation, then creates NTCP2RealConnection instances with SipHash-obfuscated length framing. """ def __init__( self, host: str, port: int, our_static_key: tuple[bytes, bytes], our_ri_hash: bytes, our_iv: bytes, on_connection: Callable[[NTCP2RealConnection], Awaitable[None]] | None = None, ) -> None: """ Args: host: Bind address. port: Bind port (0 for OS-assigned). our_static_key: (private_key, public_key) X25519 keypair. our_ri_hash: SHA-256 hash of our RouterInfo (32 bytes). our_iv: 16-byte IV (the "i" parameter from our NTCP2 address). on_connection: Optional async callback invoked with each new NTCP2RealConnection after a successful handshake. """ self._host = host self._port = port self._our_static = our_static_key self._our_ri_hash = our_ri_hash self._our_iv = our_iv self._on_connection = on_connection self._server: asyncio.Server | None = None async def start(self) -> asyncio.Server: """Start listening and return the asyncio.Server object.""" self._server = await asyncio.start_server( self._handle_connection, self._host, self._port, ) return self._server async def _handle_connection( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: """Handle incoming connection as responder. Steps: 1. Read 64-byte msg1 header, peek-decrypt to learn padding length 2. Read padding bytes, assemble full msg1 3. Process msg1 with the real handshake 4. Create and send msg2 (no padding for simplicity) 5. Read msg3 (48 + msg3p2len bytes) 6. Process msg3, split to get transport keys 7. Create NTCP2RealConnection and invoke callback """ try: # --- Message 1: SessionRequest --- msg1_header = await reader.readexactly(64) # Peek-decrypt options to learn padding length padlen1 = _peek_msg1_padlen( self._our_static, self._our_ri_hash, self._our_iv, msg1_header, ) # Read padding and assemble full msg1 if padlen1 > 0: padding = await reader.readexactly(padlen1) msg1_full = msg1_header + padding else: msg1_full = msg1_header # Create the real handshake and process the complete msg1 hs = NTCP2RealHandshake( our_static=self._our_static, peer_static_pub=None, peer_ri_hash=self._our_ri_hash, peer_iv=self._our_iv, initiator=False, ) hs.process_session_request(msg1_full) # --- Message 2: SessionCreated --- msg2 = hs.create_session_created(padding_len=0) writer.write(msg2) await writer.drain() # --- Message 3: SessionConfirmed --- # msg3p2len was declared in msg1 options msg3p2len = hs._msg3p2len msg3 = await reader.readexactly(48 + msg3p2len) hs.process_session_confirmed(msg3) # --- Split into transport keys --- keys = hs.split() conn = NTCP2RealConnection( reader=reader, writer=writer, cipher_send=keys.send_cipher, cipher_recv=keys.recv_cipher, siphash_send=keys.send_siphash, siphash_recv=keys.recv_siphash, remote_hash=hs.remote_static_key() or b"", ) if self._on_connection is not None: await self._on_connection(conn) except Exception: writer.close() def close(self) -> None: """Stop listening.""" if self._server is not None: self._server.close() async def wait_closed(self) -> None: """Wait for the server to fully close.""" if self._server is not None: await self._server.wait_closed() class NTCP2RealConnector: """Connects to a remote NTCP2 peer using the real handshake.""" async def connect( self, host: str, port: int, our_static_key: tuple[bytes, bytes], our_ri_bytes: bytes, peer_static_pub: bytes, peer_ri_hash: bytes, peer_iv: bytes, ) -> NTCP2RealConnection: """Open a TCP connection and complete the real NTCP2 handshake as initiator. Args: host: Remote host address. port: Remote port. our_static_key: (private_key, public_key) X25519 keypair. our_ri_bytes: Our RouterInfo serialized bytes (sent in msg3). peer_static_pub: Peer's X25519 static public key (32 bytes). peer_ri_hash: SHA-256 of peer's RouterInfo (32 bytes). peer_iv: 16-byte IV from peer's "i" NTCP2 address option. Returns: An established NTCP2RealConnection ready for frame exchange. """ reader, writer = await asyncio.open_connection(host, port) try: hs = NTCP2RealHandshake( our_static=our_static_key, peer_static_pub=peer_static_pub, peer_ri_hash=peer_ri_hash, peer_iv=peer_iv, initiator=True, ) # --- Message 1: SessionRequest (no padding) --- msg1 = hs.create_session_request(padding_len=0, router_info=our_ri_bytes) writer.write(msg1) await writer.drain() # --- Message 2: SessionCreated --- # Read the fixed 64-byte header, decrypt options to learn padlen2, # then read any padding bytes. msg2_header = await reader.readexactly(64) padlen2 = hs.process_session_created_header(msg2_header) if padlen2 > 0: msg2_padding = await reader.readexactly(padlen2) else: msg2_padding = b"" hs.process_session_created_padding(msg2_padding) # --- Message 3: SessionConfirmed --- msg3 = hs.create_session_confirmed(router_info=our_ri_bytes) writer.write(msg3) await writer.drain() # --- Split into transport keys --- keys = hs.split() return NTCP2RealConnection( reader=reader, writer=writer, cipher_send=keys.send_cipher, cipher_recv=keys.recv_cipher, siphash_send=keys.send_siphash, siphash_recv=keys.recv_siphash, remote_hash=peer_static_pub, ) except Exception: writer.close() raise