diff --git a/.github/workflows/test-integration-docker.yml b/.github/workflows/test-integration-docker.yml index 84e7349..79e033c 100644 --- a/.github/workflows/test-integration-docker.yml +++ b/.github/workflows/test-integration-docker.yml @@ -9,17 +9,19 @@ on: jobs: test-integration-docker: runs-on: ubuntu-latest - + timeout-minutes: 30 + steps: - name: Checkout thinclient repository uses: actions/checkout@v4 with: path: thinclient - - name: Checkout katzenpost repository + - name: Checkout katzenpost repository uses: actions/checkout@v4 with: repository: katzenpost/katzenpost + ref: 0a80919f65b0282e97435392e9a7b20a68d3b836 path: katzenpost - name: Set up Docker Buildx @@ -58,17 +60,19 @@ jobs: cd katzenpost/docker && make start wait - name: Brief pause to ensure mixnet is fully ready - run: sleep 5 + run: sleep 30 - name: Run all Python tests (including channel API integration tests) + timeout-minutes: 30 run: | cd thinclient - python -m pytest tests/ -vvv -s --tb=short + python -m pytest tests/ -vvv -s --tb=short --timeout=1200 - name: Run Rust integration tests + timeout-minutes: 20 run: | cd thinclient - cargo test --test '*' -- --nocapture + cargo test --test '*' -- --nocapture --test-threads=1 - name: Stop the mixnet if: always() diff --git a/Cargo.lock b/Cargo.lock index be34c12..4bdd7b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,7 @@ dependencies = [ "log", "rand", "serde", + "serde_bytes", "serde_cbor", "serde_json", "tokio", @@ -521,13 +522,24 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_cbor" version = "0.11.2" @@ -538,11 +550,20 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 12ffcb2..13a17b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ keywords = ["katzenpost", "cryptography", "sphinx", "mixnet"] libc = "0.2.152" rand = "0.8" serde = { version = "1.0", features = ["derive"] } +serde_bytes = "0.11" serde_json = "1.0" serde_cbor = "0.11" blake2 = "0.10" diff --git a/katzenpost_thinclient/__init__.py b/katzenpost_thinclient/__init__.py index ba3a6a7..c2a9e38 100644 --- a/katzenpost_thinclient/__init__.py +++ b/katzenpost_thinclient/__init__.py @@ -70,7 +70,6 @@ async def main(): THIN_CLIENT_ERROR_INVALID_REQUEST = 3 THIN_CLIENT_ERROR_INTERNAL_ERROR = 4 THIN_CLIENT_ERROR_MAX_RETRIES = 5 - THIN_CLIENT_ERROR_INVALID_CHANNEL = 6 THIN_CLIENT_ERROR_CHANNEL_NOT_FOUND = 7 THIN_CLIENT_ERROR_PERMISSION_DENIED = 8 @@ -79,6 +78,17 @@ async def main(): THIN_CLIENT_ERROR_DUPLICATE_CAPABILITY = 11 THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION = 12 THIN_CLIENT_PROPAGATION_ERROR = 13 +THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY = 14 +THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY = 15 +THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST = 16 +THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST = 17 +THIN_CLIENT_IMPOSSIBLE_HASH_ERROR = 18 +THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR = 19 +THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR = 20 +THIN_CLIENT_CAPABILITY_ALREADY_IN_USE = 21 +THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED = 22 +THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED = 23 +THIN_CLIENT_ERROR_START_RESENDING_CANCELLED = 24 def thin_client_error_to_string(error_code: int) -> str: """Convert a thin client error code to a human-readable string.""" @@ -89,7 +99,6 @@ def thin_client_error_to_string(error_code: int) -> str: THIN_CLIENT_ERROR_INVALID_REQUEST: "Invalid request", THIN_CLIENT_ERROR_INTERNAL_ERROR: "Internal error", THIN_CLIENT_ERROR_MAX_RETRIES: "Maximum retries exceeded", - THIN_CLIENT_ERROR_INVALID_CHANNEL: "Invalid channel", THIN_CLIENT_ERROR_CHANNEL_NOT_FOUND: "Channel not found", THIN_CLIENT_ERROR_PERMISSION_DENIED: "Permission denied", @@ -98,6 +107,17 @@ def thin_client_error_to_string(error_code: int) -> str: THIN_CLIENT_ERROR_DUPLICATE_CAPABILITY: "Duplicate capability", THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION: "Courier cache corruption", THIN_CLIENT_PROPAGATION_ERROR: "Propagation error", + THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY: "Invalid write capability", + THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY: "Invalid read capability", + THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST: "Invalid resume write channel request", + THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST: "Invalid resume read channel request", + THIN_CLIENT_IMPOSSIBLE_HASH_ERROR: "Impossible hash error", + THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR: "Failed to create new write capability", + THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR: "Failed to create new stateful writer", + THIN_CLIENT_CAPABILITY_ALREADY_IN_USE: "Capability already in use", + THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED: "MKEM decryption failed", + THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED: "BACAP decryption failed", + THIN_CLIENT_ERROR_START_RESENDING_CANCELLED: "Start resending cancelled", } return error_messages.get(error_code, f"Unknown thin client error code: {error_code}") @@ -123,6 +143,10 @@ class ThinClientOfflineError(Exception): # which is unique to the sent message. MESSAGE_ID_SIZE = 16 +# STREAM_ID_LENGTH is the length of a stream ID in bytes. +# Used for multi-call envelope encoding streams. +STREAM_ID_LENGTH = 16 + class WriteChannelReply: """Reply from WriteChannel operation, matching Rust WriteChannelReply.""" @@ -215,6 +239,128 @@ def __str__(self) -> str: f"KEMName: {self.KEMName}" ) + +class PigeonholeGeometry: + """ + PigeonholeGeometry describes the geometry of a Pigeonhole envelope. + + This provides mathematically precise geometry calculations for the + Pigeonhole protocol using trunnel's fixed binary format. + + It supports 3 distinct use cases: + 1. Given MaxPlaintextPayloadLength → compute all envelope sizes + 2. Given precomputed Pigeonhole Geometry → derive accommodating Sphinx Geometry + 3. Given Sphinx Geometry constraint → derive optimal Pigeonhole Geometry + + Attributes: + max_plaintext_payload_length (int): The maximum usable plaintext payload size within a Box. + courier_query_read_length (int): The size of a CourierQuery containing a ReplicaRead. + courier_query_write_length (int): The size of a CourierQuery containing a ReplicaWrite. + courier_query_reply_read_length (int): The size of a CourierQueryReply containing a ReplicaReadReply. + courier_query_reply_write_length (int): The size of a CourierQueryReply containing a ReplicaWriteReply. + nike_name (str): The NIKE scheme name used in MKEM for encrypting to multiple storage replicas. + signature_scheme_name (str): The signature scheme used for BACAP (always "Ed25519"). + """ + + # Length prefix for padded payloads + LENGTH_PREFIX_SIZE = 4 + + def __init__( + self, + *, + max_plaintext_payload_length: int, + courier_query_read_length: int = 0, + courier_query_write_length: int = 0, + courier_query_reply_read_length: int = 0, + courier_query_reply_write_length: int = 0, + nike_name: str = "", + signature_scheme_name: str = "Ed25519" + ) -> None: + self.max_plaintext_payload_length = max_plaintext_payload_length + self.courier_query_read_length = courier_query_read_length + self.courier_query_write_length = courier_query_write_length + self.courier_query_reply_read_length = courier_query_reply_read_length + self.courier_query_reply_write_length = courier_query_reply_write_length + self.nike_name = nike_name + self.signature_scheme_name = signature_scheme_name + + def validate(self) -> None: + """ + Validates that the geometry has valid parameters. + + Raises: + ValueError: If the geometry is invalid. + """ + if self.max_plaintext_payload_length <= 0: + raise ValueError("max_plaintext_payload_length must be positive") + if not self.nike_name: + raise ValueError("nike_name must be set") + if self.signature_scheme_name != "Ed25519": + raise ValueError("signature_scheme_name must be 'Ed25519'") + + def padded_payload_length(self) -> int: + """ + Returns the payload size after adding length prefix. + + Returns: + int: The padded payload length (max_plaintext_payload_length + 4). + """ + return self.max_plaintext_payload_length + self.LENGTH_PREFIX_SIZE + + def __str__(self) -> str: + return ( + f"PigeonholeGeometry:\n" + f" max_plaintext_payload_length: {self.max_plaintext_payload_length} bytes\n" + f" courier_query_read_length: {self.courier_query_read_length} bytes\n" + f" courier_query_write_length: {self.courier_query_write_length} bytes\n" + f" courier_query_reply_read_length: {self.courier_query_reply_read_length} bytes\n" + f" courier_query_reply_write_length: {self.courier_query_reply_write_length} bytes\n" + f" nike_name: {self.nike_name}\n" + f" signature_scheme_name: {self.signature_scheme_name}" + ) + + +def tombstone_plaintext(geometry: PigeonholeGeometry) -> bytes: + """ + Creates a tombstone plaintext (all zeros) for the given geometry. + + A tombstone is used to overwrite/delete a pigeonhole box by filling it + with zeros. + + Args: + geometry: Pigeonhole geometry defining the payload size. + + Returns: + bytes: Zero-filled bytes of length max_plaintext_payload_length. + + Raises: + ValueError: If the geometry is None or invalid. + """ + if geometry is None: + raise ValueError("geometry cannot be None") + geometry.validate() + return bytes(geometry.max_plaintext_payload_length) + + +def is_tombstone_plaintext(geometry: PigeonholeGeometry, plaintext: bytes) -> bool: + """ + Checks if a plaintext is a tombstone (all zeros). + + Args: + geometry: Pigeonhole geometry defining the expected payload size. + plaintext: The plaintext bytes to check. + + Returns: + bool: True if the plaintext is the correct length and all zeros. + """ + if geometry is None: + return False + if len(plaintext) != geometry.max_plaintext_payload_length: + return False + # Constant-time comparison to check if all bytes are zero + return all(b == 0 for b in plaintext) + + class ConfigFile: """ ConfigFile represents everything loaded from a TOML file: @@ -400,7 +546,7 @@ def __init__(self, filepath:str, - 'message_id' (bytes): 16-byte identifier matching the original message - 'surbid' (bytes, optional): SURB ID if reply used SURB, None otherwise - 'payload' (bytes): Reply payload data from the service - - 'reply_index' (int, optional): Index of reply used + - 'reply_index' (int, optional): Index of reply used (relevant for channel reads) - 'error_code' (int): Error code indicating success (0) or specific failure condition Example: ``{'message_id': b'\\x01\\x02...', 'surbid': b'\\xaa\\xbb...', 'payload': b'echo response', 'reply_index': 0, 'error_code': 0}`` @@ -466,7 +612,11 @@ def __init__(self, config:Config) -> None: self.pki_doc : Dict[Any,Any] | None = None self.config = config self.reply_received_event = asyncio.Event() - + self.channel_reply_event = asyncio.Event() + self.channel_reply_data : Dict[Any,Any] | None = None + # For handling async read channel responses with message ID correlation + self.pending_read_channels : Dict[bytes,asyncio.Event] = {} # message_id -> asyncio.Event + self.read_channel_responses : Dict[bytes,bytes] = {} # message_id -> payload self._is_connected : bool = False # Track connection state # Mutexes to serialize socket send/recv operations: @@ -481,7 +631,10 @@ def __init__(self, config:Config) -> None: self.pending_channel_message_queries : Dict[bytes, asyncio.Event] = {} # message_id -> Event self.channel_message_query_responses : Dict[bytes, bytes] = {} # message_id -> payload - + # For message ID-based reply matching (old channel API) + self._expected_message_id : bytes | None = None + self._received_reply_payload : bytes | None = None + self._reply_received_for_message_id : asyncio.Event | None = None self.logger = logging.getLogger('thinclient') self.logger.setLevel(logging.DEBUG) # Only add handler if none exists to avoid duplicate log messages @@ -690,7 +843,7 @@ def parse_status(self, event: "Dict[str,Any]") -> None: if self._is_connected: self.logger.debug("Daemon is connected to mixnet - full functionality available") else: - self.logger.info("Daemon is not connected to mixnet - entering offline mode") + self.logger.info("Daemon is not connected to mixnet - entering offline mode (channel operations will work)") self.logger.debug("parse status success") @@ -776,6 +929,20 @@ def new_query_id(self) -> bytes: """ return os.urandom(16) + @staticmethod + def new_stream_id() -> bytes: + """ + Generate a new 16-byte stream ID for copy stream operations. + + Stream IDs are used to identify encoder instances for multi-call + envelope encoding streams. All calls for the same stream must use + the same stream ID. + + Returns: + bytes: Random 16-byte stream identifier. + """ + return os.urandom(STREAM_ID_LENGTH) + async def _send_and_wait(self, *, query_id:bytes, request: Dict[str, Any]) -> Dict[str, Any]: cbor_request = cbor2.dumps(request) length_prefix = struct.pack('>I', len(cbor_request)) @@ -856,6 +1023,38 @@ async def handle_response(self, response: "Dict[str,Any]") -> None: if response.get("message_reply_event") is not None: self.logger.debug("message reply event") reply = response["message_reply_event"] + + # Check if this reply matches our expected message ID for old channel operations + if hasattr(self, '_expected_message_id') and self._expected_message_id is not None: + reply_message_id = reply.get("message_id") + if reply_message_id is not None and reply_message_id == self._expected_message_id: + self.logger.debug(f"Received matching MessageReplyEvent for message_id {reply_message_id.hex()[:16]}...") + # Handle error in reply using error_code field + error_code = reply.get("error_code", 0) + self.logger.debug(f"MessageReplyEvent: error_code={error_code}") + if error_code != 0: + error_msg = thin_client_error_to_string(error_code) + self.logger.debug(f"Reply contains error: {error_msg} (error code {error_code})") + self._received_reply_payload = None + else: + payload = reply.get("payload") + if payload is None: + self._received_reply_payload = b"" + else: + self._received_reply_payload = payload + self.logger.debug(f"Reply contains {len(self._received_reply_payload)} bytes of payload") + + # Signal that we received the matching reply + if hasattr(self, '_reply_received_for_message_id'): + self._reply_received_for_message_id.set() + return + else: + if reply_message_id is not None: + self.logger.debug(f"Received MessageReplyEvent with mismatched message_id (expected {self._expected_message_id.hex()[:16]}..., got {reply_message_id.hex()[:16]}...), ignoring") + else: + self.logger.debug("Received MessageReplyEvent with nil message_id, ignoring") + + # Fall back to original behavior for non-channel operations self.reply_received_event.set() await self.config.handle_message_reply_event(reply) return @@ -876,6 +1075,38 @@ async def handle_response(self, response: "Dict[str,Any]") -> None: # Continue waiting for the reply (don't return here) return + # Handle old channel API replies + if response.get("create_write_channel_reply") is not None: + self.logger.debug("channel create_write_channel_reply event") + self.channel_reply_data = response + self.channel_reply_event.set() + return + + if response.get("create_read_channel_reply") is not None: + self.logger.debug("channel create_read_channel_reply event") + self.channel_reply_data = response + self.channel_reply_event.set() + return + + if response.get("write_channel_reply") is not None: + self.logger.debug("channel write_channel_reply event") + self.channel_reply_data = response + self.channel_reply_event.set() + return + + if response.get("read_channel_reply") is not None: + self.logger.debug("channel read_channel_reply event") + self.channel_reply_data = response + self.channel_reply_event.set() + return + + if response.get("copy_channel_reply") is not None: + self.logger.debug("channel copy_channel_reply event") + self.channel_reply_data = response + self.channel_reply_event.set() + return + + # Handle newer channel query reply events if query_ack := response.get("channel_query_reply_event", None): # this is the ACK from the courier self.logger.debug("channel_query_reply_event") @@ -1017,7 +1248,68 @@ async def send_message(self, surb_id:bytes, payload:bytes|str, dest_node:bytes, except Exception as e: self.logger.error(f"Error sending message: {e}") + async def send_channel_query(self, channel_id:int, payload:bytes, dest_node:bytes, dest_queue:bytes, message_id:"bytes|None"=None): + """ + Send a channel query (prepared by write_channel or read_channel) to the mixnet. + This method sets the ChannelID inside the Request for proper channel handling. + This method requires mixnet connectivity. + + Args: + channel_id (int): The 16-bit channel ID. + payload (bytes): Channel query payload prepared by write_channel or read_channel. + dest_node (bytes): Destination node identity hash. + dest_queue (bytes): Destination recipient queue ID. + message_id (bytes, optional): Message ID for reply correlation. If None, generates a new one. + + Returns: + bytes: The message ID used for this query (either provided or generated). + + Raises: + RuntimeError: If in offline mode (daemon not connected to mixnet). + """ + # Check if we're in offline mode + if not self._is_connected: + raise RuntimeError("cannot send channel query in offline mode - daemon not connected to mixnet") + + if not isinstance(payload, bytes): + payload = payload.encode('utf-8') # Encoding the string to bytes + + # Generate message ID if not provided, and SURB ID + if message_id is None: + message_id = self.new_message_id() + self.logger.debug(f"send_channel_query: Generated message_id {message_id.hex()[:16]}...") + else: + self.logger.debug(f"send_channel_query: Using provided message_id {message_id.hex()[:16]}...") + + surb_id = self.new_surb_id() + + # Create the SendMessage structure with ChannelID + + send_message = { + "channel_id": channel_id, # This is the key difference from send_message + "id": message_id, # Use generated message_id for reply correlation + "with_surb": True, + "surbid": surb_id, + "destination_id_hash": dest_node, + "recipient_queue_id": dest_queue, + "payload": payload, + } + + # Wrap in the new Request structure + request = { + "send_message": send_message + } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: + await self._send_all(length_prefixed_request) + self.logger.info(f"Channel query sent successfully for channel {channel_id}.") + return message_id + except Exception as e: + self.logger.error(f"Error sending channel query: {e}") + raise async def send_reliable_message(self, message_id:bytes, payload:bytes|str, dest_node:bytes, dest_queue:bytes) -> None: """ @@ -1107,86 +1399,134 @@ async def await_message_reply(self) -> None: # Channel API methods - async def create_write_channel(self) -> "Tuple[int, bytes, bytes]": + async def create_write_channel(self, write_cap: "bytes|None "=None, message_box_index: "bytes|None"=None) -> "Tuple[bytes,bytes,bytes,bytes]": """ - Creates a new Pigeonhole write channel for sending messages. + Create a new pigeonhole write channel. + + Args: + write_cap: Optional WriteCap for resuming an existing channel. + message_box_index: Optional MessageBoxIndex for resuming from a specific position. Returns: - tuple: (channel_id, read_cap, write_cap) where: - - channel_id is the 16-bit channel ID + tuple: (channel_id, read_cap, write_cap, next_message_index) where: + - channel_id is 16-bit channel ID - read_cap is the read capability for sharing - write_cap is the write capability for persistence + - next_message_index is the current position for crash consistency Raises: Exception: If the channel creation fails. """ - query_id = self.new_query_id() + request_data = {} + + if write_cap is not None: + request_data["write_cap"] = write_cap + + if message_box_index is not None: + request_data["message_box_index"] = message_box_index request = { - "create_write_channel": { - "query_id": query_id - } + "create_write_channel": request_data } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: - reply = await self._send_and_wait(query_id=query_id, request=request) - except Exception as e: - self.logger.error(f"Error creating write channel: {e}") - raise e + # Clear previous reply data and reset event + self.channel_reply_data = None + self.channel_reply_event.clear() - channel_id = reply["channel_id"] - read_cap = reply["read_cap"] - write_cap = reply["write_cap"] + await self._send_all(length_prefixed_request) + self.logger.info("CreateWriteChannel request sent successfully.") - return channel_id, read_cap, write_cap + # Wait for CreateWriteChannelReply via the background worker + await self.channel_reply_event.wait() - async def create_read_channel(self, read_cap: bytes) -> int: + if self.channel_reply_data and self.channel_reply_data.get("create_write_channel_reply"): + reply = self.channel_reply_data["create_write_channel_reply"] + error_code = reply.get("error_code", 0) + if error_code != 0: + error_msg = thin_client_error_to_string(error_code) + raise Exception(f"CreateWriteChannel failed: {error_msg} (error code {error_code})") + return reply["channel_id"], reply["read_cap"], reply["write_cap"], reply["next_message_index"] + else: + raise Exception("No create_write_channel_reply received") + + except Exception as e: + self.logger.error(f"Error creating write channel: {e}") + raise + + async def create_read_channel(self, read_cap:bytes, message_box_index: "bytes|None"=None) -> "Tuple[bytes,bytes]": """ - Creates a read channel from a read capability. + Create a read channel from a read capability. Args: - read_cap: The read capability bytes. + read_cap: The read capability object. + message_box_index: Optional MessageBoxIndex for resuming from a specific position. Returns: - int: The channel ID. + tuple: (channel_id, next_message_index) where: + - channel_id is the 16-bit channel ID + - next_message_index is the current position for crash consistency Raises: Exception: If the read channel creation fails. """ - query_id = self.new_query_id() + request_data = { + "read_cap": read_cap + } + + if message_box_index is not None: + request_data["message_box_index"] = message_box_index request = { - "create_read_channel": { - "query_id": query_id, - "read_cap": read_cap - } + "create_read_channel": request_data } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: - reply = await self._send_and_wait(query_id=query_id, request=request) + # Clear previous reply data and reset event + self.channel_reply_data = None + self.channel_reply_event.clear() + + await self._send_all(length_prefixed_request) + self.logger.info("CreateReadChannel request sent successfully.") + + # Wait for CreateReadChannelReply via the background worker + await self.channel_reply_event.wait() + + if self.channel_reply_data and self.channel_reply_data.get("create_read_channel_reply"): + reply = self.channel_reply_data["create_read_channel_reply"] + error_code = reply.get("error_code", 0) + if error_code != 0: + error_msg = thin_client_error_to_string(error_code) + raise Exception(f"CreateReadChannel failed: {error_msg} (error code {error_code})") + return reply["channel_id"], reply["next_message_index"] + else: + raise Exception("No create_read_channel_reply received") + except Exception as e: self.logger.error(f"Error creating read channel: {e}") raise - # client2/thin/thin_messages.go: ThinClientCapabilityAlreadyInUse uint8 = 21 - - channel_id = reply["channel_id"] - return channel_id - - async def write_channel(self, channel_id: int, payload: "bytes|str") -> WriteChannelReply: + async def write_channel(self, channel_id: bytes, payload: "bytes|str") -> "Tuple[bytes,bytes]": """ - Prepares a message for writing to a Pigeonhole channel. + Prepare a write message for a pigeonhole channel and return the SendMessage payload and next MessageBoxIndex. + The thin client must then call send_message with the returned payload to actually send the message. Args: - channel_id: The 16-bit channel ID. - payload: The data to write to the channel. + channel_id (int): The 16-bit channel ID. + payload (bytes or str): The data to write to the channel. Returns: - WriteChannelReply: Reply containing send_message_payload and other metadata. - // ThinClientErrorInternalError indicates an internal error occurred within - // the client daemon or thin client that prevented operation completion. - ThinClientErrorInternalError uint8 = 4 - + tuple: (send_message_payload, next_message_index) where: + - send_message_payload is the prepared payload for send_message + - next_message_index is the position to use after courier acknowledgment Raises: Exception: If the write preparation fails. @@ -1194,64 +1534,69 @@ async def write_channel(self, channel_id: int, payload: "bytes|str") -> WriteCha if not isinstance(payload, bytes): payload = payload.encode('utf-8') - query_id = self.new_query_id() - request = { "write_channel": { "channel_id": channel_id, - "query_id": query_id, "payload": payload } } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: - reply = await self._send_and_wait(query_id=query_id, request=request) + # Clear previous reply data and reset event + self.channel_reply_data = None + self.channel_reply_event.clear() + + await self._send_all(length_prefixed_request) + self.logger.info("WriteChannel prepare request sent successfully.") + + # Wait for WriteChannelReply via the background worker + await self.channel_reply_event.wait() + + if self.channel_reply_data and self.channel_reply_data.get("write_channel_reply"): + reply = self.channel_reply_data["write_channel_reply"] + error_code = reply.get("error_code", 0) + if error_code != 0: + error_msg = thin_client_error_to_string(error_code) + raise Exception(f"WriteChannel failed: {error_msg} (error code {error_code})") + return reply["send_message_payload"], reply["next_message_index"] + else: + raise Exception("No write_channel_reply received") + except Exception as e: self.logger.error(f"Error preparing write to channel: {e}") raise - if reply['error_code'] != 0: - # Examples: - # 12:24:32.206 ERRO katzenpost/client2: writeChannel failure: failed to create write request: pki: replica not found - # - This one will probably never succeed? Why is the client using a bad replica? - # - raise Exception(f"write_channel got error from clientd: {reply['error_code']}") - - return WriteChannelReply( - send_message_payload=reply["send_message_payload"], - current_message_index=reply["current_message_index"], - next_message_index=reply["next_message_index"], - envelope_descriptor=reply["envelope_descriptor"], - envelope_hash=reply["envelope_hash"] - ) - - - async def read_channel(self, channel_id: int, message_box_index: "bytes|None" = None, - reply_index: "int|None" = None) -> ReadChannelReply: + async def read_channel(self, channel_id:int, message_id:"bytes|None"=None, reply_index:"int|None"=None) -> "Tuple[bytes,bytes,int|None]": """ - Prepares a read query for a Pigeonhole channel. + Prepare a read query for a pigeonhole channel and return the SendMessage payload, next MessageBoxIndex, and used ReplyIndex. + The thin client must then call send_message with the returned payload to actually send the query. Args: - channel_id: The 16-bit channel ID. - message_box_index: Optional message box index for resuming from a specific position. - reply_index: Optional index of the reply to return. + channel_id (int): The 16-bit channel ID. + message_id (bytes, optional): The 16-byte message ID for correlation. If None, generates a new one. + reply_index (int, optional): The index of the reply to return. If None, defaults to 0. Returns: - ReadChannelReply: Reply containing send_message_payload and other metadata. + tuple: (send_message_payload, next_message_index, used_reply_index) where: + - send_message_payload is the prepared payload for send_message + - next_message_index is the position to use after successful read + - used_reply_index is the reply index that was used (or None if not specified) Raises: Exception: If the read preparation fails. """ - query_id = self.new_query_id() + if message_id is None: + message_id = self.new_message_id() request_data = { "channel_id": channel_id, - "query_id": query_id + "message_id": message_id } - if message_box_index is not None: - request_data["message_box_index"] = message_box_index - if reply_index is not None: request_data["reply_index"] = reply_index @@ -1259,342 +1604,879 @@ async def read_channel(self, channel_id: int, message_box_index: "bytes|None" = "read_channel": request_data } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: - reply = await self._send_and_wait(query_id=query_id, request=request) + # Clear previous reply data and reset event + self.channel_reply_data = None + self.channel_reply_event.clear() + + await self._send_all(length_prefixed_request) + self.logger.info(f"ReadChannel request sent for message_id {message_id.hex()[:16]}...") + + # Wait for ReadChannelReply via the background worker + await self.channel_reply_event.wait() + + if self.channel_reply_data and self.channel_reply_data.get("read_channel_reply"): + reply = self.channel_reply_data["read_channel_reply"] + error_code = reply.get("error_code", 0) + if error_code != 0: + error_msg = thin_client_error_to_string(error_code) + raise Exception(f"ReadChannel failed: {error_msg} (error code {error_code})") + + used_reply_index = reply.get("reply_index") + return reply["send_message_payload"], reply["next_message_index"], used_reply_index + else: + raise Exception("No read_channel_reply received") + except Exception as e: self.logger.error(f"Error preparing read from channel: {e}") raise - return ReadChannelReply( - send_message_payload=reply["send_message_payload"], - current_message_index=reply["current_message_index"], - next_message_index=reply["next_message_index"], - reply_index=reply.get("reply_index"), - envelope_descriptor=reply["envelope_descriptor"], - envelope_hash=reply["envelope_hash"] - ) + async def read_channel_with_retry(self, channel_id: int, dest_node: bytes, dest_queue: bytes, + max_retries: int = 2) -> bytes: + """ + Send a read query for a pigeonhole channel with automatic reply index retry. + It first tries reply index 0 up to max_retries times, and if that fails, + it tries reply index 1 up to max_retries times. + This method handles the common case where the courier has cached replies at different indices + and accounts for timing issues where messages may not have propagated yet. + This method requires mixnet connectivity and will fail in offline mode. + The method generates its own message ID and matches replies for correct correlation. + + Args: + channel_id (int): The 16-bit channel ID. + dest_node (bytes): Destination node identity hash. + dest_queue (bytes): Destination recipient queue ID. + max_retries (int): Maximum number of attempts per reply index (default: 2). + Returns: + bytes: The received payload from the channel. - async def resume_write_channel(self, write_cap: bytes, message_box_index: "bytes|None" = None) -> int: + Raises: + RuntimeError: If in offline mode (daemon not connected to mixnet). + Exception: If all retry attempts fail. """ - Resumes a write channel from a previous session. + # Check if we're in offline mode + if not self._is_connected: + raise RuntimeError("cannot send channel query in offline mode - daemon not connected to mixnet") + + # Generate a new message ID for this read operation + message_id = self.new_message_id() + self.logger.debug(f"read_channel_with_retry: Generated message_id {message_id.hex()[:16]}...") + + reply_indices = [0, 1] + + for reply_index in reply_indices: + self.logger.debug(f"read_channel_with_retry: Trying reply index {reply_index}") + + # Prepare the read query for this reply index + try: + # read_channel expects int channel_id + payload, _, _ = await self.read_channel(channel_id, message_id, reply_index) + except Exception as e: + self.logger.error(f"Failed to prepare read query with reply index {reply_index}: {e}") + continue + + # Try this reply index up to max_retries times + for attempt in range(1, max_retries + 1): + self.logger.debug(f"read_channel_with_retry: Reply index {reply_index} attempt {attempt}/{max_retries}") + + try: + # Send the channel query and wait for matching reply + result = await self._send_channel_query_and_wait_for_message_id( + channel_id, payload, dest_node, dest_queue, message_id, is_read_operation=True + ) + + # For read operations, we should only consider it successful if we got actual data + if len(result) > 0: + self.logger.debug(f"read_channel_with_retry: Reply index {reply_index} succeeded on attempt {attempt} with {len(result)} bytes") + return result + else: + self.logger.debug(f"read_channel_with_retry: Reply index {reply_index} attempt {attempt} got empty payload, treating as failure") + raise Exception("received empty payload - message not available yet") + + except Exception as e: + self.logger.debug(f"read_channel_with_retry: Reply index {reply_index} attempt {attempt} failed: {e}") + + # If this was the last attempt for this reply index, move to next reply index + if attempt == max_retries: + break + + # Add a delay between retries to allow for message propagation (match Go client) + await asyncio.sleep(5.0) + + # All reply indices and attempts failed + self.logger.debug(f"read_channel_with_retry: All reply indices failed after {max_retries} attempts each") + raise Exception("all reply indices failed after multiple attempts") + + async def _send_channel_query_and_wait_for_message_id(self, channel_id: int, payload: bytes, + dest_node: bytes, dest_queue: bytes, + expected_message_id: bytes, is_read_operation: bool = True) -> bytes: + """ + Send a channel query and wait for a reply with the specified message ID. + This method matches replies by message ID to ensure correct correlation. Args: - write_cap: The write capability bytes. - message_box_index: Optional message box index for resuming from a specific position. + channel_id (int): The channel ID for the query + payload (bytes): The prepared query payload + dest_node (bytes): Destination node identity hash + dest_queue (bytes): Destination recipient queue ID + expected_message_id (bytes): The message ID to match replies against + is_read_operation (bool): Whether this is a read operation (affects empty payload handling) Returns: - int: The channel ID. + bytes: The received payload Raises: - Exception: If the channel resumption fails. + Exception: If the query fails or times out """ - query_id = self.new_query_id() + # Store the expected message ID for reply matching + self._expected_message_id = expected_message_id + self._received_reply_payload = None + self._reply_received_for_message_id = asyncio.Event() + self._reply_received_for_message_id.clear() - request_data = { - "query_id": query_id, - "write_cap": write_cap - } + try: + # Send the channel query with the specific expected_message_id + actual_message_id = await self.send_channel_query(channel_id, payload, dest_node, dest_queue, expected_message_id) - if message_box_index is not None: - request_data["message_box_index"] = message_box_index + # Verify that the message ID matches what we expected + assert actual_message_id == expected_message_id, f"Message ID mismatch: expected {expected_message_id.hex()}, got {actual_message_id.hex()}" + + # Wait for the matching reply with timeout + await asyncio.wait_for(self._reply_received_for_message_id.wait(), timeout=120.0) + + # Check if we got a valid payload + if self._received_reply_payload is None: + raise Exception("no reply received for message ID") + + # Handle empty payload based on operation type + if len(self._received_reply_payload) == 0: + if is_read_operation: + raise Exception("message not available yet - empty payload") + else: + return b"" # Empty payload is success for write operations + + return self._received_reply_payload + + except asyncio.TimeoutError: + raise Exception("timeout waiting for reply") + finally: + # Clean up + self._expected_message_id = None + self._received_reply_payload = None + + async def close_channel(self, channel_id: int) -> None: + """ + Close a pigeonhole channel and clean up its resources. + This helps avoid running out of channel IDs by properly releasing them. + This operation is infallible - it sends the close request and returns immediately. + Args: + channel_id (int): The 16-bit channel ID to close. + + Raises: + Exception: If the socket send operation fails. + """ request = { - "resume_write_channel": request_data + "close_channel": { + "channel_id": channel_id + } } + cbor_request = cbor2.dumps(request) + length_prefix = struct.pack('>I', len(cbor_request)) + length_prefixed_request = length_prefix + cbor_request + try: - reply = await self._send_and_wait(query_id=query_id, request=request) + # CloseChannel is infallible - fire and forget, no reply expected + await self._send_all(length_prefixed_request) + self.logger.info(f"CloseChannel request sent for channel {channel_id}.") except Exception as e: - self.logger.error(f"Error resuming write channel: {e}") + self.logger.error(f"Error sending close channel request: {e}") raise - return reply["channel_id"] + # New Pigeonhole API methods - async def resume_read_channel(self, read_cap: bytes, next_message_index: "bytes|None" = None, - reply_index: "int|None" = None) -> int: + async def new_keypair(self, seed: bytes) -> "Tuple[bytes, bytes, bytes]": """ - Resumes a read channel from a previous session. + Creates a new keypair for use with the Pigeonhole protocol. + + This method generates a WriteCap and ReadCap from the provided seed using + the BACAP (Blinding-and-Capability) protocol. The WriteCap should be stored + securely for writing messages, while the ReadCap can be shared with others + to allow them to read messages. Args: - read_cap: The read capability bytes. - next_message_index: Optional next message index for resuming from a specific position. - reply_index: Optional reply index. + seed: 32-byte seed used to derive the keypair. Returns: - int: The channel ID. + tuple: (write_cap, read_cap, first_message_index) where: + - write_cap is the write capability for sending messages + - read_cap is the read capability that can be shared with recipients + - first_message_index is the first message index to use when writing Raises: - Exception: If the channel resumption fails. + Exception: If the keypair creation fails. + ValueError: If seed is not exactly 32 bytes. + + Example: + >>> import os + >>> seed = os.urandom(32) + >>> write_cap, read_cap, first_index = await client.new_keypair(seed) + >>> # Share read_cap with Bob so he can read messages + >>> # Store write_cap for sending messages """ + if len(seed) != 32: + raise ValueError("seed must be exactly 32 bytes") + query_id = self.new_query_id() - request_data = { - "query_id": query_id, - "read_cap": read_cap + request = { + "new_keypair": { + "query_id": query_id, + "seed": seed + } } - if next_message_index is not None: - request_data["next_message_index"] = next_message_index + try: + reply = await self._send_and_wait(query_id=query_id, request=request) + except Exception as e: + self.logger.error(f"Error creating keypair: {e}") + raise - if reply_index is not None: - request_data["reply_index"] = reply_index + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"new_keypair failed: {error_msg}") + + return reply["write_cap"], reply["read_cap"], reply["first_message_index"] + + async def encrypt_read(self, read_cap: bytes, message_box_index: bytes) -> "Tuple[bytes, bytes, bytes, bytes, int]": + """ + Encrypts a read operation for a given read capability. + + This method prepares an encrypted read request that can be sent to the + courier service to retrieve a message from a pigeonhole box. The returned + ciphertext should be sent via start_resending_encrypted_message. + + Args: + read_cap: Read capability that grants access to the channel. + message_box_index: Starting read position for the channel. + + Returns: + tuple: (message_ciphertext, next_message_index, envelope_descriptor, envelope_hash) where: + - message_ciphertext is the encrypted message to send to courier + - next_message_index is the next message index for subsequent reads + - envelope_descriptor is for decrypting the reply + - envelope_hash is the hash of the courier envelope + + Raises: + Exception: If the encryption fails. + + Example: + >>> ciphertext, next_index, env_desc, env_hash = await client.encrypt_read( + ... read_cap, message_box_index) + >>> # Send ciphertext via start_resending_encrypted_message + """ + query_id = self.new_query_id() request = { - "resume_read_channel": request_data + "encrypt_read": { + "query_id": query_id, + "read_cap": read_cap, + "message_box_index": message_box_index + } } try: reply = await self._send_and_wait(query_id=query_id, request=request) except Exception as e: - self.logger.error(f"Error resuming read channel: {e}") + self.logger.error(f"Error encrypting read: {e}") raise - if not reply["channel_id"]: - self.logger.error(f"Error resuming read channel: no channel_id") - raise Exception("TODO resume_read_channel error", reply) - return reply["channel_id"] + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"encrypt_read failed: {error_msg}") - async def resume_write_channel_query(self, write_cap: bytes, message_box_index: bytes, - envelope_descriptor: bytes, envelope_hash: bytes) -> int: + return ( + reply["message_ciphertext"], + reply["next_message_index"], + reply["envelope_descriptor"], + reply["envelope_hash"] + ) + + async def encrypt_write(self, plaintext: bytes, write_cap: bytes, message_box_index: bytes) -> "Tuple[bytes, bytes, bytes]": """ - Resumes a write channel with a specific query state. - This method provides more granular resumption control than resume_write_channel - by allowing the application to resume from a specific query state, including - the envelope descriptor and hash. This is useful when resuming from a partially - completed write operation that was interrupted during transmission. + Encrypts a write operation for a given write capability. + + This method prepares an encrypted write request that can be sent to the + courier service to store a message in a pigeonhole box. The returned + ciphertext should be sent via start_resending_encrypted_message. Args: - write_cap: The write capability bytes. - message_box_index: Message box index for resuming from a specific position (WriteChannelReply.current_message_index). - envelope_descriptor: Envelope descriptor from previous query (WriteChannelReply.envelope_descriptor). - envelope_hash: Envelope hash from previous query (WriteChannelReply.envelope_hash). + plaintext: The plaintext message to encrypt. + write_cap: Write capability that grants access to the channel. + message_box_index: Starting write position for the channel. Returns: - int: The channel ID. + tuple: (message_ciphertext, envelope_descriptor, envelope_hash) where: + - message_ciphertext is the encrypted message to send to courier + - envelope_descriptor is for decrypting the reply + - envelope_hash is the hash of the courier envelope Raises: - Exception: If the channel resumption fails. + Exception: If the encryption fails. + + Example: + >>> plaintext = b"Hello, Bob!" + >>> ciphertext, env_desc, env_hash = await client.encrypt_write( + ... plaintext, write_cap, message_box_index) + >>> # Send ciphertext via start_resending_encrypted_message """ query_id = self.new_query_id() request = { - "resume_write_channel_query": { + "encrypt_write": { "query_id": query_id, + "plaintext": plaintext, "write_cap": write_cap, - "message_box_index": message_box_index, - "envelope_descriptor": envelope_descriptor, - "envelope_hash": envelope_hash + "message_box_index": message_box_index } } try: reply = await self._send_and_wait(query_id=query_id, request=request) except Exception as e: - self.logger.error(f"Error resuming write channel query: {e}") + self.logger.error(f"Error encrypting write: {e}") raise - return reply["channel_id"] + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"encrypt_write failed: {error_msg}") - async def resume_read_channel_query(self, read_cap: bytes, next_message_index: bytes, - reply_index: "int|None", envelope_descriptor: bytes, - envelope_hash: bytes) -> int: - """ - Resumes a read channel with a specific query state. - This method provides more granular resumption control than resume_read_channel - by allowing the application to resume from a specific query state, including - the envelope descriptor and hash. This is useful when resuming from a partially - completed read operation that was interrupted during transmission. + return ( + reply["message_ciphertext"], + reply["envelope_descriptor"], + reply["envelope_hash"] + ) + + async def start_resending_encrypted_message( + self, + read_cap: "bytes|None", + write_cap: "bytes|None", + next_message_index: "bytes|None", + reply_index: "int|None", + envelope_descriptor: bytes, + message_ciphertext: bytes, + envelope_hash: bytes + ) -> bytes: + """ + Starts resending an encrypted message via ARQ. + + This method initiates automatic repeat request (ARQ) for an encrypted message, + which will be resent periodically until either: + - A reply is received from the courier + - The message is cancelled via cancel_resending_encrypted_message + - The client is shut down + + This is used for both read and write operations in the new Pigeonhole API. + + The daemon implements a finite state machine (FSM) for handling the stop-and-wait ARQ protocol: + - For write operations (write_cap != None, read_cap == None): + The method waits for an ACK from the courier and returns immediately. + - For read operations (read_cap != None, write_cap == None): + The method waits for an ACK from the courier, then the daemon automatically + sends a new SURB to request the payload, and this method waits for the payload. + The daemon performs all decryption (MKEM envelope + BACAP payload) and returns + the fully decrypted plaintext. Args: - read_cap: The read capability bytes. - next_message_index: Next message index for resuming from a specific position. - reply_index: Optional reply index. - envelope_descriptor: Envelope descriptor from previous query. - envelope_hash: Envelope hash from previous query. + read_cap: Read capability (can be None for write operations, required for reads). + write_cap: Write capability (can be None for read operations, required for writes). + next_message_index: Next message index for BACAP decryption (required for reads). + reply_index: Index of the reply to use (typically 0 or 1). + envelope_descriptor: Serialized envelope descriptor for MKEM decryption. + message_ciphertext: MKEM-encrypted message to send (from encrypt_read or encrypt_write). + envelope_hash: Hash of the courier envelope. Returns: - int: The channel ID. + bytes: Fully decrypted plaintext from the reply (for reads) or empty (for writes). Raises: - Exception: If the channel resumption fails. + Exception: If the operation fails. Check error_code for specific errors. + + Example: + >>> plaintext = await client.start_resending_encrypted_message( + ... read_cap, None, next_index, reply_idx, env_desc, ciphertext, env_hash) + >>> print(f"Received: {plaintext}") """ query_id = self.new_query_id() - request_data = { - "query_id": query_id, - "read_cap": read_cap, - "next_message_index": next_message_index, - "envelope_descriptor": envelope_descriptor, - "envelope_hash": envelope_hash - } - - if reply_index is not None: - request_data["reply_index"] = reply_index - request = { - "resume_read_channel_query": request_data + "start_resending_encrypted_message": { + "query_id": query_id, + "read_cap": read_cap, + "write_cap": write_cap, + "next_message_index": next_message_index, + "reply_index": reply_index, + "envelope_descriptor": envelope_descriptor, + "message_ciphertext": message_ciphertext, + "envelope_hash": envelope_hash + } } try: reply = await self._send_and_wait(query_id=query_id, request=request) except Exception as e: - self.logger.error(f"Error resuming read channel query: {e}") + self.logger.error(f"Error starting resending encrypted message: {e}") raise - return reply["channel_id"] + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"start_resending_encrypted_message failed: {error_msg}") - async def get_courier_destination(self) -> "Tuple[bytes, bytes]": + return reply.get("plaintext", b"") + + async def cancel_resending_encrypted_message(self, envelope_hash: bytes) -> None: """ - Gets the courier service destination for channel queries. - This is a convenience method that combines get_service("courier") - and to_destination() to get the destination node and queue for - use with send_channel_query and send_channel_query_await_reply. + Cancels ARQ resending for an encrypted message. - Returns: - tuple: (dest_node, dest_queue) where: - - dest_node is the destination node identity hash - - dest_queue is the destination recipient queue ID + This method stops the automatic repeat request (ARQ) for a previously started + encrypted message transmission. This is useful when: + - A reply has been received through another channel + - The operation should be aborted + - The message is no longer needed + + Args: + envelope_hash: Hash of the courier envelope to cancel. Raises: - Exception: If the courier service is not found. + Exception: If the cancellation fails. + + Example: + >>> await client.cancel_resending_encrypted_message(env_hash) """ - courier_service = self.get_service("courier") - dest_node, dest_queue = courier_service.to_destination() - return dest_node, dest_queue + query_id = self.new_query_id() + + request = { + "cancel_resending_encrypted_message": { + "query_id": query_id, + "envelope_hash": envelope_hash + } + } + + try: + reply = await self._send_and_wait(query_id=query_id, request=request) + except Exception as e: + self.logger.error(f"Error cancelling resending encrypted message: {e}") + raise + + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"cancel_resending_encrypted_message failed: {error_msg}") - async def send_channel_query_await_reply(self, channel_id: int, payload: bytes, - dest_node: bytes, dest_queue: bytes, - message_id: bytes, timeout_seconds=30.0) -> bytes: + async def next_message_box_index(self, message_box_index: bytes) -> bytes: """ - Sends a channel query and waits for the reply. - This combines send_channel_query with event handling to wait for the response. + Increments a MessageBoxIndex using the BACAP NextIndex method. + + This method is used when sending multiple messages to different mailboxes using + the same WriteCap or ReadCap. It properly advances the cryptographic state by: + - Incrementing the Idx64 counter + - Deriving new encryption and blinding keys using HKDF + - Updating the HKDF state for the next iteration + + The daemon handles the cryptographic operations internally, ensuring correct + BACAP protocol implementation. Args: - channel_id: The 16-bit channel ID. - payload: The prepared query payload. - dest_node: Destination node identity hash. - dest_queue: Destination recipient queue ID. - message_id: Message ID for reply correlation. - timeout_seconds: float (seconds to wait), None for indefinite wait + message_box_index: Current message box index to increment (as bytes). Returns: - bytes: The received payload from the channel. + bytes: The next message box index. Raises: - ThinClientOfflineError: If in offline mode (daemon not connected to mixnet). - Exception: If the query fails or times out. + Exception: If the increment operation fails. + + Example: + >>> current_index = first_message_index + >>> next_index = await client.next_message_box_index(current_index) + >>> # Use next_index for the next message """ - # Check if we're in offline mode - if not self._is_connected: - raise ThinClientOfflineError("cannot send_channel_query_await_reply in offline mode - daemon not connected to mixnet") + query_id = self.new_query_id() - # Create an event for this message_id - if message_id not in self.pending_channel_message_queries: - event = asyncio.Event() - self.pending_channel_message_queries[message_id] = event + request = { + "next_message_box_index": { + "query_id": query_id, + "message_box_index": message_box_index + } + } try: - # Send the channel query - await self.send_channel_query(channel_id, payload=payload, dest_node=dest_node, dest_queue=dest_queue, message_id=message_id) + reply = await self._send_and_wait(query_id=query_id, request=request) + except Exception as e: + self.logger.error(f"Error incrementing message box index: {e}") + raise - # Wait for the reply with timeout - await asyncio.wait_for(event.wait(), timeout=timeout_seconds) + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"next_message_box_index failed: {error_msg}") - # Get the response payload - if message_id not in self.channel_message_query_responses: - raise Exception("No channel query reply received within timeout_seconds") + return reply.get("next_message_box_index") - response_payload = self.channel_message_query_responses[message_id] + async def start_resending_copy_command( + self, + write_cap: bytes, + courier_identity_hash: "bytes|None" = None, + courier_queue_id: "bytes|None" = None + ) -> None: + """ + Starts resending a copy command to a courier via ARQ. - # Check if it's an error message - if isinstance(response_payload, bytes) and response_payload.startswith(b"Channel query"): - raise Exception(response_payload.decode()) + This method instructs a courier to read data from a temporary channel + (identified by the write_cap) and write it to the destination channel. + The command is automatically retransmitted until acknowledged. - return response_payload + If courier_identity_hash and courier_queue_id are both provided, + the copy command is sent to that specific courier. Otherwise, a + random courier is selected. - except asyncio.TimeoutError: - raise Exception("Timeout waiting for channel query reply") - finally: - # Clean up - self.pending_channel_message_queries.pop(message_id, None) - self.channel_message_query_responses.pop(message_id, None) + Args: + write_cap: Write capability for the temporary channel containing the data. + courier_identity_hash: Optional identity hash of a specific courier to use. + courier_queue_id: Optional queue ID for the specified courier. Must be set + if courier_identity_hash is set. - async def send_channel_query(self, channel_id: int, *, payload: bytes, dest_node: bytes, - dest_queue: bytes, message_id: bytes) -> None: + Raises: + Exception: If the operation fails. + + Example: + >>> # Send copy command to a random courier + >>> await client.start_resending_copy_command(temp_write_cap) + >>> # Send copy command to a specific courier + >>> await client.start_resending_copy_command( + ... temp_write_cap, courier_identity_hash, courier_queue_id) """ - Sends a prepared channel query to the mixnet without waiting for a reply. + query_id = self.new_query_id() + + request_data = { + "query_id": query_id, + "write_cap": write_cap, + } + + if courier_identity_hash is not None: + request_data["courier_identity_hash"] = courier_identity_hash + if courier_queue_id is not None: + request_data["courier_queue_id"] = courier_queue_id + + request = { + "start_resending_copy_command": request_data + } + + try: + reply = await self._send_and_wait(query_id=query_id, request=request) + except Exception as e: + self.logger.error(f"Error starting resending copy command: {e}") + raise + + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"start_resending_copy_command failed: {error_msg}") + + async def cancel_resending_copy_command(self, write_cap_hash: bytes) -> None: + """ + Cancels ARQ resending for a copy command. + + This method stops the automatic repeat request (ARQ) for a previously started + copy command. Use this when: + - The copy operation should be aborted + - The operation is no longer needed + - You want to clean up pending ARQ operations Args: - channel_id: The 16-bit channel ID. - payload: Channel query payload prepared by write_channel or read_channel. - dest_node: Destination node identity hash. - dest_queue: Destination recipient queue ID. - message_id: Message ID for reply correlation. + write_cap_hash: Hash of the WriteCap used in start_resending_copy_command. Raises: - ThinClientOfflineError: If in offline mode (daemon not connected to mixnet). - """ - # Check if we're in offline mode - if not self._is_connected: - raise ThinClientOfflineError("cannot send_channel_query while not is_connected() - daemon not connected to mixnet") + Exception: If the cancellation fails. - if not isinstance(payload, bytes): - self.logger.error("send_channel_query: type error: payload= must be bytes()") - payload = payload.encode('utf-8') + Example: + >>> await client.cancel_resending_copy_command(write_cap_hash) + """ + query_id = self.new_query_id() - # Create the SendChannelQuery structure (matches Rust implementation) - send_channel_query = { - "message_id": message_id, - "channel_id": channel_id, - "destination_id_hash": dest_node, - "recipient_queue_id": dest_queue, - "payload": payload, + request = { + "cancel_resending_copy_command": { + "query_id": query_id, + "write_cap_hash": write_cap_hash + } } - # Wrap in the Request structure + try: + reply = await self._send_and_wait(query_id=query_id, request=request) + except Exception as e: + self.logger.error(f"Error cancelling resending copy command: {e}") + raise + + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"cancel_resending_copy_command failed: {error_msg}") + + async def create_courier_envelopes_from_payload( + self, + query_id: bytes, + stream_id: bytes, + payload: bytes, + dest_write_cap: bytes, + dest_start_index: bytes, + is_last: bool + ) -> "List[bytes]": + """ + Creates multiple CourierEnvelopes from a payload of any size. + + The payload is automatically chunked and each chunk is wrapped in a + CourierEnvelope. Each returned chunk is a serialized CopyStreamElement + ready to be written to a box. + + Multiple calls can be made with the same stream_id to build up a stream + incrementally. The first call creates a new encoder (first element gets + IsStart=true). The final call should have is_last=True (last element + gets IsFinal=true). + + Args: + query_id: 16-byte query identifier for correlating requests and replies. + stream_id: 16-byte identifier for the encoder instance. All calls for + the same stream must use the same stream ID. + payload: The data to be encoded into courier envelopes. + dest_write_cap: Write capability for the destination channel. + dest_start_index: Starting index in the destination channel. + is_last: Whether this is the last payload in the sequence. When True, + the final CopyStreamElement will have IsFinal=true and the + encoder instance will be removed. + + Returns: + List[bytes]: List of serialized CopyStreamElements, one per chunk. + + Raises: + Exception: If the envelope creation fails. + + Example: + >>> query_id = client.new_query_id() + >>> stream_id = client.new_stream_id() + >>> envelopes = await client.create_courier_envelopes_from_payload( + ... query_id, stream_id, payload, dest_write_cap, dest_start_index, is_last=True) + >>> for env in envelopes: + ... # Write each envelope to the copy stream + ... pass + """ + request = { - "send_channel_query": send_channel_query + "create_courier_envelopes_from_payload": { + "query_id": query_id, + "stream_id": stream_id, + "payload": payload, + "dest_write_cap": dest_write_cap, + "dest_start_index": dest_start_index, + "is_last": is_last + } } - cbor_request = cbor2.dumps(request) - length_prefix = struct.pack('>I', len(cbor_request)) - length_prefixed_request = length_prefix + cbor_request - try: - await self._send_all(length_prefixed_request) - self.logger.info(f"Channel query sent successfully for channel {channel_id}.") + reply = await self._send_and_wait(query_id=query_id, request=request) except Exception as e: - self.logger.error(f"Error sending channel query: {e}") + self.logger.error(f"Error creating courier envelopes from payload: {e}") raise - async def close_channel(self, channel_id: int) -> None: + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"create_courier_envelopes_from_payload failed: {error_msg}") + + return reply.get("envelopes", []) + + async def create_courier_envelopes_from_payloads( + self, + stream_id: bytes, + destinations: "List[Dict[str, Any]]", + is_last: bool + ) -> "List[bytes]": """ - Closes a pigeonhole channel and cleans up its resources. - This helps avoid running out of channel IDs by properly releasing them. - This operation is infallible - it sends the close request and returns immediately. + Creates CourierEnvelopes from multiple payloads going to different destinations. + + This is more space-efficient than calling create_courier_envelopes_from_payload + multiple times because envelopes from different destinations are packed + together in the copy stream without wasting space. + + Multiple calls can be made with the same stream_id to build up a stream + incrementally. The first call creates a new encoder (first element gets + IsStart=true). The final call should have is_last=True (last element + gets IsFinal=true). Args: - channel_id: The 16-bit channel ID to close. + stream_id: 16-byte identifier for the encoder instance. All calls for + the same stream must use the same stream ID. + destinations: List of destination payloads, each a dict with: + - "payload": bytes - The data to be written + - "write_cap": bytes - Write capability for destination + - "start_index": bytes - Starting index in destination + is_last: Whether this is the last set of payloads in the sequence. + When True, the final CopyStreamElement will have IsFinal=true + and the encoder instance will be removed. + + Returns: + List[bytes]: List of serialized CopyStreamElements containing all + courier envelopes from all destinations packed efficiently. Raises: - Exception: If the socket send operation fails. + Exception: If the envelope creation fails. + + Example: + >>> stream_id = client.new_stream_id() + >>> destinations = [ + ... {"payload": data1, "write_cap": cap1, "start_index": idx1}, + ... {"payload": data2, "write_cap": cap2, "start_index": idx2}, + ... ] + >>> envelopes = await client.create_courier_envelopes_from_payloads( + ... stream_id, destinations, is_last=True) """ + query_id = self.new_query_id() request = { - "close_channel": { - "channel_id": channel_id + "create_courier_envelopes_from_payloads": { + "query_id": query_id, + "stream_id": stream_id, + "destinations": destinations, + "is_last": is_last } } - cbor_request = cbor2.dumps(request) - length_prefix = struct.pack('>I', len(cbor_request)) - length_prefixed_request = length_prefix + cbor_request - try: - # CloseChannel is infallible - fire and forget, no reply expected - await self._send_all(length_prefixed_request) + reply = await self._send_and_wait(query_id=query_id, request=request) except Exception as e: - self.logger.error(f"Error sending close channel request: {e}") + self.logger.error(f"Error creating courier envelopes from payloads: {e}") raise - self.logger.info(f"CloseChannel request sent for channel {channel_id}.") + + if reply.get('error_code', 0) != THIN_CLIENT_SUCCESS: + error_msg = thin_client_error_to_string(reply['error_code']) + raise Exception(f"create_courier_envelopes_from_payloads failed: {error_msg}") + + return reply.get("envelopes", []) + + async def tombstone_box( + self, + geometry: "PigeonholeGeometry", + write_cap: bytes, + box_index: bytes + ) -> None: + """ + Tombstone a single pigeonhole box by overwriting it with zeros. + + This method overwrites the specified box with a zero-filled payload, + effectively deleting its contents. The tombstone is sent via ARQ + for reliable delivery. + + Args: + geometry: Pigeonhole geometry defining payload size. + write_cap: Write capability for the box. + box_index: Index of the box to tombstone. + + Raises: + ValueError: If any argument is None or geometry is invalid. + Exception: If the encrypt or send operation fails. + + Example: + >>> geometry = PigeonholeGeometry(max_plaintext_payload_length=1024, nike_name="x25519") + >>> await client.tombstone_box(geometry, write_cap, box_index) + """ + if geometry is None: + raise ValueError("geometry cannot be None") + geometry.validate() + if write_cap is None: + raise ValueError("write_cap cannot be None") + if box_index is None: + raise ValueError("box_index cannot be None") + + # Create zero-filled tombstone payload + tomb = bytes(geometry.max_plaintext_payload_length) + + # Encrypt the tombstone for the target box + message_ciphertext, envelope_descriptor, envelope_hash = await self.encrypt_write( + tomb, write_cap, box_index + ) + + # Send the tombstone via ARQ + await self.start_resending_encrypted_message( + None, # read_cap + write_cap, + None, # next_message_index + None, # reply_index + envelope_descriptor, + message_ciphertext, + envelope_hash + ) + + async def tombstone_range( + self, + geometry: "PigeonholeGeometry", + write_cap: bytes, + start: bytes, + max_count: int + ) -> "Dict[str, Any]": + """ + Tombstone a range of pigeonhole boxes starting from a given index. + + This method tombstones up to max_count boxes, starting from the + specified box index and advancing through consecutive indices. + + If an error occurs during the operation, a partial result is returned + containing the number of boxes successfully tombstoned and the next + index that was being processed. + + Args: + geometry: Pigeonhole geometry defining payload size. + write_cap: Write capability for the boxes. + start: Starting MessageBoxIndex. + max_count: Maximum number of boxes to tombstone. + + Returns: + Dict[str, Any]: A dictionary with: + - "tombstoned" (int): Number of boxes successfully tombstoned. + - "next" (bytes): The next MessageBoxIndex after the last processed. + + Raises: + ValueError: If geometry, write_cap, or start is None, or if geometry is invalid. + + Example: + >>> geometry = PigeonholeGeometry(max_plaintext_payload_length=1024, nike_name="x25519") + >>> result = await client.tombstone_range(geometry, write_cap, start_index, 10) + >>> print(f"Tombstoned {result['tombstoned']} boxes") + """ + if geometry is None: + raise ValueError("geometry cannot be None") + geometry.validate() + if write_cap is None: + raise ValueError("write_cap cannot be None") + if start is None: + raise ValueError("start index cannot be None") + if max_count == 0: + return {"tombstoned": 0, "next": start} + + cur = start + done = 0 + + while done < max_count: + try: + await self.tombstone_box(geometry, write_cap, cur) + except Exception as e: + self.logger.error(f"Error tombstoning box at index {done}: {e}") + return {"tombstoned": done, "next": cur, "error": str(e)} + + done += 1 + + try: + cur = await self.next_message_box_index(cur) + except Exception as e: + self.logger.error(f"Error getting next index after tombstoning: {e}") + return {"tombstoned": done, "next": cur, "error": str(e)} + + return {"tombstoned": done, "next": cur} diff --git a/pyproject.toml b/pyproject.toml index ef3998f..da8f132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,5 +39,6 @@ test = [ "pytest", "pytest-cov", "pytest-asyncio", + "pytest-timeout", ] diff --git a/pytest.ini b/pytest.ini index c372928..f0a59b3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -24,6 +24,8 @@ addopts = --durations=10 # Timeout configuration +# Default timeout per test: 5 minutes (300 seconds) for unit tests +# Integration tests override this with --timeout flag in CI timeout = 300 timeout_method = thread diff --git a/src/lib.rs b/src/lib.rs index e35c8c2..4200f02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -221,6 +221,51 @@ pub const THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION: u8 = 12; /// propagated to replicas. pub const THIN_CLIENT_PROPAGATION_ERROR: u8 = 13; +/// ThinClientErrorInvalidWriteCapability indicates that the provided write +/// capability is invalid. +pub const THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY: u8 = 14; + +/// ThinClientErrorInvalidReadCapability indicates that the provided read +/// capability is invalid. +pub const THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY: u8 = 15; + +/// ThinClientErrorInvalidResumeWriteChannelRequest indicates that the provided +/// ResumeWriteChannel request is invalid. +pub const THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST: u8 = 16; + +/// ThinClientErrorInvalidResumeReadChannelRequest indicates that the provided +/// ResumeReadChannel request is invalid. +pub const THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST: u8 = 17; + +/// ThinClientImpossibleHashError indicates that the provided hash is impossible +/// to compute, such as when the hash of a write capability is provided but +/// the write capability itself is not provided. +pub const THIN_CLIENT_IMPOSSIBLE_HASH_ERROR: u8 = 18; + +/// ThinClientImpossibleNewWriteCapError indicates that the daemon was unable +/// to create a new write capability. +pub const THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR: u8 = 19; + +/// ThinClientImpossibleNewStatefulWriterError indicates that the daemon was unable +/// to create a new stateful writer. +pub const THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR: u8 = 20; + +/// ThinClientCapabilityAlreadyInUse indicates that the provided capability +/// is already in use. +pub const THIN_CLIENT_CAPABILITY_ALREADY_IN_USE: u8 = 21; + +/// ThinClientErrorMKEMDecryptionFailed indicates that MKEM decryption failed. +/// This occurs when the MKEM envelope cannot be decrypted with any of the replica keys. +pub const THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED: u8 = 22; + +/// ThinClientErrorBACAPDecryptionFailed indicates that BACAP decryption failed. +/// This occurs when the BACAP payload cannot be decrypted or signature verification fails. +pub const THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED: u8 = 23; + +/// ThinClientErrorStartResendingCancelled indicates that a StartResendingEncryptedMessage +/// or StartResendingCopyCommand operation was cancelled before completion. +pub const THIN_CLIENT_ERROR_START_RESENDING_CANCELLED: u8 = 24; + /// Converts a thin client error code to a human-readable string. /// This function provides consistent error message formatting across the thin client /// protocol and is used for logging and error reporting. @@ -240,6 +285,17 @@ pub fn thin_client_error_to_string(error_code: u8) -> &'static str { THIN_CLIENT_ERROR_DUPLICATE_CAPABILITY => "Duplicate capability", THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION => "Courier cache corruption", THIN_CLIENT_PROPAGATION_ERROR => "Propagation error", + THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY => "Invalid write capability", + THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY => "Invalid read capability", + THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST => "Invalid resume write channel request", + THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST => "Invalid resume read channel request", + THIN_CLIENT_IMPOSSIBLE_HASH_ERROR => "Impossible hash error", + THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR => "Failed to create new write capability", + THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR => "Failed to create new stateful writer", + THIN_CLIENT_CAPABILITY_ALREADY_IN_USE => "Capability already in use", + THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED => "MKEM decryption failed", + THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED => "BACAP decryption failed", + THIN_CLIENT_ERROR_START_RESENDING_CANCELLED => "Start resending cancelled", _ => "Unknown thin client error code", } } @@ -247,6 +303,7 @@ pub fn thin_client_error_to_string(error_code: u8) -> &'static str { use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; use std::fs; +use std::time::Duration; use serde::Deserialize; use serde_json::json; @@ -266,25 +323,271 @@ use log::{debug, error}; use crate::error::ThinClientError; -/// Reply from WriteChannel operation, matching Go WriteChannelReply -#[derive(Debug, Clone)] -pub struct WriteChannelReply { - pub send_message_payload: Vec, - pub current_message_index: Vec, - pub next_message_index: Vec, - pub envelope_descriptor: Vec, - pub envelope_hash: Vec, +// ======================================================================== +// Helper module for serializing Option> as CBOR byte strings +// ======================================================================== + +mod optional_bytes { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(value: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(bytes) => serde_bytes::serialize(bytes, serializer), + None => Option::<&[u8]>::None.serialize(serializer), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(|b| b.into_vec())) + } } -/// Reply from ReadChannel operation, matching Go ReadChannelReply -#[derive(Debug, Clone)] -pub struct ReadChannelReply { - pub send_message_payload: Vec, - pub current_message_index: Vec, - pub next_message_index: Vec, - pub reply_index: Option, - pub envelope_descriptor: Vec, - pub envelope_hash: Vec, +// ======================================================================== +// NEW Pigeonhole API Protocol Message Structs +// ======================================================================== + +/// Request to create a new keypair for the Pigeonhole protocol. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct NewKeypairRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + seed: Vec, +} + +/// Reply containing the generated keypair and first message index. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct NewKeypairReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + write_cap: Vec, + #[serde(with = "serde_bytes")] + read_cap: Vec, + #[serde(with = "serde_bytes")] + first_message_index: Vec, + error_code: u8, +} + +/// Request to encrypt a read operation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct EncryptReadRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + read_cap: Vec, + #[serde(with = "serde_bytes")] + message_box_index: Vec, +} + +/// Reply containing the encrypted read operation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct EncryptReadReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + message_ciphertext: Vec, + #[serde(with = "serde_bytes")] + next_message_index: Vec, + #[serde(with = "serde_bytes")] + envelope_descriptor: Vec, + #[serde(with = "serde_bytes")] + envelope_hash: Vec, + error_code: u8, +} + +/// Request to encrypt a write operation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct EncryptWriteRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + plaintext: Vec, + #[serde(with = "serde_bytes")] + write_cap: Vec, + #[serde(with = "serde_bytes")] + message_box_index: Vec, +} + +/// Reply containing the encrypted write operation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct EncryptWriteReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + message_ciphertext: Vec, + #[serde(with = "serde_bytes")] + envelope_descriptor: Vec, + #[serde(with = "serde_bytes")] + envelope_hash: Vec, + error_code: u8, +} + +/// Request to start resending an encrypted message via ARQ. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct StartResendingEncryptedMessageRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(skip_serializing_if = "Option::is_none", with = "optional_bytes")] + read_cap: Option>, + #[serde(skip_serializing_if = "Option::is_none", with = "optional_bytes")] + write_cap: Option>, + #[serde(skip_serializing_if = "Option::is_none", with = "optional_bytes")] + next_message_index: Option>, + reply_index: u8, + #[serde(with = "serde_bytes")] + envelope_descriptor: Vec, + #[serde(with = "serde_bytes")] + message_ciphertext: Vec, + #[serde(with = "serde_bytes")] + envelope_hash: Vec, +} + +/// Reply containing the plaintext from a resent encrypted message. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct StartResendingEncryptedMessageReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(default, with = "optional_bytes")] + plaintext: Option>, + error_code: u8, +} + +/// Request to cancel resending an encrypted message. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CancelResendingEncryptedMessageRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + envelope_hash: Vec, +} + +/// Reply confirming cancellation of resending. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CancelResendingEncryptedMessageReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + error_code: u8, +} + +/// Request to increment a MessageBoxIndex. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct NextMessageBoxIndexRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + message_box_index: Vec, +} + +/// Reply containing the incremented MessageBoxIndex. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct NextMessageBoxIndexReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + next_message_box_index: Vec, + error_code: u8, +} + +/// Request to start resending a copy command. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct StartResendingCopyCommandRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + write_cap: Vec, + #[serde(skip_serializing_if = "Option::is_none", default, with = "optional_bytes")] + courier_identity_hash: Option>, + #[serde(skip_serializing_if = "Option::is_none", default, with = "optional_bytes")] + courier_queue_id: Option>, +} + +/// Reply confirming start of copy command resending. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct StartResendingCopyCommandReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + error_code: u8, +} + +/// Request to cancel resending a copy command. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CancelResendingCopyCommandRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + write_cap_hash: Vec, +} + +/// Reply confirming cancellation of copy command resending. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CancelResendingCopyCommandReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + error_code: u8, +} + +/// Request to create courier envelopes from a payload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CreateCourierEnvelopesFromPayloadRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + stream_id: Vec, + #[serde(with = "serde_bytes")] + payload: Vec, + #[serde(with = "serde_bytes")] + dest_write_cap: Vec, + #[serde(with = "serde_bytes")] + dest_start_index: Vec, + is_last: bool, +} + +/// Reply containing the created courier envelopes. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CreateCourierEnvelopesFromPayloadReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + envelopes: Vec, + error_code: u8, +} + +/// A destination for creating courier envelopes. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct EnvelopeDestination { + #[serde(with = "serde_bytes")] + payload: Vec, + #[serde(with = "serde_bytes")] + write_cap: Vec, + #[serde(with = "serde_bytes")] + start_index: Vec, +} + +/// Request to create courier envelopes from multiple payloads. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CreateCourierEnvelopesFromPayloadsRequest { + #[serde(with = "serde_bytes")] + query_id: Vec, + #[serde(with = "serde_bytes")] + stream_id: Vec, + destinations: Vec, + is_last: bool, +} + +/// Reply containing the created courier envelopes from multiple payloads. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct CreateCourierEnvelopesFromPayloadsReply { + #[serde(with = "serde_bytes")] + query_id: Vec, + envelopes: Vec, + error_code: u8, } /// The size in bytes of a SURB (Single-Use Reply Block) identifier. @@ -379,11 +682,105 @@ pub struct Geometry { pub kem_name: String, } +/// PigeonholeGeometry describes the geometry of a Pigeonhole envelope. +/// +/// This provides mathematically precise geometry calculations using trunnel's +/// fixed binary format. +/// +/// It supports 3 distinct use cases: +/// 1. Given MaxPlaintextPayloadLength → compute all envelope sizes +/// 2. Given precomputed Pigeonhole Geometry → derive accommodating Sphinx Geometry +/// 3. Given Sphinx Geometry constraint → derive optimal Pigeonhole Geometry +#[derive(Debug, Clone, Deserialize)] +pub struct PigeonholeGeometry { + /// The maximum usable plaintext payload size within a Box. + #[serde(rename = "MaxPlaintextPayloadLength")] + pub max_plaintext_payload_length: usize, + + /// The size of a CourierQuery containing a ReplicaRead. + #[serde(rename = "CourierQueryReadLength")] + pub courier_query_read_length: usize, + + /// The size of a CourierQuery containing a ReplicaWrite. + #[serde(rename = "CourierQueryWriteLength")] + pub courier_query_write_length: usize, + + /// The size of a CourierQueryReply containing a ReplicaReadReply. + #[serde(rename = "CourierQueryReplyReadLength")] + pub courier_query_reply_read_length: usize, + + /// The size of a CourierQueryReply containing a ReplicaWriteReply. + #[serde(rename = "CourierQueryReplyWriteLength")] + pub courier_query_reply_write_length: usize, + + /// The NIKE scheme name used in MKEM for encrypting to multiple storage replicas. + #[serde(rename = "NIKEName")] + pub nike_name: String, + + /// The signature scheme used for BACAP (always "Ed25519"). + #[serde(rename = "SignatureSchemeName")] + pub signature_scheme_name: String, +} + +impl PigeonholeGeometry { + /// Creates a new PigeonholeGeometry with the given parameters. + /// + /// Note: In a real application, the courier query lengths would be computed + /// from the max_plaintext_payload_length using the geometry calculations. + /// This constructor is primarily for testing where those values may be + /// provided directly or defaulted to 0. + pub fn new(max_plaintext_payload_length: usize, nike_name: &str) -> Self { + Self { + max_plaintext_payload_length, + courier_query_read_length: 0, + courier_query_write_length: 0, + courier_query_reply_read_length: 0, + courier_query_reply_write_length: 0, + nike_name: nike_name.to_string(), + signature_scheme_name: "Ed25519".to_string(), + } + } + + /// Validates that the geometry has valid parameters. + pub fn validate(&self) -> Result<(), &'static str> { + if self.max_plaintext_payload_length == 0 { + return Err("MaxPlaintextPayloadLength must be positive"); + } + if self.nike_name.is_empty() { + return Err("NIKEName must be set"); + } + if self.signature_scheme_name != "Ed25519" { + return Err("SignatureSchemeName must be Ed25519"); + } + Ok(()) + } +} + +/// Creates a tombstone plaintext (all zeros) for the given geometry. +/// +/// A tombstone is used to overwrite/delete a pigeonhole box by filling it +/// with zeros. +pub fn tombstone_plaintext(geometry: &PigeonholeGeometry) -> Result, &'static str> { + geometry.validate()?; + Ok(vec![0u8; geometry.max_plaintext_payload_length]) +} + +/// Checks if a plaintext is a tombstone (all zeros of the correct length). +pub fn is_tombstone_plaintext(geometry: &PigeonholeGeometry, plaintext: &[u8]) -> bool { + if plaintext.len() != geometry.max_plaintext_payload_length { + return false; + } + plaintext.iter().all(|&b| b == 0) +} + #[derive(Debug, Deserialize)] pub struct ConfigFile { #[serde(rename = "SphinxGeometry")] pub sphinx_geometry: Geometry, + #[serde(rename = "PigeonholeGeometry")] + pub pigeonhole_geometry: PigeonholeGeometry, + #[serde(rename = "Network")] pub network: String, @@ -407,6 +804,7 @@ pub struct Config { pub network: String, pub address: String, pub sphinx_geometry: Geometry, + pub pigeonhole_geometry: PigeonholeGeometry, pub on_connection_status: Option) + Send + Sync>>, pub on_new_pki_document: Option) + Send + Sync>>, @@ -423,6 +821,7 @@ impl Config { network: parsed.network, address: parsed.address, sphinx_geometry: parsed.sphinx_geometry, + pigeonhole_geometry: parsed.pigeonhole_geometry, on_connection_status: None, on_new_pki_document: None, on_message_sent: None, @@ -626,6 +1025,12 @@ impl ThinClient { self.pki_doc.read().await.clone().expect("❌ PKI document is missing!") } + /// Returns the pigeonhole geometry from the config. + /// This geometry defines the payload sizes and envelope formats for the pigeonhole protocol. + pub fn pigeonhole_geometry(&self) -> &PigeonholeGeometry { + &self.config.pigeonhole_geometry + } + /// Given a service name this returns a ServiceDescriptor if the service exists /// in the current PKI document. pub async fn get_service(&self, service_name: &str) -> Result { @@ -843,6 +1248,69 @@ impl ThinClient { Ok(()) } + /// Send a CBOR request and wait for a reply with the matching query_id + async fn send_and_wait(&self, query_id: &[u8], request: BTreeMap) -> Result, ThinClientError> { + // Create an event sink to receive the reply + let mut event_rx = self.event_sink(); + + // Small delay to ensure the event sink drain is registered before sending the request + // This prevents a race condition where a fast daemon response arrives before the drain is ready + tokio::time::sleep(Duration::from_millis(10)).await; + + // Send the request + self.send_cbor_request(request).await?; + + // Wait for the reply with matching query_id (with timeout) + // Mixnets are slow due to mixing delays, cover traffic, etc. + // Use a generous timeout for integration tests and real-world usage + let timeout_duration = Duration::from_secs(600); + let start = std::time::Instant::now(); + + loop { + if start.elapsed() > timeout_duration { + return Err(ThinClientError::Other("Timeout waiting for reply".to_string())); + } + + // Try to receive with a short timeout to allow checking the overall timeout + match tokio::time::timeout(Duration::from_millis(100), event_rx.recv()).await { + Ok(Some(reply)) => { + let reply_types = vec![ + "new_keypair_reply", + "encrypt_read_reply", + "encrypt_write_reply", + "start_resending_encrypted_message_reply", + "cancel_resending_encrypted_message_reply", + "next_message_box_index_reply", + "start_resending_copy_command_reply", + "cancel_resending_copy_command_reply", + "create_courier_envelopes_from_payload_reply", + "create_courier_envelopes_from_payloads_reply", + ]; + + for reply_type in reply_types { + if let Some(Value::Map(inner_reply)) = reply.get(&Value::Text(reply_type.to_string())) { + // Check if this inner reply has the matching query_id + if let Some(Value::Bytes(reply_query_id)) = inner_reply.get(&Value::Text("query_id".to_string())) { + if reply_query_id == query_id { + // Found our reply! Return the inner map + return Ok(inner_reply.clone()); + } + } + } + } + // Not our reply, continue waiting + } + Ok(None) => { + return Err(ThinClientError::Other("Event channel closed".to_string())); + } + Err(_) => { + // Timeout on this receive, continue loop to check overall timeout + continue; + } + } + } + } + /// Sends a message encapsulated in a Sphinx packet without any SURB. /// No reply will be possible. This method requires mixnet connectivity. pub async fn send_message_without_reply( @@ -939,564 +1407,625 @@ impl ThinClient { self.send_cbor_request(request).await } - /*** Channel API ***/ - - /// Creates a new Pigeonhole write channel for sending messages. - /// Returns (channel_id, read_cap, write_cap) on success. - pub async fn create_write_channel(&self) -> Result<(u16, Vec, Vec), ThinClientError> { + // ======================================================================== + // NEW Pigeonhole API Methods + // ======================================================================== + + /// Creates a new keypair for use with the Pigeonhole protocol. + /// + /// This method generates a WriteCap and ReadCap from the provided seed using + /// the BACAP (Blinding-and-Capability) protocol. The WriteCap should be stored + /// securely for writing messages, while the ReadCap can be shared with others + /// to allow them to read messages. + /// + /// # Arguments + /// * `seed` - 32-byte seed used to derive the keypair + /// + /// # Returns + /// * `Ok((write_cap, read_cap, first_message_index))` on success + /// * `Err(ThinClientError)` on failure + pub async fn new_keypair(&self, seed: &[u8; 32]) -> Result<(Vec, Vec, Vec), ThinClientError> { let query_id = Self::new_query_id(); - let mut create_write_channel = BTreeMap::new(); - create_write_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - - let mut request = BTreeMap::new(); - request.insert(Value::Text("create_write_channel".to_string()), Value::Map(create_write_channel)); - - self.send_cbor_request(request).await?; - - // Wait for CreateWriteChannelReply using event sink - let mut event_sink = self.event_sink(); - - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("create_write_channel_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("CreateWriteChannel failed with error code: {}", error_code))); - } - } - - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("CreateWriteChannel failed: {}", err))); - } + let request_inner = NewKeypairRequest { + query_id: query_id.clone(), + seed: seed.to_vec(), + }; - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - let read_cap = match reply.get(&Value::Text("read_cap".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("read_cap is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing read_cap in response".to_string())), - }; + let mut request = BTreeMap::new(); + request.insert(Value::Text("new_keypair".to_string()), request_value); - let write_cap = match reply.get(&Value::Text("write_cap".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("write_cap is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing write_cap in response".to_string())), - }; + let reply_map = self.send_and_wait(&query_id, request).await?; - return Ok((channel_id, read_cap, write_cap)); - } + let reply: NewKeypairReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - // If we get here, it wasn't the reply we were looking for + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("new_keypair failed with error code: {}", reply.error_code))); } + + Ok((reply.write_cap, reply.read_cap, reply.first_message_index)) } - /// Creates a read channel from a read capability. - /// Returns channel_id on success. - pub async fn create_read_channel(&self, read_cap: Vec) -> Result { + /// Encrypts a read operation for a given read capability. + /// + /// This method prepares an encrypted read request that can be sent to the + /// courier service to retrieve a message from a pigeonhole box. + /// + /// # Arguments + /// * `read_cap` - Read capability that grants access to the channel + /// * `message_box_index` - Starting read position for the channel + /// + /// # Returns + /// * `Ok((message_ciphertext, next_message_index, envelope_descriptor, envelope_hash))` on success + /// * `Err(ThinClientError)` on failure + pub async fn encrypt_read( + &self, + read_cap: &[u8], + message_box_index: &[u8] + ) -> Result<(Vec, Vec, Vec, [u8; 32]), ThinClientError> { let query_id = Self::new_query_id(); - let mut create_read_channel = BTreeMap::new(); - create_read_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - create_read_channel.insert(Value::Text("read_cap".to_string()), Value::Bytes(read_cap)); + let request_inner = EncryptReadRequest { + query_id: query_id.clone(), + read_cap: read_cap.to_vec(), + message_box_index: message_box_index.to_vec(), + }; + + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; let mut request = BTreeMap::new(); - request.insert(Value::Text("create_read_channel".to_string()), Value::Map(create_read_channel)); + request.insert(Value::Text("encrypt_read".to_string()), request_value); - self.send_cbor_request(request).await?; + let reply_map = self.send_and_wait(&query_id, request).await?; - // Wait for CreateReadChannelReply using event sink - let mut event_sink = self.event_sink(); + let reply: EncryptReadReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("create_read_channel_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("CreateReadChannel failed with error code: {}", error_code))); - } - } - - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("CreateReadChannel failed: {}", err))); - } - - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("encrypt_read failed with error code: {}", reply.error_code))); + } - return Ok(channel_id); - } + let mut envelope_hash = [0u8; 32]; + envelope_hash.copy_from_slice(&reply.envelope_hash[..32]); - // If we get here, it wasn't the reply we were looking for - } + Ok(( + reply.message_ciphertext, + reply.next_message_index, + reply.envelope_descriptor, + envelope_hash + )) } - /// Prepares a message for writing to a Pigeonhole channel. - /// Returns WriteChannelReply matching the Go API. - pub async fn write_channel(&self, channel_id: u16, payload: &[u8]) -> Result { + /// Encrypts a write operation for a given write capability. + /// + /// This method prepares an encrypted write request that can be sent to the + /// courier service to store a message in a pigeonhole box. + /// + /// # Arguments + /// * `plaintext` - The plaintext message to encrypt + /// * `write_cap` - Write capability that grants access to the channel + /// * `message_box_index` - Starting write position for the channel + /// + /// # Returns + /// * `Ok((message_ciphertext, envelope_descriptor, envelope_hash))` on success + /// * `Err(ThinClientError)` on failure + pub async fn encrypt_write( + &self, + plaintext: &[u8], + write_cap: &[u8], + message_box_index: &[u8] + ) -> Result<(Vec, Vec, [u8; 32]), ThinClientError> { let query_id = Self::new_query_id(); - let mut write_channel = BTreeMap::new(); - write_channel.insert(Value::Text("channel_id".to_string()), Value::Integer(channel_id.into())); - write_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - write_channel.insert(Value::Text("payload".to_string()), Value::Bytes(payload.to_vec())); + let request_inner = EncryptWriteRequest { + query_id: query_id.clone(), + plaintext: plaintext.to_vec(), + write_cap: write_cap.to_vec(), + message_box_index: message_box_index.to_vec(), + }; + + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; let mut request = BTreeMap::new(); - request.insert(Value::Text("write_channel".to_string()), Value::Map(write_channel)); + request.insert(Value::Text("encrypt_write".to_string()), request_value); - self.send_cbor_request(request).await?; + let reply_map = self.send_and_wait(&query_id, request).await?; - // Wait for WriteChannelReply using event sink - let mut event_sink = self.event_sink(); + let reply: EncryptWriteReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("encrypt_write failed with error code: {}", reply.error_code))); + } - if let Some(Value::Map(reply)) = response.get(&Value::Text("write_channel_reply".to_string())) { - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("WriteChannel failed: {}", err))); - } + let mut envelope_hash = [0u8; 32]; + envelope_hash.copy_from_slice(&reply.envelope_hash[..32]); - let send_message_payload = reply.get(&Value::Text("send_message_payload".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing send_message_payload in response".to_string()))?; + Ok(( + reply.message_ciphertext, + reply.envelope_descriptor, + envelope_hash + )) + } - let current_message_index = match reply.get(&Value::Text("current_message_index".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("current_message_index is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing current_message_index in response".to_string())), - }; + /// Starts resending an encrypted message via ARQ (Automatic Repeat Request). + /// + /// This method initiates automatic repeat request for an encrypted message, + /// which will be resent periodically until either a reply is received or + /// the operation is cancelled. + /// + /// # Arguments + /// * `read_cap` - Optional read capability (for read operations) + /// * `write_cap` - Optional write capability (for write operations) + /// * `next_message_index` - Optional next message index (for read operations) + /// * `reply_index` - Reply index for the operation + /// * `envelope_descriptor` - Envelope descriptor from encrypt_read/encrypt_write + /// * `message_ciphertext` - Encrypted message from encrypt_read/encrypt_write + /// * `envelope_hash` - Envelope hash from encrypt_read/encrypt_write + /// + /// # Returns + /// * `Ok(plaintext)` - The plaintext reply received + /// * `Err(ThinClientError)` on failure + pub async fn start_resending_encrypted_message( + &self, + read_cap: Option<&[u8]>, + write_cap: Option<&[u8]>, + next_message_index: Option<&[u8]>, + reply_index: u8, + envelope_descriptor: &[u8], + message_ciphertext: &[u8], + envelope_hash: &[u8; 32] + ) -> Result, ThinClientError> { + let query_id = Self::new_query_id(); - let next_message_index = match reply.get(&Value::Text("next_message_index".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("next_message_index is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing next_message_index in response".to_string())), - }; + let request_inner = StartResendingEncryptedMessageRequest { + query_id: query_id.clone(), + read_cap: read_cap.map(|rc| rc.to_vec()), + write_cap: write_cap.map(|wc| wc.to_vec()), + next_message_index: next_message_index.map(|nmi| nmi.to_vec()), + reply_index, + envelope_descriptor: envelope_descriptor.to_vec(), + message_ciphertext: message_ciphertext.to_vec(), + envelope_hash: envelope_hash.to_vec(), + }; - let envelope_descriptor = reply.get(&Value::Text("envelope_descriptor".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing envelope_descriptor in response".to_string()))?; - - let envelope_hash = reply.get(&Value::Text("envelope_hash".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing envelope_hash in response".to_string()))?; - - return Ok(WriteChannelReply { - send_message_payload, - current_message_index, - next_message_index, - envelope_descriptor, - envelope_hash, - }); - } + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; + + let mut request = BTreeMap::new(); + request.insert(Value::Text("start_resending_encrypted_message".to_string()), request_value); + + let reply_map = self.send_and_wait(&query_id, request).await?; + + let reply: StartResendingEncryptedMessageReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - // If we get here, it wasn't the reply we were looking for + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("start_resending_encrypted_message failed with error code: {}", reply.error_code))); } + + Ok(reply.plaintext.unwrap_or_default()) } - /// Prepares a read query for a Pigeonhole channel. - /// Returns ReadChannelReply matching the Go API. - pub async fn read_channel(&self, channel_id: u16, message_box_index: Option<&[u8]>, reply_index: Option) -> Result { + /// Cancels ARQ resending for an encrypted message. + /// + /// This method stops the automatic repeat request for a previously started + /// encrypted message transmission. + /// + /// # Arguments + /// * `envelope_hash` - Hash of the courier envelope to cancel + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err(ThinClientError)` on failure + pub async fn cancel_resending_encrypted_message(&self, envelope_hash: &[u8; 32]) -> Result<(), ThinClientError> { let query_id = Self::new_query_id(); - let mut read_channel = BTreeMap::new(); - read_channel.insert(Value::Text("channel_id".to_string()), Value::Integer(channel_id.into())); - read_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - - if let Some(index) = message_box_index { - read_channel.insert(Value::Text("message_box_index".to_string()), Value::Bytes(index.to_vec())); - } + let request_inner = CancelResendingEncryptedMessageRequest { + query_id: query_id.clone(), + envelope_hash: envelope_hash.to_vec(), + }; - if let Some(idx) = reply_index { - read_channel.insert(Value::Text("reply_index".to_string()), Value::Integer(idx.into())); - } + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; let mut request = BTreeMap::new(); - request.insert(Value::Text("read_channel".to_string()), Value::Map(read_channel)); + request.insert(Value::Text("cancel_resending_encrypted_message".to_string()), request_value); - self.send_cbor_request(request).await?; + let reply_map = self.send_and_wait(&query_id, request).await?; - // Wait for ReadChannelReply using event sink - let mut event_sink = self.event_sink(); + let reply: CancelResendingEncryptedMessageReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("cancel_resending_encrypted_message failed with error code: {}", reply.error_code))); + } - if let Some(Value::Map(reply)) = response.get(&Value::Text("read_channel_reply".to_string())) { - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("ReadChannel failed: {}", err))); - } + Ok(()) + } - let send_message_payload = reply.get(&Value::Text("send_message_payload".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing send_message_payload in response".to_string()))?; + /// Increments a MessageBoxIndex using the BACAP NextIndex method. + /// + /// This method is used when sending multiple messages to different mailboxes using + /// the same WriteCap or ReadCap. It properly advances the cryptographic state by: + /// - Incrementing the Idx64 counter + /// - Deriving new encryption and blinding keys using HKDF + /// - Updating the HKDF state for the next iteration + /// + /// # Arguments + /// * `message_box_index` - Current message box index to increment + /// + /// # Returns + /// * `Ok(next_message_box_index)` - The incremented message box index + /// * `Err(ThinClientError)` on failure + pub async fn next_message_box_index(&self, message_box_index: &[u8]) -> Result, ThinClientError> { + let query_id = Self::new_query_id(); - let current_message_index = match reply.get(&Value::Text("current_message_index".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("current_message_index is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing current_message_index in response".to_string())), - }; + let request_inner = NextMessageBoxIndexRequest { + query_id: query_id.clone(), + message_box_index: message_box_index.to_vec(), + }; - let next_message_index = match reply.get(&Value::Text("next_message_index".to_string())) { - Some(Value::Bytes(bytes)) => bytes.clone(), - Some(_) => return Err(ThinClientError::Other("next_message_index is unexpected type".to_string())), - None => return Err(ThinClientError::Other("Missing next_message_index in response".to_string())), - }; + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - let used_reply_index = reply.get(&Value::Text("reply_index".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u8), _ => None }); - - let envelope_descriptor = reply.get(&Value::Text("envelope_descriptor".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing envelope_descriptor in response".to_string()))?; - - let envelope_hash = reply.get(&Value::Text("envelope_hash".to_string())) - .and_then(|v| match v { Value::Bytes(b) => Some(b.clone()), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing envelope_hash in response".to_string()))?; - - return Ok(ReadChannelReply { - send_message_payload, - current_message_index, - next_message_index, - reply_index: used_reply_index, - envelope_descriptor, - envelope_hash, - }); - } + let mut request = BTreeMap::new(); + request.insert(Value::Text("next_message_box_index".to_string()), request_value); - // If we get here, it wasn't the reply we were looking for - } - } + let reply_map = self.send_and_wait(&query_id, request).await?; - /// Resumes a write channel from a previous session. - /// Returns channel_id on success. - pub async fn resume_write_channel(&self, write_cap: Vec, message_box_index: Option>) -> Result { - let query_id = Self::new_query_id(); + let reply: NextMessageBoxIndexReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - let mut resume_write_channel = BTreeMap::new(); - resume_write_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - resume_write_channel.insert(Value::Text("write_cap".to_string()), Value::Bytes(write_cap)); - if let Some(index) = message_box_index { - resume_write_channel.insert(Value::Text("message_box_index".to_string()), Value::Bytes(index)); + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("next_message_box_index failed with error code: {}", reply.error_code))); } - let mut request = BTreeMap::new(); - request.insert(Value::Text("resume_write_channel".to_string()), Value::Map(resume_write_channel)); + Ok(reply.next_message_box_index) + } - self.send_cbor_request(request).await?; + /// Starts resending a copy command to a courier via ARQ. + /// + /// This method instructs a courier to read data from a temporary channel + /// (identified by the write_cap) and write it to the destination channel. + /// The command is automatically retransmitted until acknowledged. + /// + /// If courier_identity_hash and courier_queue_id are both provided, + /// the copy command is sent to that specific courier. Otherwise, a + /// random courier is selected. + /// + /// # Arguments + /// * `write_cap` - Write capability for the temporary channel containing the data + /// * `courier_identity_hash` - Optional identity hash of a specific courier to use + /// * `courier_queue_id` - Optional queue ID for the specified courier + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err(ThinClientError)` on failure + pub async fn start_resending_copy_command( + &self, + write_cap: &[u8], + courier_identity_hash: Option<&[u8]>, + courier_queue_id: Option<&[u8]> + ) -> Result<(), ThinClientError> { + let query_id = Self::new_query_id(); - // Wait for ResumeWriteChannelReply using event sink - let mut event_sink = self.event_sink(); + let request_inner = StartResendingCopyCommandRequest { + query_id: query_id.clone(), + write_cap: write_cap.to_vec(), + courier_identity_hash: courier_identity_hash.map(|h| h.to_vec()), + courier_queue_id: courier_queue_id.map(|q| q.to_vec()), + }; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("resume_write_channel_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("ResumeWriteChannel failed with error code: {}", error_code))); - } - } + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("ResumeWriteChannel failed: {}", err))); - } + let mut request = BTreeMap::new(); + request.insert(Value::Text("start_resending_copy_command".to_string()), request_value); - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + let reply_map = self.send_and_wait(&query_id, request).await?; - return Ok(channel_id); - } + let reply: StartResendingCopyCommandReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - // If we get here, it wasn't the reply we were looking for + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("start_resending_copy_command failed with error code: {}", reply.error_code))); } + + Ok(()) } - /// Resumes a read channel from a previous session. - /// Returns channel_id on success. - pub async fn resume_read_channel(&self, read_cap: Vec, next_message_index: Option>, reply_index: Option) -> Result { + /// Cancels ARQ resending for a copy command. + /// + /// This method stops the automatic repeat request (ARQ) for a previously started + /// copy command. + /// + /// # Arguments + /// * `write_cap_hash` - Hash of the WriteCap used in start_resending_copy_command + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err(ThinClientError)` on failure + pub async fn cancel_resending_copy_command(&self, write_cap_hash: &[u8; 32]) -> Result<(), ThinClientError> { let query_id = Self::new_query_id(); - let mut resume_read_channel = BTreeMap::new(); - resume_read_channel.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - resume_read_channel.insert(Value::Text("read_cap".to_string()), Value::Bytes(read_cap)); - if let Some(index) = next_message_index { - resume_read_channel.insert(Value::Text("next_message_index".to_string()), Value::Bytes(index)); - } - if let Some(index) = reply_index { - resume_read_channel.insert(Value::Text("reply_index".to_string()), Value::Integer(index.into())); - } - - let mut request = BTreeMap::new(); - request.insert(Value::Text("resume_read_channel".to_string()), Value::Map(resume_read_channel)); - - self.send_cbor_request(request).await?; + let request_inner = CancelResendingCopyCommandRequest { + query_id: query_id.clone(), + write_cap_hash: write_cap_hash.to_vec(), + }; - // Wait for ResumeReadChannelReply using event sink - let mut event_sink = self.event_sink(); + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("resume_read_channel_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("ResumeReadChannel failed with error code: {}", error_code))); - } - } - - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("ResumeReadChannel failed: {}", err))); - } + let mut request = BTreeMap::new(); + request.insert(Value::Text("cancel_resending_copy_command".to_string()), request_value); - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + let reply_map = self.send_and_wait(&query_id, request).await?; - return Ok(channel_id); - } + let reply: CancelResendingCopyCommandReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - // If we get here, it wasn't the reply we were looking for + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("cancel_resending_copy_command failed with error code: {}", reply.error_code))); } + + Ok(()) } - /// Resumes a write channel with a specific query state. - /// This method provides more granular resumption control than ResumeWriteChannel - /// by allowing the application to resume from a specific query state, including - /// the envelope descriptor and hash. This is useful when resuming from a partially - /// completed write operation that was interrupted during transmission. - /// Returns channel_id on success. - pub async fn resume_write_channel_query( + /// Creates multiple CourierEnvelopes from a payload of any size. + /// + /// The payload is automatically chunked and each chunk is wrapped in a + /// CourierEnvelope. Each returned chunk is a serialized CopyStreamElement + /// ready to be written to a box. + /// + /// Multiple calls can be made with the same stream_id to build up a stream + /// incrementally. The first call creates a new encoder (first element gets + /// IsStart=true). The final call should have is_last=true (last element + /// gets IsFinal=true). + /// + /// # Arguments + /// * `stream_id` - 16-byte identifier for the encoder instance + /// * `payload` - The data to be encoded into courier envelopes + /// * `dest_write_cap` - Write capability for the destination channel + /// * `dest_start_index` - Starting index in the destination channel + /// * `is_last` - Whether this is the last payload in the sequence + /// + /// # Returns + /// * `Ok(Vec>)` - List of serialized CopyStreamElements + /// * `Err(ThinClientError)` on failure + pub async fn create_courier_envelopes_from_payload( &self, - write_cap: Vec, - message_box_index: Vec, - envelope_descriptor: Vec, - envelope_hash: Vec, - ) -> Result { + stream_id: &[u8; 16], + payload: &[u8], + dest_write_cap: &[u8], + dest_start_index: &[u8], + is_last: bool + ) -> Result>, ThinClientError> { let query_id = Self::new_query_id(); - let mut resume_write_channel_query = BTreeMap::new(); - resume_write_channel_query.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - resume_write_channel_query.insert(Value::Text("write_cap".to_string()), Value::Bytes(write_cap)); - resume_write_channel_query.insert(Value::Text("message_box_index".to_string()), Value::Bytes(message_box_index)); - resume_write_channel_query.insert(Value::Text("envelope_descriptor".to_string()), Value::Bytes(envelope_descriptor)); - resume_write_channel_query.insert(Value::Text("envelope_hash".to_string()), Value::Bytes(envelope_hash)); - - let mut request = BTreeMap::new(); - request.insert(Value::Text("resume_write_channel_query".to_string()), Value::Map(resume_write_channel_query)); - - self.send_cbor_request(request).await?; + let request_inner = CreateCourierEnvelopesFromPayloadRequest { + query_id: query_id.clone(), + stream_id: stream_id.to_vec(), + payload: payload.to_vec(), + dest_write_cap: dest_write_cap.to_vec(), + dest_start_index: dest_start_index.to_vec(), + is_last, + }; - // Wait for ResumeWriteChannelQueryReply using event sink - let mut event_sink = self.event_sink(); + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("resume_write_channel_query_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("ResumeWriteChannelQuery failed with error code: {}", error_code))); - } - } - - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("ResumeWriteChannelQuery failed: {}", err))); - } + let mut request = BTreeMap::new(); + request.insert(Value::Text("create_courier_envelopes_from_payload".to_string()), request_value); - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + let reply_map = self.send_and_wait(&query_id, request).await?; - return Ok(channel_id); - } + let reply: CreateCourierEnvelopesFromPayloadReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - // If we get here, it wasn't the reply we were looking for + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("create_courier_envelopes_from_payload failed with error code: {}", reply.error_code))); } + + Ok(reply.envelopes.into_iter().map(|b| b.into_vec()).collect()) } - /// Resumes a read channel with a specific query state. - /// This method provides more granular resumption control than ResumeReadChannel - /// by allowing the application to resume from a specific query state, including - /// the envelope descriptor and hash. This is useful when resuming from a partially - /// completed read operation that was interrupted during transmission. - /// Returns channel_id on success. - pub async fn resume_read_channel_query( + /// Creates CourierEnvelopes from multiple payloads going to different destinations. + /// + /// This is more space-efficient than calling create_courier_envelopes_from_payload + /// multiple times because envelopes from different destinations are packed + /// together in the copy stream without wasting space. + /// + /// # Arguments + /// * `stream_id` - 16-byte identifier for the encoder instance + /// * `destinations` - List of (payload, write_cap, start_index) tuples + /// * `is_last` - Whether this is the last set of payloads in the sequence + /// + /// # Returns + /// * `Ok(Vec>)` - List of serialized CopyStreamElements + /// * `Err(ThinClientError)` on failure + pub async fn create_courier_envelopes_from_payloads( &self, - read_cap: Vec, - next_message_index: Vec, - reply_index: Option, - envelope_descriptor: Vec, - envelope_hash: Vec, - ) -> Result { + stream_id: &[u8; 16], + destinations: Vec<(&[u8], &[u8], &[u8])>, + is_last: bool + ) -> Result>, ThinClientError> { let query_id = Self::new_query_id(); - let mut resume_read_channel_query = BTreeMap::new(); - resume_read_channel_query.insert(Value::Text("query_id".to_string()), Value::Bytes(query_id.clone())); - resume_read_channel_query.insert(Value::Text("read_cap".to_string()), Value::Bytes(read_cap)); - resume_read_channel_query.insert(Value::Text("next_message_index".to_string()), Value::Bytes(next_message_index)); - if let Some(index) = reply_index { - resume_read_channel_query.insert(Value::Text("reply_index".to_string()), Value::Integer(index.into())); - } - resume_read_channel_query.insert(Value::Text("envelope_descriptor".to_string()), Value::Bytes(envelope_descriptor)); - resume_read_channel_query.insert(Value::Text("envelope_hash".to_string()), Value::Bytes(envelope_hash)); + let destinations_inner: Vec = destinations + .into_iter() + .map(|(payload, write_cap, start_index)| EnvelopeDestination { + payload: payload.to_vec(), + write_cap: write_cap.to_vec(), + start_index: start_index.to_vec(), + }) + .collect(); - let mut request = BTreeMap::new(); - request.insert(Value::Text("resume_read_channel_query".to_string()), Value::Map(resume_read_channel_query)); + let request_inner = CreateCourierEnvelopesFromPayloadsRequest { + query_id: query_id.clone(), + stream_id: stream_id.to_vec(), + destinations: destinations_inner, + is_last, + }; - self.send_cbor_request(request).await?; + let request_value = serde_cbor::value::to_value(&request_inner) + .map_err(|e| ThinClientError::CborError(e))?; - // Wait for ResumeReadChannelQueryReply using event sink - let mut event_sink = self.event_sink(); + let mut request = BTreeMap::new(); + request.insert(Value::Text("create_courier_envelopes_from_payloads".to_string()), request_value); - loop { - let response = event_sink.recv().await - .ok_or_else(|| ThinClientError::Other("Event sink closed".to_string()))?; - - if let Some(Value::Map(reply)) = response.get(&Value::Text("resume_read_channel_query_reply".to_string())) { - // Check for error first - if let Some(Value::Integer(error_code)) = reply.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("ResumeReadChannelQuery failed with error code: {}", error_code))); - } - } + let reply_map = self.send_and_wait(&query_id, request).await?; - if let Some(Value::Text(err)) = reply.get(&Value::Text("err".to_string())) { - return Err(ThinClientError::Other(format!("ResumeReadChannelQuery failed: {}", err))); - } + let reply: CreateCourierEnvelopesFromPayloadsReply = serde_cbor::value::from_value(Value::Map(reply_map)) + .map_err(|e| ThinClientError::CborError(e))?; - let channel_id = reply.get(&Value::Text("channel_id".to_string())) - .and_then(|v| match v { Value::Integer(i) => Some(*i as u16), _ => None }) - .ok_or_else(|| ThinClientError::Other("Missing channel_id in response".to_string()))?; + if reply.error_code != 0 { + return Err(ThinClientError::Other(format!("create_courier_envelopes_from_payloads failed with error code: {}", reply.error_code))); + } - return Ok(channel_id); - } + Ok(reply.envelopes.into_iter().map(|b| b.into_vec()).collect()) + } - // If we get here, it wasn't the reply we were looking for - } + /// Generates a new random 16-byte stream ID. + pub fn new_stream_id() -> [u8; 16] { + let mut stream_id = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut stream_id); + stream_id } - /// Sends a prepared channel query to the mixnet without waiting for a reply. - pub async fn send_channel_query( + /// Tombstone a single pigeonhole box by overwriting it with zeros. + /// + /// This method overwrites the specified box with a zero-filled payload, + /// effectively deleting its contents. The tombstone is sent via ARQ + /// for reliable delivery. + /// + /// # Arguments + /// * `geometry` - Pigeonhole geometry defining payload size + /// * `write_cap` - Write capability for the box + /// * `box_index` - Index of the box to tombstone + /// + /// # Returns + /// * `Ok(())` on success + /// * `Err(ThinClientError)` on failure + pub async fn tombstone_box( &self, - channel_id: u16, - payload: &[u8], - dest_node: Vec, - dest_queue: Vec, - message_id: Vec, + geometry: &PigeonholeGeometry, + write_cap: &[u8], + box_index: &[u8] ) -> Result<(), ThinClientError> { - // Check if we're in offline mode - if !self.is_connected() { - return Err(ThinClientError::OfflineMode("cannot send channel query in offline mode - daemon not connected to mixnet".to_string())); - } - - let mut send_channel_query = BTreeMap::new(); - send_channel_query.insert(Value::Text("message_id".to_string()), Value::Bytes(message_id)); - send_channel_query.insert(Value::Text("channel_id".to_string()), Value::Integer(channel_id.into())); - send_channel_query.insert(Value::Text("destination_id_hash".to_string()), Value::Bytes(dest_node)); - send_channel_query.insert(Value::Text("recipient_queue_id".to_string()), Value::Bytes(dest_queue)); - send_channel_query.insert(Value::Text("payload".to_string()), Value::Bytes(payload.to_vec())); + geometry.validate().map_err(|e| ThinClientError::Other(e.to_string()))?; + + // Create zero-filled tombstone payload + let tomb = vec![0u8; geometry.max_plaintext_payload_length]; + + // Encrypt the tombstone for the target box + let (ciphertext, env_desc, env_hash) = self + .encrypt_write(&tomb, write_cap, box_index).await?; + + // Send via ARQ for reliable delivery + let _ = self.start_resending_encrypted_message( + None, + Some(write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await?; - let mut request = BTreeMap::new(); - request.insert(Value::Text("send_channel_query".to_string()), Value::Map(send_channel_query)); - - self.send_cbor_request(request).await + Ok(()) } +} - /// Sends a channel query and waits for the reply. - /// This combines send_channel_query with event handling to wait for the response. - pub async fn send_channel_query_await_reply( +/// Result of a tombstone_range operation. +#[derive(Debug)] +pub struct TombstoneRangeResult { + /// Number of boxes successfully tombstoned. + pub tombstoned: u32, + /// The next MessageBoxIndex after the last processed. + pub next: Vec, + /// Error message if the operation failed partway through. + pub error: Option, +} + +impl ThinClient { + /// Tombstone a range of pigeonhole boxes starting from a given index. + /// + /// This method tombstones up to max_count boxes, starting from the + /// specified box index and advancing through consecutive indices. + /// + /// If an error occurs during the operation, a partial result is returned + /// containing the number of boxes successfully tombstoned and the next + /// index that was being processed. + /// + /// # Arguments + /// * `geometry` - Pigeonhole geometry defining payload size + /// * `write_cap` - Write capability for the boxes + /// * `start` - Starting MessageBoxIndex + /// * `max_count` - Maximum number of boxes to tombstone + /// + /// # Returns + /// * `TombstoneRangeResult` containing the count and next index + pub async fn tombstone_range( &self, - channel_id: u16, - payload: &[u8], - dest_node: Vec, - dest_queue: Vec, - message_id: Vec, - ) -> Result, ThinClientError> { - // Create an event sink to listen for the reply - let mut event_sink = self.event_sink(); + geometry: &PigeonholeGeometry, + write_cap: &[u8], + start: &[u8], + max_count: u32 + ) -> TombstoneRangeResult { + if max_count == 0 { + return TombstoneRangeResult { + tombstoned: 0, + next: start.to_vec(), + error: None, + }; + } - // Send the channel query - self.send_channel_query(channel_id, payload, dest_node, dest_queue, message_id.clone()).await?; + if let Err(e) = geometry.validate() { + return TombstoneRangeResult { + tombstoned: 0, + next: start.to_vec(), + error: Some(e.to_string()), + }; + } - // Wait for the reply - loop { - match event_sink.recv().await { - Some(response) => { - // Check for ChannelQuerySentEvent first - if let Some(Value::Map(event)) = response.get(&Value::Text("channel_query_sent_event".to_string())) { - if let Some(Value::Bytes(reply_message_id)) = event.get(&Value::Text("message_id".to_string())) { - if reply_message_id == &message_id { - // Check for error in sent event - if let Some(Value::Integer(error_code)) = event.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("Channel query send failed with error code: {}", error_code))); - } - } - // Continue waiting for the reply - continue; - } - } - } + let mut cur = start.to_vec(); + let mut done: u32 = 0; - // Check for ChannelQueryReplyEvent - if let Some(Value::Map(event)) = response.get(&Value::Text("channel_query_reply_event".to_string())) { - if let Some(Value::Bytes(reply_message_id)) = event.get(&Value::Text("message_id".to_string())) { - if reply_message_id == &message_id { - // Check for error code - if let Some(Value::Integer(error_code)) = event.get(&Value::Text("error_code".to_string())) { - if *error_code != 0 { - return Err(ThinClientError::Other(format!("Channel query failed with error code: {}", error_code))); - } - } + while done < max_count { + if let Err(e) = self.tombstone_box(geometry, write_cap, &cur).await { + return TombstoneRangeResult { + tombstoned: done, + next: cur, + error: Some(format!("Error tombstoning box at index {}: {:?}", done, e)), + }; + } - // Extract the payload - if let Some(Value::Bytes(reply_payload)) = event.get(&Value::Text("payload".to_string())) { - return Ok(reply_payload.clone()); - } else { - return Err(ThinClientError::Other("Missing payload in channel query reply".to_string())); - } - } - } - } + done += 1; - // Ignore other events and continue waiting - } - None => { - return Err(ThinClientError::Other("Event sink closed while waiting for reply".to_string())); + match self.next_message_box_index(&cur).await { + Ok(next) => cur = next, + Err(e) => { + return TombstoneRangeResult { + tombstoned: done, + next: cur, + error: Some(format!("Error getting next index after tombstoning: {:?}", e)), + }; } } } - } - - /// Closes a pigeonhole channel and cleans up its resources. - /// This helps avoid running out of channel IDs by properly releasing them. - pub async fn close_channel(&self, channel_id: u16) -> Result<(), ThinClientError> { - let mut close_channel = BTreeMap::new(); - close_channel.insert(Value::Text("channel_id".to_string()), Value::Integer(channel_id.into())); - let mut request = BTreeMap::new(); - request.insert(Value::Text("close_channel".to_string()), Value::Map(close_channel)); - - self.send_cbor_request(request).await + TombstoneRangeResult { + tombstoned: done, + next: cur, + error: None, + } } } diff --git a/testdata/thinclient.toml b/testdata/thinclient.toml index 06bab92..513a2a5 100644 --- a/testdata/thinclient.toml +++ b/testdata/thinclient.toml @@ -18,10 +18,10 @@ Address = "localhost:64331" KEMName = "" [PigeonholeGeometry] - BoxPayloadLength = 1556 - CourierQueryReadLength = 360 + MaxPlaintextPayloadLength = 1553 + CourierQueryReadLength = 359 CourierQueryWriteLength = 2000 - CourierQueryReplyReadLength = 1701 + CourierQueryReplyReadLength = 1698 CourierQueryReplyWriteLength = 50 NIKEName = "CTIDH1024-X25519" SignatureSchemeName = "Ed25519" diff --git a/tests/channel_api_test.rs b/tests/channel_api_test.rs index dfb56fc..915b5f3 100644 --- a/tests/channel_api_test.rs +++ b/tests/channel_api_test.rs @@ -1,15 +1,29 @@ // SPDX-FileCopyrightText: Copyright (C) 2025 David Stainton // SPDX-License-Identifier: AGPL-3.0-only -//! Channel API integration tests for the Rust thin client -//! -//! These tests mirror the Go tests in courier_docker_test.go and require -//! a running mixnet with client daemon for integration testing. +//! NEW Pigeonhole API integration tests for the Rust thin client +//! +//! These tests verify the NEW Pigeonhole API: +//! 1. new_keypair - Generate WriteCap and ReadCap from seed +//! 2. encrypt_read - Encrypt a read operation +//! 3. encrypt_write - Encrypt a write operation +//! 4. start_resending_encrypted_message - Send encrypted message with ARQ +//! 5. cancel_resending_encrypted_message - Cancel ARQ for a message +//! 6. next_message_box_index - Increment MessageBoxIndex for multiple messages +//! 7. start_resending_copy_command - Send copy command via ARQ +//! 8. cancel_resending_copy_command - Cancel copy command ARQ +//! 9. create_courier_envelopes_from_payload - Chunk payload into courier envelopes +//! 10. create_courier_envelopes_from_payloads - Chunk multiple payloads efficiently +//! +//! Helper functions and tests: +//! - tombstone_box - Overwrite a box with zeros +//! - tombstone_range - Overwrite a range of boxes with zeros +//! - is_tombstone_plaintext - Check if plaintext is a tombstone +//! +//! These tests require a running mixnet with client daemon for integration testing. use std::time::Duration; -use katzenpost_thin_client::{ThinClient, Config}; - - +use katzenpost_thin_client::{ThinClient, Config, is_tombstone_plaintext}; /// Test helper to setup a thin client for integration tests async fn setup_thin_client() -> Result, Box> { @@ -22,798 +36,511 @@ async fn setup_thin_client() -> Result, Box Result<(), Box> { - let alice_thin_client = setup_thin_client().await?; - let bob_thin_client = setup_thin_client().await?; - - // Wait for PKI documents to be available and connection to mixnet - println!("Waiting for daemon to connect to mixnet..."); - let mut attempts = 0; - while !alice_thin_client.is_connected() && attempts < 30 { - tokio::time::sleep(Duration::from_secs(1)).await; - attempts += 1; - } +async fn test_new_keypair_basic() { + println!("\n=== Test: new_keypair basic functionality ==="); - if !alice_thin_client.is_connected() { - return Err("Daemon failed to connect to mixnet within 30 seconds".into()); - } + let client = setup_thin_client().await.expect("Failed to setup client"); - println!("✅ Daemon connected to mixnet, using current PKI document"); - - // Alice creates write channel - println!("Alice: Creating write channel"); - let (alice_channel_id, read_cap, _write_cap) = alice_thin_client.create_write_channel().await?; - println!("Alice: Created write channel {}", alice_channel_id); - - // Bob creates read channel using the read capability from Alice's write channel - println!("Bob: Creating read channel"); - let bob_channel_id = bob_thin_client.create_read_channel(read_cap).await?; - println!("Bob: Created read channel {}", bob_channel_id); - - // Alice writes first message - let original_message = b"hello1"; - println!("Alice: Writing first message and waiting for completion"); - - let write_reply1 = alice_thin_client.write_channel(alice_channel_id, original_message).await?; - println!("Alice: Write operation completed successfully"); - - // Get the courier service from PKI - let courier_service = alice_thin_client.get_service("courier").await?; - let (dest_node, dest_queue) = courier_service.to_destination(); - - let alice_message_id1 = ThinClient::new_message_id(); - - let _reply1 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id1 - ).await?; - - // Alice writes a second message - let second_message = b"hello2"; - println!("Alice: Writing second message and waiting for completion"); - - let write_reply2 = alice_thin_client.write_channel(alice_channel_id, second_message).await?; - println!("Alice: Second write operation completed successfully"); - - let alice_message_id2 = ThinClient::new_message_id(); - - let _reply2 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id2 - ).await?; - - // Wait for message propagation to storage replicas - println!("Waiting for message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(10)).await; - - // Bob reads first message - println!("Bob: Reading first message"); - let read_reply1 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id1 = ThinClient::new_message_id(); - - // In a real implementation, you'd retry the SendChannelQueryAwaitReply until you get a response - let mut bob_reply_payload1 = vec![]; - for i in 0..10 { - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id1.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload1 = payload; - break; - } - Ok(_) => { - println!("Bob: Read attempt {} returned empty payload, retrying...", i + 1); - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } + // Generate a random 32-byte seed + let seed: [u8; 32] = rand::random(); - assert_eq!(original_message, bob_reply_payload1.as_slice(), "Bob: Reply payload mismatch"); - - // Bob reads second message - println!("Bob: Reading second message"); - let read_reply2 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id2 = ThinClient::new_message_id(); - let mut bob_reply_payload2 = vec![]; - - for i in 0..10 { - println!("Bob: second read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id2.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload2 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } + // Create a new keypair + let result = client.new_keypair(&seed).await; + if let Err(ref e) = result { + println!("new_keypair error: {:?}", e); } + assert!(result.is_ok(), "new_keypair should succeed: {:?}", result.err()); - assert_eq!(second_message, bob_reply_payload2.as_slice(), "Bob: Second reply payload mismatch"); - - // Clean up channels - alice_thin_client.close_channel(alice_channel_id).await?; - bob_thin_client.close_channel(bob_channel_id).await?; + let (write_cap, read_cap, first_index) = result.unwrap(); - alice_thin_client.stop().await; - bob_thin_client.stop().await; + // Verify we got non-empty capabilities + assert!(!write_cap.is_empty(), "WriteCap should not be empty"); + assert!(!read_cap.is_empty(), "ReadCap should not be empty"); + assert!(!first_index.is_empty(), "First message index should not be empty"); - println!("✅ Channel API basics test completed successfully"); - Ok(()) + println!("✓ Created keypair successfully"); + println!(" WriteCap length: {}", write_cap.len()); + println!(" ReadCap length: {}", read_cap.len()); + println!(" First index length: {}", first_index.len()); } -/// Test resuming a write channel - equivalent to TestResumeWriteChannel from Go -/// This test demonstrates the write channel resumption workflow: -/// 1. Create a write channel -/// 2. Write the first message onto the channel -/// 3. Close the channel -/// 4. Resume the channel -/// 5. Write the second message onto the channel -/// 6. Create a read channel -/// 7. Read first and second message from the channel -/// 8. Verify payloads match #[tokio::test] -async fn test_resume_write_channel() -> Result<(), Box> { - let alice_thin_client = setup_thin_client().await?; - let bob_thin_client = setup_thin_client().await?; - - // Wait for PKI documents to be available and connection to mixnet - println!("Waiting for daemon to connect to mixnet..."); - let mut attempts = 0; - while !alice_thin_client.is_connected() && attempts < 30 { - tokio::time::sleep(Duration::from_secs(1)).await; - attempts += 1; - } - - if !alice_thin_client.is_connected() { - return Err("Daemon failed to connect to mixnet within 30 seconds".into()); - } - - println!("✅ Daemon connected to mixnet, using current PKI document"); - - // Alice creates write channel - println!("Alice: Creating write channel"); - let (alice_channel_id, read_cap, write_cap) = alice_thin_client.create_write_channel().await?; - println!("Alice: Created write channel {}", alice_channel_id); - - // Alice writes first message - let alice_payload1 = b"Hello, Bob!"; - println!("Alice: Writing first message"); - let write_reply1 = alice_thin_client.write_channel(alice_channel_id, alice_payload1).await?; - - // Get courier destination - let (dest_node, dest_queue) = alice_thin_client.get_courier_destination().await?; - let alice_message_id1 = ThinClient::new_message_id(); - - // Send first message - let _reply1 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id1 - ).await?; - - println!("Waiting for first message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Close the channel - alice_thin_client.close_channel(alice_channel_id).await?; - - // Resume the write channel - println!("Alice: Resuming write channel"); - let alice_channel_id = alice_thin_client.resume_write_channel( - write_cap, - Some(write_reply1.next_message_index) - ).await?; - println!("Alice: Resumed write channel with ID {}", alice_channel_id); - - // Write second message after resume - println!("Alice: Writing second message after resume"); - let alice_payload2 = b"Second message from Alice!"; - let write_reply2 = - alice_thin_client.write_channel(alice_channel_id, alice_payload2).await?; - - let alice_message_id2 = ThinClient::new_message_id(); - let _reply2 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id2 - ).await?; - println!("Alice: Second write operation completed successfully"); - - println!("Waiting for second message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Bob creates read channel - println!("Bob: Creating read channel"); - let bob_channel_id = bob_thin_client.create_read_channel(read_cap).await?; - println!("Bob: Created read channel {}", bob_channel_id); - - // Bob reads first message - println!("Bob: Reading first message"); - let read_reply1 = - bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id1 = ThinClient::new_message_id(); - let mut bob_reply_payload1 = vec![]; - - for i in 0..10 { - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id1.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload1 = payload; - break; - } - Ok(_) => { - println!("Bob: First read attempt {} returned empty payload, retrying...", i + 1); - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } - - assert_eq!(alice_payload1, bob_reply_payload1.as_slice(), "Bob: First message payload mismatch"); - - // Bob reads second message - println!("Bob: Reading second message"); - let read_reply2 = - bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id2 = ThinClient::new_message_id(); - let mut bob_reply_payload2 = vec![]; - - for i in 0..10 { - println!("Bob: second message read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id2.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload2 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } - - // Verify the second message content matches - assert_eq!(alice_payload2, bob_reply_payload2.as_slice(), "Bob: Second message payload mismatch"); - println!("Bob: Successfully received and verified second message"); - - // Clean up channels - alice_thin_client.close_channel(alice_channel_id).await?; - bob_thin_client.close_channel(bob_channel_id).await?; - - alice_thin_client.stop().await; - bob_thin_client.stop().await; - - println!("✅ Resume write channel test completed successfully"); - Ok(()) +async fn test_alice_sends_bob_complete_workflow() { + println!("\n=== Test: Complete Alice sends to Bob workflow ==="); + + let alice_client = setup_thin_client().await.expect("Failed to setup Alice client"); + let bob_client = setup_thin_client().await.expect("Failed to setup Bob client"); + + // Alice creates a keypair + let alice_seed: [u8; 32] = rand::random(); + let (alice_write_cap, bob_read_cap, first_index) = alice_client.new_keypair(&alice_seed).await + .expect("Failed to create Alice's keypair"); + println!("✓ Alice created keypair"); + + // Alice encrypts and sends a message + let message = b"Hello Bob, this is Alice!"; + let (ciphertext, env_desc, env_hash) = alice_client + .encrypt_write(message, &alice_write_cap, &first_index).await + .expect("Failed to encrypt write"); + println!("✓ Alice encrypted message"); + + // Alice starts resending the encrypted message + let _alice_plaintext = alice_client.start_resending_encrypted_message( + None, + Some(&alice_write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await.expect("Failed to start resending"); + + println!("✓ Alice sent message via ARQ"); + + // Wait for message propagation + println!("Waiting for message propagation..."); + tokio::time::sleep(Duration::from_secs(5)).await; + + // Bob encrypts a read operation + let (bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash) = bob_client + .encrypt_read(&bob_read_cap, &first_index).await + .expect("Failed to encrypt read"); + println!("✓ Bob encrypted read operation"); + + // Bob starts resending to retrieve the message + let bob_plaintext = bob_client.start_resending_encrypted_message( + Some(&bob_read_cap), + None, + Some(&bob_next_index), + 0, + &bob_env_desc, + &bob_ciphertext, + &bob_env_hash + ).await.expect("Failed to retrieve message"); + + println!("✓ Bob received message"); + + // Verify the message matches + assert_eq!(bob_plaintext, message, "Bob should receive Alice's message"); + + println!("✅ Complete workflow test passed!"); + println!(" Message sent: {:?}", String::from_utf8_lossy(message)); + println!(" Message received: {:?}", String::from_utf8_lossy(&bob_plaintext)); } -/// Test resuming a write channel with query state - equivalent to TestResumeWriteChannelQuery from Go -/// This test demonstrates the write channel query resumption workflow: -/// 1. Create write channel -/// 2. Create first write query message but do not send to channel yet -/// 3. Close channel -/// 4. Resume write channel with query via ResumeWriteChannelQuery -/// 5. Send resumed write query to channel -/// 6. Send second message to channel -/// 7. Create read channel -/// 8. Read both messages from channel -/// 9. Verify payloads match #[tokio::test] -async fn test_resume_write_channel_query() -> Result<(), Box> { - let alice_thin_client = setup_thin_client().await?; - let bob_thin_client = setup_thin_client().await?; - - // Wait for PKI documents to be available and connection to mixnet - println!("Waiting for daemon to connect to mixnet..."); - let mut attempts = 0; - while !alice_thin_client.is_connected() && attempts < 30 { - tokio::time::sleep(Duration::from_secs(1)).await; - attempts += 1; - } +async fn test_next_message_box_index() { + println!("\n=== Test: next_message_box_index ==="); - if !alice_thin_client.is_connected() { - return Err("Daemon failed to connect to mixnet within 30 seconds".into()); - } + let client = setup_thin_client().await.expect("Failed to setup client"); - println!("✅ Daemon connected to mixnet, using current PKI document"); - - // Alice creates write channel - println!("Alice: Creating write channel"); - let (alice_channel_id, read_cap, write_cap) = alice_thin_client.create_write_channel().await?; - println!("Alice: Created write channel {}", alice_channel_id); - - // Alice prepares first message but doesn't send it yet - let alice_payload1 = b"Hello, Bob!"; - let write_reply = alice_thin_client.write_channel(alice_channel_id, alice_payload1).await?; - - // Get courier destination - let (courier_node, courier_queue_id) = alice_thin_client.get_courier_destination().await?; - let alice_message_id1 = ThinClient::new_message_id(); - - // Close the channel immediately (like in Go test - no waiting for propagation) - alice_thin_client.close_channel(alice_channel_id).await?; - - // Resume the write channel with query state using current_message_index like Go test - println!("Alice: Resuming write channel"); - let alice_channel_id = alice_thin_client.resume_write_channel_query( - write_cap, - write_reply.current_message_index, // Use current_message_index like in Go test - write_reply.envelope_descriptor, - write_reply.envelope_hash - ).await?; - println!("Alice: Resumed write channel with ID {}", alice_channel_id); - - // Send the first message after resume - println!("Alice: Writing first message after resume"); - let _reply1 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply.send_message_payload, - courier_node.clone(), - courier_queue_id.clone(), - alice_message_id1 - ).await?; - - // Write second message - println!("Alice: Writing second message"); - let alice_payload2 = b"Second message from Alice!"; - let write_reply2 = - alice_thin_client.write_channel(alice_channel_id, alice_payload2).await?; - - let alice_message_id2 = ThinClient::new_message_id(); - let _reply2 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply2.send_message_payload, - courier_node.clone(), - courier_queue_id.clone(), - alice_message_id2 - ).await?; - println!("Alice: Second write operation completed successfully"); - - println!("Waiting for second message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Bob creates read channel - println!("Bob: Creating read channel"); - let bob_channel_id = bob_thin_client.create_read_channel(read_cap).await?; - println!("Bob: Created read channel {}", bob_channel_id); - - // Bob reads first message - println!("Bob: Reading first message"); - let read_reply1 = - bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id1 = ThinClient::new_message_id(); - let mut bob_reply_payload1 = vec![]; - - for i in 0..10 { - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply1.send_message_payload, - courier_node.clone(), - courier_queue_id.clone(), - bob_message_id1.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload1 = payload; - break; - } - Ok(_) => { - println!("Bob: First read attempt {} returned empty payload, retrying...", i + 1); - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } + // Generate keypair to get a first_index + let seed: [u8; 32] = rand::random(); + let (_write_cap, _read_cap, first_index) = client.new_keypair(&seed).await + .expect("Failed to create keypair"); - assert_eq!(alice_payload1, bob_reply_payload1.as_slice(), "Bob: First message payload mismatch"); - - // Bob reads second message - println!("Bob: Reading second message"); - let read_reply2 = - bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id2 = ThinClient::new_message_id(); - let mut bob_reply_payload2 = vec![]; - - for i in 0..10 { - println!("Bob: second message read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply2.send_message_payload, - courier_node.clone(), - courier_queue_id.clone(), - bob_message_id2.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload2 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } + println!("✓ Created keypair"); + println!(" First index length: {}", first_index.len()); + + // Increment the index + let second_index = client.next_message_box_index(&first_index).await + .expect("Failed to get next message box index"); - // Verify the second message content matches - assert_eq!(alice_payload2, bob_reply_payload2.as_slice(), "Bob: Second message payload mismatch"); - println!("Bob: Successfully received and verified second message"); + assert!(!second_index.is_empty(), "Second index should not be empty"); + assert_ne!(first_index, second_index, "Second index should differ from first"); + println!("✓ Got second index (length: {})", second_index.len()); - // Clean up channels - alice_thin_client.close_channel(alice_channel_id).await?; - bob_thin_client.close_channel(bob_channel_id).await?; + // Increment again + let third_index = client.next_message_box_index(&second_index).await + .expect("Failed to get third message box index"); - alice_thin_client.stop().await; - bob_thin_client.stop().await; + assert!(!third_index.is_empty(), "Third index should not be empty"); + assert_ne!(second_index, third_index, "Third index should differ from second"); + println!("✓ Got third index (length: {})", third_index.len()); - println!("✅ Resume write channel query test completed successfully"); - Ok(()) + println!("✅ next_message_box_index test passed!"); } -/// Test resuming a read channel - equivalent to TestResumeReadChannel from Go -/// This test demonstrates the read channel resumption workflow: -/// 1. Create a write channel -/// 2. Write two messages to the channel -/// 3. Create a read channel -/// 4. Read the first message from the channel -/// 5. Verify payload matches -/// 6. Close the read channel -/// 7. Resume the read channel -/// 8. Read the second message from the channel -/// 9. Verify payload matches #[tokio::test] -async fn test_resume_read_channel() -> Result<(), Box> { - let alice_thin_client = setup_thin_client().await?; - let bob_thin_client = setup_thin_client().await?; - - // Wait for PKI documents to be available and connection to mixnet - println!("Waiting for daemon to connect to mixnet..."); - let mut attempts = 0; - while !alice_thin_client.is_connected() && attempts < 30 { - tokio::time::sleep(Duration::from_secs(1)).await; - attempts += 1; - } - - if !alice_thin_client.is_connected() { - return Err("Daemon failed to connect to mixnet within 30 seconds".into()); - } - - println!("✅ Daemon connected to mixnet, using current PKI document"); - - // Alice creates write channel - println!("Alice: Creating write channel"); - let (alice_channel_id, read_cap, _write_cap) = alice_thin_client.create_write_channel().await?; - println!("Alice: Created write channel {}", alice_channel_id); - - // Alice writes first message - let alice_payload1 = b"Hello, Bob!"; - let write_reply1 = - alice_thin_client.write_channel(alice_channel_id, alice_payload1).await?; - - let (dest_node, dest_queue) = alice_thin_client.get_courier_destination().await?; - let alice_message_id1 = ThinClient::new_message_id(); - - let _reply1 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id1 - ).await?; - - println!("Waiting for first message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Alice writes second message - println!("Alice: Writing second message"); - let alice_payload2 = b"Second message from Alice!"; - let write_reply2 = - alice_thin_client.write_channel(alice_channel_id, alice_payload2).await?; - - let alice_message_id2 = ThinClient::new_message_id(); - let _reply2 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id2 - ).await?; - println!("Alice: Second write operation completed successfully"); - - println!("Waiting for second message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Bob creates read channel - println!("Bob: Creating read channel"); - let bob_channel_id = bob_thin_client.create_read_channel(read_cap.clone()).await?; - println!("Bob: Created read channel {}", bob_channel_id); - - // Bob reads first message - println!("Bob: Reading first message"); - let read_reply1 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id1 = ThinClient::new_message_id(); - let mut bob_reply_payload1 = vec![]; - - for i in 0..10 { - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id1.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload1 = payload; - break; - } - Ok(_) => { - println!("Bob: First read attempt {} returned empty payload, retrying...", i + 1); - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } +async fn test_create_courier_envelopes_from_payload() { + println!("\n=== Test: create_courier_envelopes_from_payload with Copy Command ==="); + + let alice_client = setup_thin_client().await.expect("Failed to setup Alice client"); + let bob_client = setup_thin_client().await.expect("Failed to setup Bob client"); + + // Step 1: Alice creates destination channel + println!("\n--- Step 1: Creating destination channel ---"); + let dest_seed: [u8; 32] = rand::random(); + let (dest_write_cap, dest_read_cap, dest_first_index) = alice_client.new_keypair(&dest_seed).await + .expect("Failed to create destination keypair"); + println!("✓ Alice created destination channel"); + + // Step 2: Alice creates temporary copy stream channel + println!("\n--- Step 2: Creating temporary copy stream channel ---"); + let temp_seed: [u8; 32] = rand::random(); + let (temp_write_cap, _temp_read_cap, temp_first_index) = alice_client.new_keypair(&temp_seed).await + .expect("Failed to create temp keypair"); + println!("✓ Alice created temporary copy stream channel"); + + // Step 3: Create a payload with length prefix (like Go/Python tests) + println!("\n--- Step 3: Creating payload ---"); + let random_data: Vec = (0..100).map(|_| rand::random::()).collect(); + let mut large_payload = Vec::new(); + large_payload.extend_from_slice(&(random_data.len() as u32).to_be_bytes()); + large_payload.extend_from_slice(&random_data); + println!("✓ Alice created payload ({} bytes)", large_payload.len()); + + // Step 4: Create copy stream chunks from the payload + println!("\n--- Step 4: Creating copy stream chunks ---"); + let stream_id = ThinClient::new_stream_id(); + let copy_stream_chunks = alice_client.create_courier_envelopes_from_payload( + &stream_id, + &large_payload, + &dest_write_cap, + &dest_first_index, + true // is_last + ).await.expect("Failed to create courier envelopes from payload"); + + assert!(!copy_stream_chunks.is_empty(), "Should have at least one chunk"); + println!("✓ Alice created {} copy stream chunks", copy_stream_chunks.len()); + + // Step 5: Write all copy stream chunks to the temporary channel + println!("\n--- Step 5: Writing copy stream chunks to temp channel ---"); + let mut temp_index = temp_first_index.clone(); + for (i, chunk) in copy_stream_chunks.iter().enumerate() { + let (ciphertext, env_desc, env_hash) = alice_client + .encrypt_write(chunk, &temp_write_cap, &temp_index).await + .expect("Failed to encrypt chunk"); + + let _ = alice_client.start_resending_encrypted_message( + None, + Some(&temp_write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await.expect("Failed to send chunk via ARQ"); + + println!(" ✓ Wrote chunk {} ({} bytes)", i + 1, chunk.len()); + + // Advance to next index for next chunk + temp_index = alice_client.next_message_box_index(&temp_index).await + .expect("Failed to get next index"); } - assert_eq!(alice_payload1, bob_reply_payload1.as_slice(), "Bob: First message payload mismatch"); - - // Close the read channel - bob_thin_client.close_channel(bob_channel_id).await?; - - // Resume the read channel - println!("Bob: Resuming read channel"); - let bob_channel_id = bob_thin_client.resume_read_channel( - read_cap, - Some(read_reply1.next_message_index), - read_reply1.reply_index - ).await?; - println!("Bob: Resumed read channel with ID {}", bob_channel_id); - - // Bob reads second message - println!("Bob: Reading second message"); - let read_reply2 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id2 = ThinClient::new_message_id(); - let mut bob_reply_payload2 = vec![]; - - for i in 0..10 { - println!("Bob: second message read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id2.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload2 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } - - // Verify the second message content matches - assert_eq!(alice_payload2, bob_reply_payload2.as_slice(), "Bob: Second message payload mismatch"); - println!("Bob: Successfully received and verified second message"); - - // Clean up channels - alice_thin_client.close_channel(alice_channel_id).await?; - bob_thin_client.close_channel(bob_channel_id).await?; - - alice_thin_client.stop().await; - bob_thin_client.stop().await; - - println!("✅ Resume read channel test completed successfully"); - Ok(()) + // Wait for chunks to propagate + println!("\n--- Waiting for copy stream chunks to propagate (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; + + // Step 6: Send Copy command to courier + println!("\n--- Step 6: Sending Copy command to courier via ARQ ---"); + alice_client.start_resending_copy_command(&temp_write_cap, None, None).await + .expect("Failed to send copy command"); + println!("✓ Alice copy command completed"); + + // Wait for copy command to execute + println!("\n--- Waiting for copy command to execute (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; + + // Step 7: Bob reads from destination channel + println!("\n--- Step 7: Bob reads from destination channel ---"); + let (bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash) = bob_client + .encrypt_read(&dest_read_cap, &dest_first_index).await + .expect("Failed to encrypt read"); + + let bob_plaintext = bob_client.start_resending_encrypted_message( + Some(&dest_read_cap), + None, + Some(&bob_next_index), + 0, + &bob_env_desc, + &bob_ciphertext, + &bob_env_hash + ).await.expect("Failed to retrieve message"); + + println!("✓ Bob received {} bytes", bob_plaintext.len()); + + // Verify the payload matches + assert_eq!(bob_plaintext, large_payload, "Received payload should match original"); + + println!("✅ create_courier_envelopes_from_payload test passed!"); } -/// Test resuming a read channel with query state - equivalent to TestResumeReadChannelQuery from Go -/// This test demonstrates the read channel query resumption workflow: -/// 1. Create a write channel -/// 2. Write two messages to the channel -/// 3. Create read channel -/// 4. Make read query but do not send it -/// 5. Close read channel -/// 6. Resume read channel query with ResumeReadChannelQuery method -/// 7. Send previously made read query to channel -/// 8. Verify received payload matches -/// 9. Read second message from channel -/// 10. Verify received payload matches #[tokio::test] -async fn test_resume_read_channel_query() -> Result<(), Box> { - let alice_thin_client = setup_thin_client().await?; - let bob_thin_client = setup_thin_client().await?; - - // Wait for PKI documents to be available and connection to mixnet - println!("Waiting for daemon to connect to mixnet..."); - let mut attempts = 0; - while !alice_thin_client.is_connected() && attempts < 30 { - tokio::time::sleep(Duration::from_secs(1)).await; - attempts += 1; +async fn test_create_courier_envelopes_from_payloads_multi_channel() { + println!("\n=== Test: create_courier_envelopes_from_payloads (efficient multi-channel) ==="); + + let alice_client = setup_thin_client().await.expect("Failed to setup Alice client"); + let bob_client = setup_thin_client().await.expect("Failed to setup Bob client"); + + // Step 1: Create two destination channels + println!("\n--- Step 1: Creating two destination channels ---"); + let chan1_seed: [u8; 32] = rand::random(); + let (chan1_write_cap, chan1_read_cap, chan1_first_index) = alice_client.new_keypair(&chan1_seed).await + .expect("Failed to create channel 1 keypair"); + println!("✓ Created Channel 1"); + + let chan2_seed: [u8; 32] = rand::random(); + let (chan2_write_cap, chan2_read_cap, chan2_first_index) = alice_client.new_keypair(&chan2_seed).await + .expect("Failed to create channel 2 keypair"); + println!("✓ Created Channel 2"); + + // Step 2: Create temporary copy stream channel + println!("\n--- Step 2: Creating temporary copy stream channel ---"); + let temp_seed: [u8; 32] = rand::random(); + let (temp_write_cap, _temp_read_cap, temp_first_index) = alice_client.new_keypair(&temp_seed).await + .expect("Failed to create temp keypair"); + println!("✓ Created temporary copy stream channel"); + + // Step 3: Create payloads for each channel + println!("\n--- Step 3: Creating payloads ---"); + let payload1 = b"Hello from Channel 1! This is payload one.".to_vec(); + let payload2 = b"Hello from Channel 2! This is payload two.".to_vec(); + println!("✓ Created payload1 ({} bytes) and payload2 ({} bytes)", payload1.len(), payload2.len()); + + // Step 4: Create copy stream chunks using efficient multi-destination API + println!("\n--- Step 4: Creating copy stream chunks using efficient API ---"); + let stream_id = ThinClient::new_stream_id(); + + let destinations = vec![ + (payload1.as_slice(), chan1_write_cap.as_slice(), chan1_first_index.as_slice()), + (payload2.as_slice(), chan2_write_cap.as_slice(), chan2_first_index.as_slice()), + ]; + + let all_chunks = alice_client.create_courier_envelopes_from_payloads( + &stream_id, + destinations, + true // is_last + ).await.expect("Failed to create courier envelopes from payloads"); + + assert!(!all_chunks.is_empty(), "Should have at least one chunk"); + println!("✓ Created {} copy stream chunks for both destinations", all_chunks.len()); + + // Step 5: Write all chunks to temporary channel + println!("\n--- Step 5: Writing copy stream chunks to temp channel ---"); + let mut temp_index = temp_first_index.clone(); + for (i, chunk) in all_chunks.iter().enumerate() { + let (ciphertext, env_desc, env_hash) = alice_client + .encrypt_write(chunk, &temp_write_cap, &temp_index).await + .expect("Failed to encrypt chunk"); + + let _ = alice_client.start_resending_encrypted_message( + None, + Some(&temp_write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await.expect("Failed to send chunk via ARQ"); + + println!(" ✓ Wrote chunk {} ({} bytes)", i + 1, chunk.len()); + + temp_index = alice_client.next_message_box_index(&temp_index).await + .expect("Failed to get next index"); } - if !alice_thin_client.is_connected() { - return Err("Daemon failed to connect to mixnet within 30 seconds".into()); - } + // Wait for chunks to propagate + println!("\n--- Waiting for copy stream chunks to propagate (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; + + // Step 6: Send Copy command + println!("\n--- Step 6: Sending Copy command via ARQ ---"); + alice_client.start_resending_copy_command(&temp_write_cap, None, None).await + .expect("Failed to send copy command"); + println!("✓ Copy command completed"); + + // Wait for copy command to execute + println!("\n--- Waiting for copy command to execute (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; + + // Step 7: Bob reads from Channel 1 + println!("\n--- Step 7: Bob reads from Channel 1 ---"); + let (bob1_ciphertext, bob1_next_index, bob1_env_desc, bob1_env_hash) = bob_client + .encrypt_read(&chan1_read_cap, &chan1_first_index).await + .expect("Failed to encrypt read for channel 1"); + + let bob1_plaintext = bob_client.start_resending_encrypted_message( + Some(&chan1_read_cap), + None, + Some(&bob1_next_index), + 0, + &bob1_env_desc, + &bob1_ciphertext, + &bob1_env_hash + ).await.expect("Failed to retrieve from channel 1"); + + println!("✓ Bob received from Channel 1: {:?}", String::from_utf8_lossy(&bob1_plaintext)); + assert_eq!(bob1_plaintext, payload1, "Channel 1 payload mismatch"); + + // Step 8: Bob reads from Channel 2 + println!("\n--- Step 8: Bob reads from Channel 2 ---"); + let (bob2_ciphertext, bob2_next_index, bob2_env_desc, bob2_env_hash) = bob_client + .encrypt_read(&chan2_read_cap, &chan2_first_index).await + .expect("Failed to encrypt read for channel 2"); + + let bob2_plaintext = bob_client.start_resending_encrypted_message( + Some(&chan2_read_cap), + None, + Some(&bob2_next_index), + 0, + &bob2_env_desc, + &bob2_ciphertext, + &bob2_env_hash + ).await.expect("Failed to retrieve from channel 2"); + + println!("✓ Bob received from Channel 2: {:?}", String::from_utf8_lossy(&bob2_plaintext)); + assert_eq!(bob2_plaintext, payload2, "Channel 2 payload mismatch"); + + println!("✅ create_courier_envelopes_from_payloads multi-channel test passed!"); +} - println!("✅ Daemon connected to mixnet, using current PKI document"); - - // Alice creates write channel - println!("Alice: Creating write channel"); - let (alice_channel_id, read_cap, _write_cap) = alice_thin_client.create_write_channel().await?; - println!("Alice: Created write channel {}", alice_channel_id); - - // Alice writes first message - let alice_payload1 = b"Hello, Bob!"; - let write_reply1 = - alice_thin_client.write_channel(alice_channel_id, alice_payload1).await?; - - let (dest_node, dest_queue) = alice_thin_client.get_courier_destination().await?; - let alice_message_id1 = ThinClient::new_message_id(); - - let _reply1 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id1 - ).await?; - - println!("Waiting for first message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Alice writes second message - println!("Alice: Writing second message"); - let alice_payload2 = b"Second message from Alice!"; - let write_reply2 = - alice_thin_client.write_channel(alice_channel_id, alice_payload2).await?; - - let alice_message_id2 = ThinClient::new_message_id(); - let _reply2 = alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - &write_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - alice_message_id2 - ).await?; - println!("Alice: Second write operation completed successfully"); - - println!("Waiting for second message propagation to storage replicas"); - tokio::time::sleep(Duration::from_secs(3)).await; - - // Bob creates read channel - println!("Bob: Creating read channel"); - let bob_channel_id = bob_thin_client.create_read_channel(read_cap.clone()).await?; - println!("Bob: Created read channel {}", bob_channel_id); - - // Bob prepares first read query but doesn't send it yet - println!("Bob: Reading first message"); - let read_reply1 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - // Close the read channel - bob_thin_client.close_channel(bob_channel_id).await?; - - // Resume the read channel with query state - println!("Bob: Resuming read channel"); - let bob_channel_id = bob_thin_client.resume_read_channel_query( - read_cap, - read_reply1.current_message_index, - read_reply1.reply_index, - read_reply1.envelope_descriptor, - read_reply1.envelope_hash - ).await?; - println!("Bob: Resumed read channel with ID {}", bob_channel_id); - - // Send the first read query and get the message payload - let bob_message_id1 = ThinClient::new_message_id(); - let mut bob_reply_payload1 = vec![]; - - for i in 0..10 { - println!("Bob: first message read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply1.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id1.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload1 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), - } - } +#[tokio::test] +async fn test_tombstone_box() { + println!("\n=== Test: tombstone_box ==="); + + let alice_client = setup_thin_client().await.expect("Failed to setup Alice client"); + let bob_client = setup_thin_client().await.expect("Failed to setup Bob client"); + + // Get the geometry from the config - this ensures we use the correct payload size + let geometry = alice_client.pigeonhole_geometry().clone(); + + // Create keypair + let seed: [u8; 32] = rand::random(); + let (write_cap, read_cap, first_index) = alice_client.new_keypair(&seed).await + .expect("Failed to create keypair"); + println!("✓ Created keypair"); + + // Step 1: Alice writes a message + println!("\n--- Step 1: Alice writes a message ---"); + let message = b"Secret message that will be tombstoned"; + let (ciphertext, env_desc, env_hash) = alice_client + .encrypt_write(message, &write_cap, &first_index).await + .expect("Failed to encrypt write"); + + let _ = alice_client.start_resending_encrypted_message( + None, + Some(&write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await.expect("Failed to send message"); + println!("✓ Alice wrote message"); + + // Wait for message propagation + println!("--- Waiting for message propagation (5 seconds) ---"); + tokio::time::sleep(Duration::from_secs(5)).await; + + // Step 2: Bob reads and verifies + println!("\n--- Step 2: Bob reads and verifies ---"); + let (bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash) = bob_client + .encrypt_read(&read_cap, &first_index).await + .expect("Failed to encrypt read"); + + let bob_plaintext = bob_client.start_resending_encrypted_message( + Some(&read_cap), + None, + Some(&bob_next_index), + 0, + &bob_env_desc, + &bob_ciphertext, + &bob_env_hash + ).await.expect("Failed to read message"); + + assert_eq!(bob_plaintext, message, "Message mismatch"); + println!("✓ Bob read message: {:?}", String::from_utf8_lossy(&bob_plaintext)); + + // Step 3: Alice tombstones the box + println!("\n--- Step 3: Alice tombstones the box ---"); + alice_client.tombstone_box(&geometry, &write_cap, &first_index).await + .expect("Failed to tombstone box"); + println!("✓ Alice tombstoned the box"); + + // Wait for tombstone propagation + println!("--- Waiting for tombstone propagation (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; + + // Step 4: Bob reads again and verifies tombstone + println!("\n--- Step 4: Bob reads again and verifies tombstone ---"); + let (bob_ciphertext2, bob_next_index2, bob_env_desc2, bob_env_hash2) = bob_client + .encrypt_read(&read_cap, &first_index).await + .expect("Failed to encrypt read for tombstone"); + + let bob_plaintext2 = bob_client.start_resending_encrypted_message( + Some(&read_cap), + None, + Some(&bob_next_index2), + 0, + &bob_env_desc2, + &bob_ciphertext2, + &bob_env_hash2 + ).await.expect("Failed to read tombstone"); + + assert!(is_tombstone_plaintext(&geometry, &bob_plaintext2), "Expected tombstone (all zeros)"); + println!("✓ Bob verified tombstone (all zeros)"); + + println!("✅ tombstone_box test passed!"); +} - assert_eq!(alice_payload1, bob_reply_payload1.as_slice(), "Bob: First message payload mismatch"); - - // Bob reads second message - println!("Bob: Reading second message"); - let read_reply2 = bob_thin_client.read_channel(bob_channel_id, None, None).await?; - - let bob_message_id2 = ThinClient::new_message_id(); - let mut bob_reply_payload2 = vec![]; - - for i in 0..10 { - println!("Bob: second message read attempt {}", i + 1); - match alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - &read_reply2.send_message_payload, - dest_node.clone(), - dest_queue.clone(), - bob_message_id2.clone() - ).await { - Ok(payload) if !payload.is_empty() => { - bob_reply_payload2 = payload; - break; - } - Ok(_) => { - tokio::time::sleep(Duration::from_millis(500)).await; - } - Err(e) => return Err(e.into()), +#[tokio::test] +async fn test_tombstone_range() { + println!("\n=== Test: tombstone_range ==="); + + let alice_client = setup_thin_client().await.expect("Failed to setup Alice client"); + + // Get the geometry from the config + let geometry = alice_client.pigeonhole_geometry().clone(); + + // Create keypair + let seed: [u8; 32] = rand::random(); + let (write_cap, _read_cap, first_index) = alice_client.new_keypair(&seed).await + .expect("Failed to create keypair"); + println!("✓ Created keypair"); + + // Write 3 messages to sequential boxes + let num_messages: u32 = 3; + let mut current_index = first_index.clone(); + + println!("\n--- Writing {} messages ---", num_messages); + for i in 0..num_messages { + let message = format!("Message {} to be tombstoned", i + 1); + let (ciphertext, env_desc, env_hash) = alice_client + .encrypt_write(message.as_bytes(), &write_cap, ¤t_index).await + .expect("Failed to encrypt write"); + + let _ = alice_client.start_resending_encrypted_message( + None, + Some(&write_cap), + None, + 0, + &env_desc, + &ciphertext, + &env_hash + ).await.expect("Failed to send message"); + println!("✓ Wrote message {}", i + 1); + + if i < num_messages - 1 { + current_index = alice_client.next_message_box_index(¤t_index).await + .expect("Failed to get next index"); } } - // Verify the second message content matches - assert_eq!(alice_payload2, bob_reply_payload2.as_slice(), "Bob: Second message payload mismatch"); - println!("Bob: Successfully received and verified second message"); + // Wait for messages to propagate + println!("--- Waiting for message propagation (30 seconds) ---"); + tokio::time::sleep(Duration::from_secs(30)).await; - // Clean up channels - alice_thin_client.close_channel(alice_channel_id).await?; - bob_thin_client.close_channel(bob_channel_id).await?; + // Tombstone the range + println!("\n--- Tombstoning {} boxes ---", num_messages); + let result = alice_client.tombstone_range(&geometry, &write_cap, &first_index, num_messages).await; - alice_thin_client.stop().await; - bob_thin_client.stop().await; + println!("✓ Tombstoned {} boxes", result.tombstoned); + assert_eq!(result.tombstoned, num_messages, "Expected {} tombstoned, got {}", num_messages, result.tombstoned); + assert!(result.error.is_none(), "Unexpected error: {:?}", result.error); + assert!(!result.next.is_empty(), "Next index should not be empty"); - println!("✅ Resume read channel query test completed successfully"); - Ok(()) + println!("✅ tombstone_range test passed! Tombstoned {} boxes successfully!", num_messages); } diff --git a/tests/test_channel_api.py b/tests/test_channel_api.py deleted file mode 100644 index 0e05ba5..0000000 --- a/tests/test_channel_api.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (C) 2025 David Stainton -# SPDX-License-Identifier: AGPL-3.0-only - -""" -Channel API integration tests for the Python thin client. - -These tests mirror the Rust tests in channel_api_test.rs and require -a running mixnet with client daemon for integration testing. -""" - -import asyncio -import pytest -from katzenpost_thinclient import ThinClient, Config - - -async def setup_thin_client(): - """Test helper to setup a thin client for integration tests.""" - config = Config("testdata/thinclient.toml") - client = ThinClient(config) - - # Start the client and wait a bit for initial connection and PKI document - loop = asyncio.get_running_loop() - await client.start(loop) - await asyncio.sleep(2) - - return client - - -@pytest.mark.asyncio -async def test_channel_api_basics(): - """ - Test basic channel API operations - equivalent to TestChannelAPIBasics from Rust. - This test demonstrates the full channel workflow: Alice creates a write channel, - Bob creates a read channel, Alice writes messages, Bob reads them back. - """ - alice_thin_client = await setup_thin_client() - bob_thin_client = await setup_thin_client() - - # Wait for PKI documents to be available and connection to mixnet - print("Waiting for daemon to connect to mixnet...") - attempts = 0 - while not alice_thin_client.is_connected() and attempts < 30: - await asyncio.sleep(1) - attempts += 1 - - if not alice_thin_client.is_connected(): - raise Exception("Daemon failed to connect to mixnet within 30 seconds") - - print("✅ Daemon connected to mixnet, using current PKI document") - - # Alice creates write channel - print("Alice: Creating write channel") - alice_channel_id, read_cap, _write_cap = await alice_thin_client.create_write_channel() - print(f"Alice: Created write channel {alice_channel_id}") - - # Bob creates read channel using the read capability from Alice's write channel - print("Bob: Creating read channel") - bob_channel_id = await bob_thin_client.create_read_channel(read_cap) - print(f"Bob: Created read channel {bob_channel_id}") - - # Alice writes first message - original_message = b"hello1" - print("Alice: Writing first message and waiting for completion") - - write_reply1 = await alice_thin_client.write_channel(alice_channel_id, original_message) - print("Alice: Write operation completed successfully") - - # Get the courier service from PKI - courier_service = alice_thin_client.get_service("courier") - dest_node, dest_queue = courier_service.to_destination() - - alice_message_id1 = ThinClient.new_message_id() - - _reply1 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply1.send_message_payload, - dest_node, - dest_queue, - alice_message_id1 - ) - - # Alice writes a second message - second_message = b"hello2" - print("Alice: Writing second message and waiting for completion") - - write_reply2 = await alice_thin_client.write_channel(alice_channel_id, second_message) - print("Alice: Second write operation completed successfully") - - alice_message_id2 = ThinClient.new_message_id() - - _reply2 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply2.send_message_payload, - dest_node, - dest_queue, - alice_message_id2 - ) - - # Wait for message propagation to storage replicas - print("Waiting for message propagation to storage replicas") - await asyncio.sleep(10) - - # Bob reads first message - print("Bob: Reading first message") - read_reply1 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id1 = ThinClient.new_message_id() - - # In a real implementation, you'd retry the send_channel_query_await_reply until you get a response - bob_reply_payload1 = b"" - for i in range(10): - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply1.send_message_payload, - dest_node, - dest_queue, - bob_message_id1 - ) - if payload: - bob_reply_payload1 = payload - break - else: - print(f"Bob: Read attempt {i + 1} returned empty payload, retrying...") - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert original_message == bob_reply_payload1, "Bob: Reply payload mismatch" - - # Bob closes and resumes read channel to advance to second message - await bob_thin_client.close_channel(bob_channel_id) - - print("Bob: Resuming read channel to read second message") - bob_channel_id = await bob_thin_client.resume_read_channel( - read_cap, - read_reply1.next_message_index, - read_reply1.reply_index - ) - - # Bob reads second message - print("Bob: Reading second message") - read_reply2 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id2 = ThinClient.new_message_id() - bob_reply_payload2 = b"" - - for i in range(10): - print(f"Bob: second read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply2.send_message_payload, - dest_node, - dest_queue, - bob_message_id2 - ) - if payload: - bob_reply_payload2 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert second_message == bob_reply_payload2, "Bob: Second reply payload mismatch" - - # Clean up channels - await alice_thin_client.close_channel(alice_channel_id) - await bob_thin_client.close_channel(bob_channel_id) - - alice_thin_client.stop() - bob_thin_client.stop() - - print("✅ Channel API basics test completed successfully") - - -@pytest.mark.asyncio -async def test_resume_write_channel(): - """ - Test resuming a write channel - equivalent to TestResumeWriteChannel from Rust. - This test demonstrates the write channel resumption workflow: - 1. Create a write channel - 2. Write the first message onto the channel - 3. Close the channel - 4. Resume the channel - 5. Write the second message onto the channel - 6. Create a read channel - 7. Read first and second message from the channel - 8. Verify payloads match - """ - alice_thin_client = await setup_thin_client() - bob_thin_client = await setup_thin_client() - - # Wait for PKI documents to be available and connection to mixnet - print("Waiting for daemon to connect to mixnet...") - attempts = 0 - while not alice_thin_client.is_connected() and attempts < 30: - await asyncio.sleep(1) - attempts += 1 - - if not alice_thin_client.is_connected(): - raise Exception("Daemon failed to connect to mixnet within 30 seconds") - - print("✅ Daemon connected to mixnet, using current PKI document") - - # Alice creates write channel - print("Alice: Creating write channel") - alice_channel_id, read_cap, write_cap = await alice_thin_client.create_write_channel() - print(f"Alice: Created write channel {alice_channel_id}") - - # Alice writes first message - alice_payload1 = b"Hello, Bob!" - print("Alice: Writing first message") - write_reply1 = await alice_thin_client.write_channel(alice_channel_id, alice_payload1) - - # Get courier destination - dest_node, dest_queue = await alice_thin_client.get_courier_destination() - alice_message_id1 = ThinClient.new_message_id() - - # Send first message - _reply1 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply1.send_message_payload, - dest_node, - dest_queue, - alice_message_id1 - ) - - print("Waiting for first message propagation to storage replicas") - await asyncio.sleep(3) - - # Close the channel - await alice_thin_client.close_channel(alice_channel_id) - - # Resume the write channel - print("Alice: Resuming write channel") - alice_channel_id = await alice_thin_client.resume_write_channel( - write_cap, - write_reply1.next_message_index - ) - print(f"Alice: Resumed write channel with ID {alice_channel_id}") - - # Write second message after resume - print("Alice: Writing second message after resume") - alice_payload2 = b"Second message from Alice!" - write_reply2 = await alice_thin_client.write_channel(alice_channel_id, alice_payload2) - - alice_message_id2 = ThinClient.new_message_id() - _reply2 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply2.send_message_payload, - dest_node, - dest_queue, - alice_message_id2 - ) - print("Alice: Second write operation completed successfully") - - print("Waiting for second message propagation to storage replicas") - await asyncio.sleep(3) - - # Bob creates read channel - print("Bob: Creating read channel") - bob_channel_id = await bob_thin_client.create_read_channel(read_cap) - print(f"Bob: Created read channel {bob_channel_id}") - - # Bob reads first message - print("Bob: Reading first message") - read_reply1 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id1 = ThinClient.new_message_id() - bob_reply_payload1 = b"" - - for i in range(10): - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply1.send_message_payload, - dest_node, - dest_queue, - bob_message_id1 - ) - if payload: - bob_reply_payload1 = payload - break - else: - print(f"Bob: First read attempt {i + 1} returned empty payload, retrying...") - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert alice_payload1 == bob_reply_payload1, "Bob: First message payload mismatch" - - # Bob closes and resumes read channel to advance to second message - await bob_thin_client.close_channel(bob_channel_id) - - print("Bob: Resuming read channel to read second message") - bob_channel_id = await bob_thin_client.resume_read_channel( - read_cap, - read_reply1.next_message_index, - read_reply1.reply_index - ) - - # Bob reads second message - print("Bob: Reading second message") - read_reply2 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id2 = ThinClient.new_message_id() - bob_reply_payload2 = b"" - - for i in range(10): - print(f"Bob: second message read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply2.send_message_payload, - dest_node, - dest_queue, - bob_message_id2 - ) - if payload: - bob_reply_payload2 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - # Verify the second message content matches - assert alice_payload2 == bob_reply_payload2, "Bob: Second message payload mismatch" - print("Bob: Successfully received and verified second message") - - # Clean up channels - await alice_thin_client.close_channel(alice_channel_id) - await bob_thin_client.close_channel(bob_channel_id) - - alice_thin_client.stop() - bob_thin_client.stop() - - print("✅ Resume write channel test completed successfully") - - -if __name__ == "__main__": - pytest.main([__file__]) diff --git a/tests/test_channel_api_extended.py b/tests/test_channel_api_extended.py deleted file mode 100644 index 14b6304..0000000 --- a/tests/test_channel_api_extended.py +++ /dev/null @@ -1,492 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (C) 2025 David Stainton -# SPDX-License-Identifier: AGPL-3.0-only - -""" -Extended channel API integration tests for the Python thin client. -These tests cover the more advanced channel resumption scenarios. -""" - -import asyncio -import pytest -from katzenpost_thinclient import ThinClient, Config - - -async def setup_thin_client(): - """Test helper to setup a thin client for integration tests.""" - config = Config("testdata/thinclient.toml") - client = ThinClient(config) - - # Start the client and wait a bit for initial connection and PKI document - loop = asyncio.get_running_loop() - await client.start(loop) - await asyncio.sleep(2) - - return client - - -@pytest.mark.asyncio -async def test_resume_write_channel_query(): - """ - Test resuming a write channel with query state - equivalent to TestResumeWriteChannelQuery from Rust. - This test demonstrates the write channel query resumption workflow: - 1. Create write channel - 2. Create first write query message but do not send to channel yet - 3. Close channel - 4. Resume write channel with query via resume_write_channel_query - 5. Send resumed write query to channel - 6. Send second message to channel - 7. Create read channel - 8. Read both messages from channel - 9. Verify payloads match - """ - alice_thin_client = await setup_thin_client() - bob_thin_client = await setup_thin_client() - - # Wait for PKI documents to be available and connection to mixnet - print("Waiting for daemon to connect to mixnet...") - attempts = 0 - while not alice_thin_client.is_connected() and attempts < 30: - await asyncio.sleep(1) - attempts += 1 - - if not alice_thin_client.is_connected(): - raise Exception("Daemon failed to connect to mixnet within 30 seconds") - - print("✅ Daemon connected to mixnet, using current PKI document") - - # Alice creates write channel - print("Alice: Creating write channel") - alice_channel_id, read_cap, write_cap = await alice_thin_client.create_write_channel() - print(f"Alice: Created write channel {alice_channel_id}") - - # Alice prepares first message but doesn't send it yet - alice_payload1 = b"Hello, Bob!" - write_reply = await alice_thin_client.write_channel(alice_channel_id, alice_payload1) - - # Get courier destination - courier_node, courier_queue_id = await alice_thin_client.get_courier_destination() - alice_message_id1 = ThinClient.new_message_id() - - # Close the channel immediately (like in Rust test - no waiting for propagation) - await alice_thin_client.close_channel(alice_channel_id) - - # Resume the write channel with query state using current_message_index like Rust test - print("Alice: Resuming write channel") - alice_channel_id = await alice_thin_client.resume_write_channel_query( - write_cap, - write_reply.current_message_index, # Use current_message_index like in Rust test - write_reply.envelope_descriptor, - write_reply.envelope_hash - ) - print(f"Alice: Resumed write channel with ID {alice_channel_id}") - - # Send the first message after resume - print("Alice: Writing first message after resume") - _reply1 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply.send_message_payload, - courier_node, - courier_queue_id, - alice_message_id1 - ) - - # Write second message - print("Alice: Writing second message") - alice_payload2 = b"Second message from Alice!" - write_reply2 = await alice_thin_client.write_channel(alice_channel_id, alice_payload2) - - alice_message_id2 = ThinClient.new_message_id() - _reply2 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply2.send_message_payload, - courier_node, - courier_queue_id, - alice_message_id2 - ) - print("Alice: Second write operation completed successfully") - - print("Waiting for second message propagation to storage replicas") - await asyncio.sleep(3) - - # Bob creates read channel - print("Bob: Creating read channel") - bob_channel_id = await bob_thin_client.create_read_channel(read_cap) - print(f"Bob: Created read channel {bob_channel_id}") - - # Bob reads first message - print("Bob: Reading first message") - read_reply1 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id1 = ThinClient.new_message_id() - bob_reply_payload1 = b"" - - for i in range(10): - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply1.send_message_payload, - courier_node, - courier_queue_id, - bob_message_id1 - ) - if payload: - bob_reply_payload1 = payload - break - else: - print(f"Bob: First read attempt {i + 1} returned empty payload, retrying...") - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert alice_payload1 == bob_reply_payload1, "Bob: First message payload mismatch" - - # Bob reads second message - print("Bob: Reading second message") - read_reply2 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id2 = ThinClient.new_message_id() - bob_reply_payload2 = b"" - - for i in range(10): - print(f"Bob: second message read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply2.send_message_payload, - courier_node, - courier_queue_id, - bob_message_id2 - ) - if payload: - bob_reply_payload2 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - # Verify the second message content matches - assert alice_payload2 == bob_reply_payload2, "Bob: Second message payload mismatch" - print("Bob: Successfully received and verified second message") - - # Clean up channels - await alice_thin_client.close_channel(alice_channel_id) - await bob_thin_client.close_channel(bob_channel_id) - - alice_thin_client.stop() - bob_thin_client.stop() - - print("✅ Resume write channel query test completed successfully") - - -@pytest.mark.asyncio -async def test_resume_read_channel(): - """ - Test resuming a read channel - equivalent to TestResumeReadChannel from Rust. - This test demonstrates the read channel resumption workflow: - 1. Create a write channel - 2. Write two messages to the channel - 3. Create a read channel - 4. Read the first message from the channel - 5. Verify payload matches - 6. Close the read channel - 7. Resume the read channel - 8. Read the second message from the channel - 9. Verify payload matches - """ - alice_thin_client = await setup_thin_client() - bob_thin_client = await setup_thin_client() - - # Wait for PKI documents to be available and connection to mixnet - print("Waiting for daemon to connect to mixnet...") - attempts = 0 - while not alice_thin_client.is_connected() and attempts < 30: - await asyncio.sleep(1) - attempts += 1 - - if not alice_thin_client.is_connected(): - raise Exception("Daemon failed to connect to mixnet within 30 seconds") - - print("✅ Daemon connected to mixnet, using current PKI document") - - # Alice creates write channel - print("Alice: Creating write channel") - alice_channel_id, read_cap, _write_cap = await alice_thin_client.create_write_channel() - print(f"Alice: Created write channel {alice_channel_id}") - - # Alice writes first message - alice_payload1 = b"Hello, Bob!" - write_reply1 = await alice_thin_client.write_channel(alice_channel_id, alice_payload1) - - dest_node, dest_queue = await alice_thin_client.get_courier_destination() - alice_message_id1 = ThinClient.new_message_id() - - _reply1 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply1.send_message_payload, - dest_node, - dest_queue, - alice_message_id1 - ) - - print("Waiting for first message propagation to storage replicas") - await asyncio.sleep(3) - - # Alice writes second message - print("Alice: Writing second message") - alice_payload2 = b"Second message from Alice!" - write_reply2 = await alice_thin_client.write_channel(alice_channel_id, alice_payload2) - - alice_message_id2 = ThinClient.new_message_id() - _reply2 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply2.send_message_payload, - dest_node, - dest_queue, - alice_message_id2 - ) - print("Alice: Second write operation completed successfully") - - print("Waiting for second message propagation to storage replicas") - await asyncio.sleep(3) - - # Bob creates read channel - print("Bob: Creating read channel") - bob_channel_id = await bob_thin_client.create_read_channel(read_cap) - print(f"Bob: Created read channel {bob_channel_id}") - - # Bob reads first message - print("Bob: Reading first message") - read_reply1 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id1 = ThinClient.new_message_id() - bob_reply_payload1 = b"" - - for i in range(10): - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply1.send_message_payload, - dest_node, - dest_queue, - bob_message_id1 - ) - if payload: - bob_reply_payload1 = payload - break - else: - print(f"Bob: First read attempt {i + 1} returned empty payload, retrying...") - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert alice_payload1 == bob_reply_payload1, "Bob: First message payload mismatch" - - # Close the read channel - await bob_thin_client.close_channel(bob_channel_id) - - # Resume the read channel - print("Bob: Resuming read channel") - bob_channel_id = await bob_thin_client.resume_read_channel( - read_cap, - read_reply1.next_message_index, - read_reply1.reply_index - ) - print(f"Bob: Resumed read channel with ID {bob_channel_id}") - - # Bob reads second message - print("Bob: Reading second message") - read_reply2 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id2 = ThinClient.new_message_id() - bob_reply_payload2 = b"" - - for i in range(10): - print(f"Bob: second message read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply2.send_message_payload, - dest_node, - dest_queue, - bob_message_id2 - ) - if payload: - bob_reply_payload2 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - # Verify the second message content matches - assert alice_payload2 == bob_reply_payload2, "Bob: Second message payload mismatch" - print("Bob: Successfully received and verified second message") - - # Clean up channels - await alice_thin_client.close_channel(alice_channel_id) - await bob_thin_client.close_channel(bob_channel_id) - - alice_thin_client.stop() - bob_thin_client.stop() - - print("✅ Resume read channel test completed successfully") - - -@pytest.mark.asyncio -async def test_resume_read_channel_query(): - """ - Test resuming a read channel with query state - equivalent to TestResumeReadChannelQuery from Rust. - This test demonstrates the read channel query resumption workflow: - 1. Create a write channel - 2. Write two messages to the channel - 3. Create read channel - 4. Make read query but do not send it - 5. Close read channel - 6. Resume read channel query with resume_read_channel_query method - 7. Send previously made read query to channel - 8. Verify received payload matches - 9. Read second message from channel - 10. Verify received payload matches - """ - alice_thin_client = await setup_thin_client() - bob_thin_client = await setup_thin_client() - - # Wait for PKI documents to be available and connection to mixnet - print("Waiting for daemon to connect to mixnet...") - attempts = 0 - while not alice_thin_client.is_connected() and attempts < 30: - await asyncio.sleep(1) - attempts += 1 - - if not alice_thin_client.is_connected(): - raise Exception("Daemon failed to connect to mixnet within 30 seconds") - - print("✅ Daemon connected to mixnet, using current PKI document") - - # Alice creates write channel - print("Alice: Creating write channel") - alice_channel_id, read_cap, _write_cap = await alice_thin_client.create_write_channel() - print(f"Alice: Created write channel {alice_channel_id}") - - # Alice writes first message - alice_payload1 = b"Hello, Bob!" - write_reply1 = await alice_thin_client.write_channel(alice_channel_id, alice_payload1) - - dest_node, dest_queue = await alice_thin_client.get_courier_destination() - alice_message_id1 = ThinClient.new_message_id() - - _reply1 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply1.send_message_payload, - dest_node, - dest_queue, - alice_message_id1 - ) - - print("Waiting for first message propagation to storage replicas") - await asyncio.sleep(3) - - # Alice writes second message - print("Alice: Writing second message") - alice_payload2 = b"Second message from Alice!" - write_reply2 = await alice_thin_client.write_channel(alice_channel_id, alice_payload2) - - alice_message_id2 = ThinClient.new_message_id() - _reply2 = await alice_thin_client.send_channel_query_await_reply( - alice_channel_id, - write_reply2.send_message_payload, - dest_node, - dest_queue, - alice_message_id2 - ) - print("Alice: Second write operation completed successfully") - - print("Waiting for second message propagation to storage replicas") - await asyncio.sleep(3) - - # Bob creates read channel - print("Bob: Creating read channel") - bob_channel_id = await bob_thin_client.create_read_channel(read_cap) - print(f"Bob: Created read channel {bob_channel_id}") - - # Bob prepares first read query but doesn't send it yet - print("Bob: Reading first message") - read_reply1 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - # Close the read channel - await bob_thin_client.close_channel(bob_channel_id) - - # Resume the read channel with query state - print("Bob: Resuming read channel") - bob_channel_id = await bob_thin_client.resume_read_channel_query( - read_cap, - read_reply1.current_message_index, - read_reply1.reply_index, - read_reply1.envelope_descriptor, - read_reply1.envelope_hash - ) - print(f"Bob: Resumed read channel with ID {bob_channel_id}") - - # Send the first read query and get the message payload - bob_message_id1 = ThinClient.new_message_id() - bob_reply_payload1 = b"" - - for i in range(10): - print(f"Bob: first message read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply1.send_message_payload, - dest_node, - dest_queue, - bob_message_id1 - ) - if payload: - bob_reply_payload1 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - assert alice_payload1 == bob_reply_payload1, "Bob: First message payload mismatch" - - # Bob reads second message - print("Bob: Reading second message") - read_reply2 = await bob_thin_client.read_channel(bob_channel_id, None, None) - - bob_message_id2 = ThinClient.new_message_id() - bob_reply_payload2 = b"" - - for i in range(10): - print(f"Bob: second message read attempt {i + 1}") - try: - payload = await alice_thin_client.send_channel_query_await_reply( - bob_channel_id, - read_reply2.send_message_payload, - dest_node, - dest_queue, - bob_message_id2 - ) - if payload: - bob_reply_payload2 = payload - break - else: - await asyncio.sleep(0.5) - except Exception as e: - raise e - - # Verify the second message content matches - assert alice_payload2 == bob_reply_payload2, "Bob: Second message payload mismatch" - print("Bob: Successfully received and verified second message") - - # Clean up channels - await alice_thin_client.close_channel(alice_channel_id) - await bob_thin_client.close_channel(bob_channel_id) - - alice_thin_client.stop() - bob_thin_client.stop() - - print("✅ Resume read channel query test completed successfully") diff --git a/tests/test_core.py b/tests/test_core.py index 070ccaa..6504902 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -37,6 +37,21 @@ async def test_thin_client_send_receive_integration_test(): try: await client.start(loop) + # Wait for daemon to connect to mixnet and receive PKI document + print("Waiting for daemon to connect to mixnet...") + attempts = 0 + while (not client.is_connected() or client.pki_document() is None) and attempts < 30: + await asyncio.sleep(1) + attempts += 1 + + if not client.is_connected(): + raise Exception("Daemon failed to connect to mixnet within 30 seconds") + + if client.pki_document() is None: + raise Exception("PKI document not received within 30 seconds") + + print("✅ Daemon connected to mixnet, using current PKI document") + service_desc = client.get_service("echo") surb_id = client.new_surb_id() payload = "hello" @@ -78,3 +93,83 @@ async def dummy_callback(event): assert cfg_with_callbacks is not None, "Config with callbacks should work" # Configuration validation passed + + +def test_error_codes_completeness(): + """ + Test that all error codes 0-24 are defined and have corresponding error strings. + + This is a unit test that doesn't require a daemon connection. + It verifies error code consistency between constants and the error string function. + """ + from katzenpost_thinclient import ( + THIN_CLIENT_SUCCESS, + THIN_CLIENT_ERROR_CONNECTION_LOST, + THIN_CLIENT_ERROR_TIMEOUT, + THIN_CLIENT_ERROR_INVALID_REQUEST, + THIN_CLIENT_ERROR_INTERNAL_ERROR, + THIN_CLIENT_ERROR_MAX_RETRIES, + THIN_CLIENT_ERROR_INVALID_CHANNEL, + THIN_CLIENT_ERROR_CHANNEL_NOT_FOUND, + THIN_CLIENT_ERROR_PERMISSION_DENIED, + THIN_CLIENT_ERROR_INVALID_PAYLOAD, + THIN_CLIENT_ERROR_SERVICE_UNAVAILABLE, + THIN_CLIENT_ERROR_DUPLICATE_CAPABILITY, + THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION, + THIN_CLIENT_PROPAGATION_ERROR, + THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY, + THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY, + THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST, + THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST, + THIN_CLIENT_IMPOSSIBLE_HASH_ERROR, + THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR, + THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR, + THIN_CLIENT_CAPABILITY_ALREADY_IN_USE, + THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED, + THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED, + THIN_CLIENT_ERROR_START_RESENDING_CANCELLED, + thin_client_error_to_string + ) + + # Verify all error codes have sequential values 0-24 + expected_codes = { + THIN_CLIENT_SUCCESS: 0, + THIN_CLIENT_ERROR_CONNECTION_LOST: 1, + THIN_CLIENT_ERROR_TIMEOUT: 2, + THIN_CLIENT_ERROR_INVALID_REQUEST: 3, + THIN_CLIENT_ERROR_INTERNAL_ERROR: 4, + THIN_CLIENT_ERROR_MAX_RETRIES: 5, + THIN_CLIENT_ERROR_INVALID_CHANNEL: 6, + THIN_CLIENT_ERROR_CHANNEL_NOT_FOUND: 7, + THIN_CLIENT_ERROR_PERMISSION_DENIED: 8, + THIN_CLIENT_ERROR_INVALID_PAYLOAD: 9, + THIN_CLIENT_ERROR_SERVICE_UNAVAILABLE: 10, + THIN_CLIENT_ERROR_DUPLICATE_CAPABILITY: 11, + THIN_CLIENT_ERROR_COURIER_CACHE_CORRUPTION: 12, + THIN_CLIENT_PROPAGATION_ERROR: 13, + THIN_CLIENT_ERROR_INVALID_WRITE_CAPABILITY: 14, + THIN_CLIENT_ERROR_INVALID_READ_CAPABILITY: 15, + THIN_CLIENT_ERROR_INVALID_RESUME_WRITE_CHANNEL_REQUEST: 16, + THIN_CLIENT_ERROR_INVALID_RESUME_READ_CHANNEL_REQUEST: 17, + THIN_CLIENT_IMPOSSIBLE_HASH_ERROR: 18, + THIN_CLIENT_IMPOSSIBLE_NEW_WRITE_CAP_ERROR: 19, + THIN_CLIENT_IMPOSSIBLE_NEW_STATEFUL_WRITER_ERROR: 20, + THIN_CLIENT_CAPABILITY_ALREADY_IN_USE: 21, + THIN_CLIENT_ERROR_MKEM_DECRYPTION_FAILED: 22, + THIN_CLIENT_ERROR_BACAP_DECRYPTION_FAILED: 23, + THIN_CLIENT_ERROR_START_RESENDING_CANCELLED: 24, + } + + for const, expected_value in expected_codes.items(): + assert const == expected_value, f"Error code constant has wrong value: expected {expected_value}, got {const}" + + # Verify all error codes have non-empty, non-"Unknown" error strings + for code in range(25): + error_str = thin_client_error_to_string(code) + assert error_str, f"Error code {code} has empty error string" + assert "Unknown" not in error_str, f"Error code {code} has 'Unknown' in error string: {error_str}" + + # Verify specific error strings for cancel behavior + assert thin_client_error_to_string(THIN_CLIENT_ERROR_START_RESENDING_CANCELLED) == "Start resending cancelled" + + print("✅ All error codes 0-24 are defined with proper error strings") diff --git a/tests/test_new_pigeonhole_api.py b/tests/test_new_pigeonhole_api.py new file mode 100644 index 0000000..7c77d8a --- /dev/null +++ b/tests/test_new_pigeonhole_api.py @@ -0,0 +1,1140 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (C) 2025 David Stainton +# SPDX-License-Identifier: AGPL-3.0-only + +""" +NEW Pigeonhole API integration tests for the Python thin client. + +These tests verify the 5-function NEW Pigeonhole API: +1. new_keypair - Generate WriteCap and ReadCap from seed +2. encrypt_read - Encrypt a read operation +3. encrypt_write - Encrypt a write operation +4. start_resending_encrypted_message - Send encrypted message with ARQ +5. cancel_resending_encrypted_message - Cancel ARQ for a message + +These tests require a running mixnet with client daemon for integration testing. +""" + +import asyncio +import pytest +import os +from katzenpost_thinclient import ThinClient, Config + + +async def setup_thin_client(): + """Test helper to setup a thin client for integration tests.""" + from .conftest import get_config_path + + config_path = get_config_path() + config = Config(config_path) + client = ThinClient(config) + + # Start the client and wait for connection and PKI document + loop = asyncio.get_running_loop() + await client.start(loop) + + # Wait for daemon to connect to mixnet and receive PKI document + print("Waiting for daemon to connect to mixnet...") + attempts = 0 + while (not client.is_connected() or client.pki_document() is None) and attempts < 30: + await asyncio.sleep(1) + attempts += 1 + + if not client.is_connected(): + raise Exception("Daemon failed to connect to mixnet within 30 seconds") + + if client.pki_document() is None: + raise Exception("PKI document not received within 30 seconds") + + print("✅ Daemon connected to mixnet, using current PKI document") + + return client + + +@pytest.mark.asyncio +async def test_new_keypair_basic(): + """ + Test basic keypair generation using new_keypair. + + This test verifies: + 1. Keypair can be generated from a 32-byte seed + 2. WriteCap, ReadCap, and FirstMessageIndex are returned + 3. The returned values have the expected sizes + """ + client = await setup_thin_client() + + try: + print("\n=== Test: new_keypair basic functionality ===") + + # Generate a 32-byte seed + seed = os.urandom(32) + print(f"Generated seed: {len(seed)} bytes") + + # Create keypair + write_cap, read_cap, first_message_index = await client.new_keypair(seed) + + print(f"✓ WriteCap size: {len(write_cap)} bytes") + print(f"✓ ReadCap size: {len(read_cap)} bytes") + print(f"✓ FirstMessageIndex size: {len(first_message_index)} bytes") + + # Verify the returned values are not empty + assert len(write_cap) > 0, "WriteCap should not be empty" + assert len(read_cap) > 0, "ReadCap should not be empty" + assert len(first_message_index) > 0, "FirstMessageIndex should not be empty" + + print("✅ new_keypair test completed successfully") + + finally: + client.stop() + + +@pytest.mark.asyncio +async def test_alice_sends_bob_complete_workflow(): + """ + Test complete end-to-end workflow: Alice sends a message to Bob. + + This test demonstrates the full NEW Pigeonhole API workflow: + 1. Alice creates a WriteCap and derives a ReadCap for Bob + 2. Alice encrypts a message using encrypt_write + 3. Alice sends the encrypted message via start_resending_encrypted_message + 4. Bob encrypts a read request using encrypt_read + 5. Bob sends the read request and receives Alice's encrypted message + 6. Bob verifies the received message + + This mirrors the Go test: TestNewPigeonholeAPIAliceSendsBob + """ + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: Alice sends message to Bob (complete workflow) ===") + + # Step 1: Alice creates WriteCap and derives ReadCap for Bob + print("\n--- Step 1: Alice creates keypair ---") + alice_seed = os.urandom(32) + alice_write_cap, bob_read_cap, alice_first_index = await alice_client.new_keypair(alice_seed) + print(f"✓ Alice created WriteCap and derived ReadCap for Bob") + + # Step 2: Alice encrypts a message for Bob + print("\n--- Step 2: Alice encrypts message ---") + alice_message = b"Bob, Beware they are jamming GPS." + print(f"Alice's message: {alice_message.decode()}") + + alice_ciphertext, alice_env_desc, alice_env_hash = await alice_client.encrypt_write( + alice_message, alice_write_cap, alice_first_index + ) + print(f"✓ Alice encrypted message (ciphertext: {len(alice_ciphertext)} bytes)") + + # Step 3: Alice sends the encrypted message via start_resending_encrypted_message + print("\n--- Step 3: Alice sends encrypted message to courier/replicas ---") + reply_index = 0 + + alice_plaintext = await alice_client.start_resending_encrypted_message( + read_cap=None, # None for write operations + write_cap=alice_write_cap, + next_message_index=None, # Not needed for writes + reply_index=reply_index, + envelope_descriptor=alice_env_desc, + message_ciphertext=alice_ciphertext, + envelope_hash=alice_env_hash + ) + + # For write operations, plaintext should be empty (ACK only) + print(f"✓ Alice received ACK (plaintext length: {len(alice_plaintext) if alice_plaintext else 0})") + + # Wait for message propagation to storage replicas + print("\n--- Waiting for message propagation to storage replicas ---") + await asyncio.sleep(5) + + # Step 4: Bob encrypts a read request + print("\n--- Step 4: Bob encrypts read request ---") + bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash = await bob_client.encrypt_read( + bob_read_cap, alice_first_index + ) + print(f"✓ Bob encrypted read request (ciphertext: {len(bob_ciphertext)} bytes)") + + # Step 5: Bob sends the read request and receives Alice's encrypted message + print("\n--- Step 5: Bob sends read request and receives encrypted message ---") + bob_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=bob_read_cap, + write_cap=None, # None for read operations + next_message_index=bob_next_index, + reply_index=reply_index, + envelope_descriptor=bob_env_desc, + message_ciphertext=bob_ciphertext, + envelope_hash=bob_env_hash + ) + + # Step 6: Verify Bob received Alice's message + print(f"\n--- Step 6: Verify received message ---") + print(f"Bob received: {bob_plaintext.decode() if bob_plaintext else '(empty)'}") + + assert bob_plaintext == alice_message, f"Message mismatch! Expected: {alice_message}, Got: {bob_plaintext}" + + print("✅ Complete workflow test passed - Bob successfully received Alice's message!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_cancel_resending_encrypted_message(): + """ + Test cancelling ARQ for an encrypted message. + + This test verifies: + 1. An encrypted message can be prepared + 2. The ARQ can be cancelled using cancel_resending_encrypted_message + 3. The cancellation completes without error + """ + client = await setup_thin_client() + + try: + print("\n=== Test: cancel_resending_encrypted_message ===") + + # Generate keypair and encrypt a message + seed = os.urandom(32) + write_cap, read_cap, first_message_index = await client.new_keypair(seed) + + plaintext = b"This message will be cancelled" + ciphertext, env_desc, env_hash = await client.encrypt_write( + plaintext, write_cap, first_message_index + ) + + print(f"✓ Encrypted message for cancellation test") + print(f"EnvelopeHash: {env_hash.hex()}") + + # Cancel the message (before sending it) + # Note: In practice, you would start_resending first, then cancel + # But for this test, we just verify the cancel API works + await client.cancel_resending_encrypted_message(env_hash) + + print("✅ cancel_resending_encrypted_message completed successfully") + + finally: + client.stop() + + +@pytest.mark.asyncio +async def test_cancel_causes_start_resending_to_return_error(): + """ + Test that calling cancel causes start_resending to return with error code 24. + + This test verifies the core cancel behavior: + 1. Start a start_resending_encrypted_message call (which blocks waiting for reply) + 2. Call cancel_resending_encrypted_message from another task + 3. Verify that the original start_resending call returns with error code 24 + (THIN_CLIENT_ERROR_START_RESENDING_CANCELLED) + + This requires a running daemon but does NOT require a full mixnet since we're + testing the cancel behavior before any reply is received from the mixnet. + """ + from katzenpost_thinclient import THIN_CLIENT_ERROR_START_RESENDING_CANCELLED + + client = await setup_thin_client() + + try: + print("\n=== Test: cancel causes start_resending to return error ===") + + # Generate keypair and encrypt a message + seed = os.urandom(32) + write_cap, read_cap, first_message_index = await client.new_keypair(seed) + + plaintext = b"This message will be cancelled while sending" + ciphertext, env_desc, env_hash = await client.encrypt_write( + plaintext, write_cap, first_message_index + ) + + print(f"✓ Encrypted message") + print(f"EnvelopeHash: {env_hash.hex()}") + + # Track whether the start_resending returned with the expected error + start_resending_error = None + start_resending_completed = asyncio.Event() + + async def start_resending_task(): + """Task that calls start_resending and captures any error.""" + nonlocal start_resending_error + try: + await client.start_resending_encrypted_message( + read_cap=None, + write_cap=write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + # If we get here without error, that's unexpected + start_resending_error = "No error raised" + except Exception as e: + start_resending_error = str(e) + finally: + start_resending_completed.set() + + # Start the start_resending task + print("--- Starting start_resending_encrypted_message task ---") + resend_task = asyncio.create_task(start_resending_task()) + + # Give the task just enough time to start and register with the daemon + # We need to call cancel BEFORE the message gets ACKed by the mixnet, + # so we use a very short delay (just enough for the async task to start) + await asyncio.sleep(0.1) + + # Cancel the resending + print("--- Calling cancel_resending_encrypted_message ---") + await client.cancel_resending_encrypted_message(env_hash) + print("✓ Cancel call completed") + + # Wait for the start_resending task to complete (with timeout) + try: + await asyncio.wait_for(start_resending_completed.wait(), timeout=5.0) + except asyncio.TimeoutError: + resend_task.cancel() + raise Exception("start_resending did not return within 5 seconds after cancel") + + # Verify the error + print(f"--- Verifying error ---") + print(f"Error received: {start_resending_error}") + + assert start_resending_error is not None, "Expected an error but got None" + assert "Start resending cancelled" in start_resending_error, \ + f"Expected 'Start resending cancelled' in error, got: {start_resending_error}" + + print("✅ start_resending returned with expected error code 24 (Start resending cancelled)") + + finally: + client.stop() + + +@pytest.mark.asyncio +async def test_cancel_causes_start_resending_copy_command_to_return_error(): + """ + Test that calling cancel causes start_resending_copy_command to return with error. + + This test verifies the cancel behavior for copy commands: + 1. Create a temporary channel and write some data to it + 2. Start a start_resending_copy_command call (which blocks) + 3. Call cancel_resending_copy_command from another task + 4. Verify that the original start_resending call returns with error code 24 + """ + from hashlib import blake2b + + client = await setup_thin_client() + + try: + print("\n=== Test: cancel causes start_resending_copy_command to return error ===") + + # Create temporary channel + temp_seed = os.urandom(32) + temp_write_cap, _, temp_first_index = await client.new_keypair(temp_seed) + print("✓ Created temporary copy stream WriteCap") + + # Compute write_cap_hash for cancel + write_cap_hash = blake2b(temp_write_cap, digest_size=32).digest() + print(f"WriteCapHash: {write_cap_hash.hex()}") + + # Track whether the start_resending returned with the expected error + start_resending_error = None + start_resending_completed = asyncio.Event() + + async def start_resending_copy_task(): + """Task that calls start_resending_copy_command and captures any error.""" + nonlocal start_resending_error + try: + await client.start_resending_copy_command(temp_write_cap) + # If we get here without error, that's unexpected + start_resending_error = "No error raised" + except Exception as e: + start_resending_error = str(e) + finally: + start_resending_completed.set() + + # Start the start_resending_copy_command task + print("--- Starting start_resending_copy_command task ---") + resend_task = asyncio.create_task(start_resending_copy_task()) + + # Give the task just enough time to start and register with the daemon + # We need to call cancel BEFORE the message gets ACKed by the mixnet, + # so we use a very short delay (just enough for the async task to start) + await asyncio.sleep(0.1) + + # Cancel the resending + print("--- Calling cancel_resending_copy_command ---") + await client.cancel_resending_copy_command(write_cap_hash) + print("✓ Cancel call completed") + + # Wait for the start_resending task to complete (with timeout) + try: + await asyncio.wait_for(start_resending_completed.wait(), timeout=5.0) + except asyncio.TimeoutError: + resend_task.cancel() + raise Exception("start_resending_copy_command did not return within 5 seconds after cancel") + + # Verify the error + print(f"--- Verifying error ---") + print(f"Error received: {start_resending_error}") + + assert start_resending_error is not None, "Expected an error but got None" + assert "Start resending cancelled" in start_resending_error, \ + f"Expected 'Start resending cancelled' in error, got: {start_resending_error}" + + print("✅ start_resending_copy_command returned with expected error code 24 (Start resending cancelled)") + + finally: + client.stop() + + +@pytest.mark.asyncio +async def test_multiple_messages_sequence(): + """ + Test sending multiple messages with incrementing indices. + + This test verifies: + 1. Multiple messages can be sent using the same WriteCap + 2. Each message is written to a different MessageBoxIndex + 3. All messages can be read back in sequence + 4. The messages are reassembled correctly + + Note: Each MessageBoxIndex holds one message. To send multiple messages, + you must increment the index for each new message. + """ + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: Multiple messages with incrementing indices ===") + + # Alice creates keypair + alice_seed = os.urandom(32) + alice_write_cap, bob_read_cap, first_index = await alice_client.new_keypair(alice_seed) + print(f"✓ Alice created keypair") + + num_messages = 3 + messages = [ + b"Message 1 from Alice to Bob", + b"Message 2 from Alice to Bob", + b"Message 3 from Alice to Bob" + ] + + # Alice sends multiple messages, each to a different index + # We increment the index for each message using the BACAP HKDF logic + current_index = first_index + indices_used = [current_index] # Track all indices for reading later + + for i, message in enumerate(messages): + print(f"\n--- Sending message {i+1}/{num_messages} ---") + print(f"Message: {message.decode()}") + + # Encrypt and send to current index + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + message, alice_write_cap, current_index + ) + + alice_plaintext = await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=alice_write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + + print(f"✓ Message {i+1} sent to index successfully") + + # Increment index for next message + if i < num_messages - 1: # Don't increment after last message + current_index = await alice_client.next_message_box_index(current_index) + indices_used.append(current_index) + + print("\n--- Waiting for message propagation ---") + await asyncio.sleep(5) + + # Bob reads all messages from their respective indices + print("\n--- Bob reads all messages ---") + received_messages = [] + bob_current_index = first_index + + for i in range(num_messages): + print(f"\nReading message {i+1}/{num_messages}...") + bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash = await bob_client.encrypt_read( + bob_read_cap, bob_current_index + ) + + bob_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=bob_read_cap, + write_cap=None, + next_message_index=bob_next_index, + reply_index=0, + envelope_descriptor=bob_env_desc, + message_ciphertext=bob_ciphertext, + envelope_hash=bob_env_hash + ) + + print(f"Bob received: {bob_plaintext.decode() if bob_plaintext else '(empty)'}") + received_messages.append(bob_plaintext) + + # Increment index for next read + if i < num_messages - 1: + bob_current_index = await bob_client.next_message_box_index(bob_current_index) + + # Verify all messages were received correctly + for i, (sent, received) in enumerate(zip(messages, received_messages)): + assert received == sent, f"Message {i+1} mismatch: expected {sent}, got {received}" + + print("\n✅ Multiple messages test completed successfully!") + print(f"✅ All {num_messages} messages sent and received correctly with proper index incrementing!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_create_courier_envelopes_from_payload(): + """ + Test the CreateCourierEnvelopesFromPayload API. + + This test verifies: + 1. Alice creates a large payload that will be automatically chunked + 2. Alice calls create_courier_envelopes_from_payload to get copy stream chunks + 3. Alice writes all copy stream chunks to a temporary copy stream channel + 4. Alice sends the Copy command to the courier + 5. Bob reads all chunks from the destination channel and reconstructs the payload + + This mirrors the Go test: TestCreateCourierEnvelopesFromPayload + """ + import struct + + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: CreateCourierEnvelopesFromPayload ===") + + # Step 1: Alice creates destination WriteCap for the final payload + print("\n--- Step 1: Alice creates destination WriteCap ---") + dest_seed = os.urandom(32) + dest_write_cap, bob_read_cap, dest_first_index = await alice_client.new_keypair(dest_seed) + print("✓ Alice created destination WriteCap and derived ReadCap for Bob") + + # Step 2: Alice creates temporary copy stream + print("\n--- Step 2: Alice creates temporary copy stream ---") + temp_seed = os.urandom(32) + temp_write_cap, _, temp_first_index = await alice_client.new_keypair(temp_seed) + print("✓ Alice created temporary copy stream WriteCap") + + # Step 3: Create a large payload that will be chunked + print("\n--- Step 3: Creating large payload ---") + # Create a payload large enough to require multiple chunks + # Use a 4-byte length prefix so Bob knows when to stop reading + random_data = os.urandom(5 * 1024) # 5KB of random data + # Length-prefix the payload: [4 bytes length][random data] + large_payload = struct.pack(">I", len(random_data)) + random_data + print(f"✓ Alice created large payload ({len(large_payload)} bytes = 4 byte length prefix + {len(random_data)} bytes data)") + + # Step 4: Create copy stream chunks from the large payload + print("\n--- Step 4: Creating copy stream chunks from large payload ---") + query_id = alice_client.new_query_id() + stream_id = alice_client.new_stream_id() + copy_stream_chunks = await alice_client.create_courier_envelopes_from_payload( + query_id, stream_id, large_payload, dest_write_cap, dest_first_index, True # is_last + ) + assert copy_stream_chunks, "create_courier_envelopes_from_payload returned empty chunks" + num_chunks = len(copy_stream_chunks) + print(f"✓ Alice created {num_chunks} copy stream chunks from {len(large_payload)} byte payload") + + # Step 5: Write all copy stream chunks to the temporary copy stream + print("\n--- Step 5: Writing copy stream chunks to temporary channel ---") + temp_index = temp_first_index + + for i, chunk in enumerate(copy_stream_chunks): + print(f"--- Writing copy stream chunk {i+1}/{num_chunks} to temporary channel ---") + + # Encrypt the chunk for the copy stream + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + chunk, temp_write_cap, temp_index + ) + print(f"✓ Alice encrypted copy stream chunk {i+1} ({len(chunk)} bytes plaintext -> {len(ciphertext)} bytes ciphertext)") + + # Send the encrypted chunk to the copy stream + await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=temp_write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + print(f"✓ Alice sent copy stream chunk {i+1} to temporary channel") + + # Increment temp index for next chunk + temp_index = await alice_client.next_message_box_index(temp_index) + + # Wait for all chunks to propagate to the copy stream + print("\n--- Waiting for copy stream chunks to propagate (30 seconds) ---") + await asyncio.sleep(30) + + # Step 6: Send Copy command to courier using ARQ + print("\n--- Step 6: Sending Copy command to courier via ARQ ---") + await alice_client.start_resending_copy_command(temp_write_cap) + print("✓ Alice copy command completed successfully via ARQ") + + # Step 7: Bob reads chunks until we have the full payload (based on length prefix) + print("\n--- Step 7: Bob reads all chunks and reconstructs payload ---") + bob_index = dest_first_index + reconstructed_payload = b"" + expected_length = 0 + chunk_num = 0 + + while True: + chunk_num += 1 + print(f"--- Bob reading chunk {chunk_num} ---") + + # Bob encrypts read request + bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash = await bob_client.encrypt_read( + bob_read_cap, bob_index + ) + print(f"✓ Bob encrypted read request {chunk_num}") + + # Bob sends read request and receives chunk + bob_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=bob_read_cap, + write_cap=None, + next_message_index=bob_next_index, + reply_index=0, + envelope_descriptor=bob_env_desc, + message_ciphertext=bob_ciphertext, + envelope_hash=bob_env_hash + ) + assert bob_plaintext, f"Bob: Failed to receive chunk {chunk_num}" + print(f"✓ Bob received and decrypted chunk {chunk_num} ({len(bob_plaintext)} bytes)") + + # Append chunk to reconstructed payload + reconstructed_payload += bob_plaintext + + # Extract expected length from the first 4 bytes once we have them + if expected_length == 0 and len(reconstructed_payload) >= 4: + expected_length = struct.unpack(">I", reconstructed_payload[:4])[0] + print(f"✓ Bob: Expected payload length is {expected_length} bytes (+ 4 byte prefix = {expected_length + 4} total)") + + # Check if we have the full payload (4 byte prefix + expected_length bytes) + if expected_length > 0 and len(reconstructed_payload) >= expected_length + 4: + print(f"✓ Bob: Received full payload after {chunk_num} chunks") + break + + # Advance to next chunk + bob_index = await bob_client.next_message_box_index(bob_index) + + # Verify the reconstructed payload matches the original + print(f"\n--- Verifying reconstructed payload ({len(reconstructed_payload)} bytes) ---") + assert reconstructed_payload == large_payload, "Reconstructed payload doesn't match original" + print(f"✅ CreateCourierEnvelopesFromPayload test passed! Large payload ({len(random_data)} bytes data) encoded into {num_chunks} copy stream chunks and reconstructed successfully!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_copy_command_multi_channel(): + """ + Test the Copy Command API with multiple destination channels. + + This test verifies: + 1. Alice creates two destination channels (chan1 and chan2) + 2. Alice creates a temporary copy stream channel + 3. Alice creates two payloads - one for each destination channel + 4. Alice calls create_courier_envelopes_from_payload twice with the same streamID but different WriteCaps + 5. Alice writes all copy stream chunks to the temporary channel + 6. Alice sends the Copy command to the courier + 7. Bob reads from both destination channels and verifies the payloads + + This mirrors the Go test: TestCopyCommandMultiChannel + """ + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: Copy Command Multi-Channel ===") + + # Step 1: Alice creates two destination channels + print("\n--- Step 1: Alice creates two destination channels ---") + + # Channel 1 + chan1_seed = os.urandom(32) + chan1_write_cap, chan1_read_cap, chan1_first_index = await alice_client.new_keypair(chan1_seed) + print("✓ Alice created Channel 1 (WriteCap and ReadCap)") + + # Channel 2 + chan2_seed = os.urandom(32) + chan2_write_cap, chan2_read_cap, chan2_first_index = await alice_client.new_keypair(chan2_seed) + print("✓ Alice created Channel 2 (WriteCap and ReadCap)") + + # Step 2: Alice creates temporary copy stream + print("\n--- Step 2: Alice creates temporary copy stream ---") + temp_seed = os.urandom(32) + temp_write_cap, _, temp_first_index = await alice_client.new_keypair(temp_seed) + print("✓ Alice created temporary copy stream WriteCap") + + # Step 3: Create two payloads - one for each destination channel + print("\n--- Step 3: Creating payloads for each channel ---") + payload1 = b"This is the secret message for Channel 1. It contains important information." + print(f"✓ Alice created payload1 for Channel 1 ({len(payload1)} bytes)") + payload2 = b"This is the confidential data for Channel 2. Handle with care and discretion." + print(f"✓ Alice created payload2 for Channel 2 ({len(payload2)} bytes)") + + # Step 4: Create copy stream chunks using same streamID but different WriteCaps + print("\n--- Step 4: Creating copy stream chunks for both channels ---") + query_id = alice_client.new_query_id() + stream_id = alice_client.new_stream_id() + + # First call: payload1 -> channel 1 (is_last=False) + chunks1 = await alice_client.create_courier_envelopes_from_payload( + query_id, stream_id, payload1, chan1_write_cap, chan1_first_index, False + ) + assert chunks1, "create_courier_envelopes_from_payload returned empty chunks for channel 1" + print(f"✓ Alice created {len(chunks1)} chunks for Channel 1") + + # Second call: payload2 -> channel 2 (is_last=True) + chunks2 = await alice_client.create_courier_envelopes_from_payload( + query_id, stream_id, payload2, chan2_write_cap, chan2_first_index, True + ) + assert chunks2, "create_courier_envelopes_from_payload returned empty chunks for channel 2" + print(f"✓ Alice created {len(chunks2)} chunks for Channel 2") + + # Combine all chunks + all_chunks = chunks1 + chunks2 + print(f"✓ Alice total chunks to write to temp channel: {len(all_chunks)}") + + # Step 5: Write all copy stream chunks to the temporary channel + print("\n--- Step 5: Writing all chunks to temporary channel ---") + temp_index = temp_first_index + + for i, chunk in enumerate(all_chunks): + print(f"--- Writing chunk {i+1}/{len(all_chunks)} to temporary channel ---") + + # Encrypt the chunk for the copy stream + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + chunk, temp_write_cap, temp_index + ) + print(f"✓ Alice encrypted chunk {i+1} ({len(chunk)} bytes plaintext -> {len(ciphertext)} bytes ciphertext)") + + # Send the encrypted chunk to the copy stream + await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=temp_write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + print(f"✓ Alice sent chunk {i+1} to temporary channel") + + # Increment temp index for next chunk + temp_index = await alice_client.next_message_box_index(temp_index) + + # Wait for chunks to propagate + print("\n--- Waiting for copy stream chunks to propagate (30 seconds) ---") + await asyncio.sleep(30) + + # Step 6: Send Copy command to courier using ARQ + print("\n--- Step 6: Sending Copy command to courier via ARQ ---") + await alice_client.start_resending_copy_command(temp_write_cap) + print("✓ Alice copy command completed successfully via ARQ") + + # Step 7: Bob reads from both channels and verifies payloads + print("\n--- Step 7: Bob reads from both channels ---") + + # Read from Channel 1 + print("--- Bob reading from Channel 1 ---") + bob1_ciphertext, bob1_next_index, bob1_env_desc, bob1_env_hash = await bob_client.encrypt_read( + chan1_read_cap, chan1_first_index + ) + assert bob1_ciphertext, "Bob: EncryptRead returned empty ciphertext for Channel 1" + + bob1_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=chan1_read_cap, + write_cap=None, + next_message_index=bob1_next_index, + reply_index=0, + envelope_descriptor=bob1_env_desc, + message_ciphertext=bob1_ciphertext, + envelope_hash=bob1_env_hash + ) + assert bob1_plaintext, "Bob: Failed to receive data from Channel 1" + print(f"✓ Bob received from Channel 1: {bob1_plaintext.decode()} ({len(bob1_plaintext)} bytes)") + + # Verify Channel 1 payload + assert bob1_plaintext == payload1, "Channel 1 payload doesn't match" + print("✓ Channel 1 payload verified!") + + # Read from Channel 2 + print("--- Bob reading from Channel 2 ---") + bob2_ciphertext, bob2_next_index, bob2_env_desc, bob2_env_hash = await bob_client.encrypt_read( + chan2_read_cap, chan2_first_index + ) + assert bob2_ciphertext, "Bob: EncryptRead returned empty ciphertext for Channel 2" + + bob2_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=chan2_read_cap, + write_cap=None, + next_message_index=bob2_next_index, + reply_index=0, + envelope_descriptor=bob2_env_desc, + message_ciphertext=bob2_ciphertext, + envelope_hash=bob2_env_hash + ) + assert bob2_plaintext, "Bob: Failed to receive data from Channel 2" + print(f"✓ Bob received from Channel 2: {bob2_plaintext.decode()} ({len(bob2_plaintext)} bytes)") + + # Verify Channel 2 payload + assert bob2_plaintext == payload2, "Channel 2 payload doesn't match" + print("✓ Channel 2 payload verified!") + + print("\n✅ Multi-channel Copy Command test passed! Payload1 written to Channel 1 and Payload2 written to Channel 2 atomically!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_copy_command_multi_channel_efficient(): + """ + Test the space-efficient multi-channel copy command using + create_courier_envelopes_from_payloads which packs envelopes from different + destinations together without wasting space in the copy stream. + + This test verifies: + - The create_courier_envelopes_from_payloads API works correctly + - Multiple destination payloads are packed efficiently into the copy stream + - The courier processes all envelopes and writes to the correct destinations + + This mirrors the Go test: TestCopyCommandMultiChannelEfficient + """ + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: Efficient Multi-Channel Copy Command ===") + + # Step 1: Alice creates two destination channels + print("\n--- Step 1: Alice creates two destination channels ---") + + # Channel 1 + chan1_seed = os.urandom(32) + chan1_write_cap, chan1_read_cap, chan1_first_index = await alice_client.new_keypair(chan1_seed) + print("✓ Alice created Channel 1 (WriteCap and ReadCap)") + + # Channel 2 + chan2_seed = os.urandom(32) + chan2_write_cap, chan2_read_cap, chan2_first_index = await alice_client.new_keypair(chan2_seed) + print("✓ Alice created Channel 2 (WriteCap and ReadCap)") + + # Step 2: Alice creates temporary copy stream + print("\n--- Step 2: Alice creates temporary copy stream ---") + temp_seed = os.urandom(32) + temp_write_cap, _, temp_first_index = await alice_client.new_keypair(temp_seed) + print("✓ Alice created temporary copy stream WriteCap") + + # Step 3: Create two payloads - one for each destination channel + print("\n--- Step 3: Creating payloads for each channel ---") + payload1 = b"This is the secret message for Channel 1 using the efficient multi-channel API." + print(f"✓ Alice created payload1 for Channel 1 ({len(payload1)} bytes)") + payload2 = b"This is the confidential data for Channel 2 packed efficiently with payload1." + print(f"✓ Alice created payload2 for Channel 2 ({len(payload2)} bytes)") + + # Step 4: Create copy stream chunks using efficient multi-destination API + print("\n--- Step 4: Creating copy stream chunks using efficient multi-destination API ---") + stream_id = alice_client.new_stream_id() + + # Create destinations list with both payloads + destinations = [ + { + "payload": payload1, + "write_cap": chan1_write_cap, + "start_index": chan1_first_index, + }, + { + "payload": payload2, + "write_cap": chan2_write_cap, + "start_index": chan2_first_index, + }, + ] + + # Single call packs all envelopes efficiently + all_chunks = await alice_client.create_courier_envelopes_from_payloads( + stream_id, destinations, True # is_last + ) + assert all_chunks, "create_courier_envelopes_from_payloads returned empty chunks" + print(f"✓ Alice created {len(all_chunks)} chunks for both channels (packed efficiently)") + + # Step 5: Write all copy stream chunks to the temporary channel + print("\n--- Step 5: Writing all chunks to temporary channel ---") + temp_index = temp_first_index + + for i, chunk in enumerate(all_chunks): + print(f"--- Writing chunk {i+1}/{len(all_chunks)} to temporary channel ---") + + # Encrypt the chunk for the copy stream + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + chunk, temp_write_cap, temp_index + ) + print(f"✓ Alice encrypted chunk {i+1} ({len(chunk)} bytes plaintext -> {len(ciphertext)} bytes ciphertext)") + + # Send the encrypted chunk to the copy stream + await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=temp_write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + print(f"✓ Alice sent chunk {i+1} to temporary channel") + + # Increment temp index for next chunk + temp_index = await alice_client.next_message_box_index(temp_index) + + # Wait for chunks to propagate + print("\n--- Waiting for copy stream chunks to propagate (30 seconds) ---") + await asyncio.sleep(30) + + # Step 6: Send Copy command to courier using ARQ + print("\n--- Step 6: Sending Copy command to courier via ARQ ---") + await alice_client.start_resending_copy_command(temp_write_cap) + print("✓ Alice copy command completed successfully via ARQ") + + # Step 7: Bob reads from both channels and verifies payloads + print("\n--- Step 7: Bob reads from both channels ---") + + # Read from Channel 1 + print("--- Bob reading from Channel 1 ---") + bob1_ciphertext, bob1_next_index, bob1_env_desc, bob1_env_hash = await bob_client.encrypt_read( + chan1_read_cap, chan1_first_index + ) + + bob1_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=chan1_read_cap, + write_cap=None, + next_message_index=bob1_next_index, + reply_index=0, + envelope_descriptor=bob1_env_desc, + message_ciphertext=bob1_ciphertext, + envelope_hash=bob1_env_hash + ) + assert bob1_plaintext, "Bob: Failed to receive data from Channel 1" + print(f"✓ Bob received from Channel 1: {bob1_plaintext.decode()} ({len(bob1_plaintext)} bytes)") + assert bob1_plaintext == payload1, "Channel 1 payload doesn't match" + print("✓ Channel 1 payload verified!") + + # Read from Channel 2 + print("--- Bob reading from Channel 2 ---") + bob2_ciphertext, bob2_next_index, bob2_env_desc, bob2_env_hash = await bob_client.encrypt_read( + chan2_read_cap, chan2_first_index + ) + + bob2_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=chan2_read_cap, + write_cap=None, + next_message_index=bob2_next_index, + reply_index=0, + envelope_descriptor=bob2_env_desc, + message_ciphertext=bob2_ciphertext, + envelope_hash=bob2_env_hash + ) + assert bob2_plaintext, "Bob: Failed to receive data from Channel 2" + print(f"✓ Bob received from Channel 2: {bob2_plaintext.decode()} ({len(bob2_plaintext)} bytes)") + assert bob2_plaintext == payload2, "Channel 2 payload doesn't match" + print("✓ Channel 2 payload verified!") + + print("\n✅ Efficient multi-channel Copy Command test passed! Both payloads packed efficiently and delivered to correct channels!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_tombstoning(): + """ + Test the tombstoning API. + + This test verifies: + 1. Alice writes a message to a box + 2. Bob reads and verifies the message + 3. Alice tombstones the box (overwrites with zeros) + 4. Bob reads again and verifies the tombstone + + This mirrors the Go test: TestTombstoning + """ + from katzenpost_thinclient import PigeonholeGeometry, is_tombstone_plaintext + + alice_client = await setup_thin_client() + bob_client = await setup_thin_client() + + try: + print("\n=== Test: Tombstoning ===") + + # Create a geometry with a reasonable payload size + # In a real scenario, this would come from the PKI document + geometry = PigeonholeGeometry( + max_plaintext_payload_length=1024, + nike_name="x25519" + ) + + # Create keypair + seed = os.urandom(32) + write_cap, read_cap, first_index = await alice_client.new_keypair(seed) + print("✓ Created keypair") + + # Step 1: Alice writes a message + print("\n--- Step 1: Alice writes a message ---") + message = b"Secret message that will be tombstoned" + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + message, write_cap, first_index + ) + + await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + print("✓ Alice wrote message") + + # Wait for message propagation + print("--- Waiting for message propagation (5 seconds) ---") + await asyncio.sleep(5) + + # Step 2: Bob reads and verifies + print("\n--- Step 2: Bob reads and verifies ---") + bob_ciphertext, bob_next_index, bob_env_desc, bob_env_hash = await bob_client.encrypt_read( + read_cap, first_index + ) + bob_plaintext = await bob_client.start_resending_encrypted_message( + read_cap=read_cap, + write_cap=None, + next_message_index=bob_next_index, + reply_index=0, + envelope_descriptor=bob_env_desc, + message_ciphertext=bob_ciphertext, + envelope_hash=bob_env_hash + ) + assert bob_plaintext == message, f"Message mismatch: expected {message}, got {bob_plaintext}" + print(f"✓ Bob read message: {bob_plaintext.decode()}") + + # Step 3: Alice tombstones the box + print("\n--- Step 3: Alice tombstones the box ---") + await alice_client.tombstone_box(geometry, write_cap, first_index) + print("✓ Alice tombstoned the box") + + # Wait for tombstone propagation + print("--- Waiting for tombstone propagation (30 seconds) ---") + await asyncio.sleep(30) + + # Step 4: Bob reads again and verifies tombstone + print("\n--- Step 4: Bob reads again and verifies tombstone ---") + bob_ciphertext2, bob_next_index2, bob_env_desc2, bob_env_hash2 = await bob_client.encrypt_read( + read_cap, first_index + ) + bob_plaintext2 = await bob_client.start_resending_encrypted_message( + read_cap=read_cap, + write_cap=None, + next_message_index=bob_next_index2, + reply_index=0, + envelope_descriptor=bob_env_desc2, + message_ciphertext=bob_ciphertext2, + envelope_hash=bob_env_hash2 + ) + + assert is_tombstone_plaintext(geometry, bob_plaintext2), "Expected tombstone plaintext (all zeros)" + print("✓ Bob verified tombstone (all zeros)") + + print("\n✅ Tombstoning test passed!") + + finally: + alice_client.stop() + bob_client.stop() + + +@pytest.mark.asyncio +async def test_tombstone_range(): + """ + Test the tombstone_range API. + + This test verifies: + 1. Alice writes multiple messages to sequential boxes + 2. Alice tombstones a range of boxes + 3. The result shows the correct number of tombstoned boxes + + This mirrors the Go TombstoneRange functionality. + """ + from katzenpost_thinclient import PigeonholeGeometry + + alice_client = await setup_thin_client() + + try: + print("\n=== Test: Tombstone Range ===") + + # Create a geometry with a reasonable payload size + geometry = PigeonholeGeometry( + max_plaintext_payload_length=1024, + nike_name="x25519" + ) + + # Create keypair + seed = os.urandom(32) + write_cap, read_cap, first_index = await alice_client.new_keypair(seed) + print("✓ Created keypair") + + # Write 3 messages to sequential boxes + num_messages = 3 + current_index = first_index + + print(f"\n--- Writing {num_messages} messages ---") + for i in range(num_messages): + message = f"Message {i+1} to be tombstoned".encode() + ciphertext, env_desc, env_hash = await alice_client.encrypt_write( + message, write_cap, current_index + ) + await alice_client.start_resending_encrypted_message( + read_cap=None, + write_cap=write_cap, + next_message_index=None, + reply_index=0, + envelope_descriptor=env_desc, + message_ciphertext=ciphertext, + envelope_hash=env_hash + ) + print(f"✓ Wrote message {i+1}") + + if i < num_messages - 1: + current_index = await alice_client.next_message_box_index(current_index) + + # Wait for messages to propagate + print("--- Waiting for message propagation (30 seconds) ---") + await asyncio.sleep(30) + + # Tombstone the range + print(f"\n--- Tombstoning {num_messages} boxes ---") + result = await alice_client.tombstone_range(geometry, write_cap, first_index, num_messages) + + print(f"✓ Tombstoned {result['tombstoned']} boxes") + assert result['tombstoned'] == num_messages, f"Expected {num_messages} tombstoned, got {result['tombstoned']}" + assert 'next' in result, "Result should contain 'next' index" + + print(f"\n✅ Tombstone range test passed! Tombstoned {num_messages} boxes successfully!") + + finally: + alice_client.stop() +