From 975ee6738100d41f7d65e0978abfea4b5a45d31b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 21:09:00 +0000 Subject: [PATCH] feat: Implement secure client and server for Python and Go This commit introduces a secure TCP client and server implementation for Python and Go, based on the MultixAI protocol specification. The implementation includes: - Packet serialization and deserialization logic compliant with the RFC. - Secure communication using TLS. - A simple client that sends a packet and receives a response. - A server that handles multiple clients concurrently. - A comprehensive test suite for both unit and integration testing. The test vectors for CBOR have been corrected, and the TLS certificate has been updated to include a Subject Alternative Name (SAN) for compatibility with modern TLS clients. --- scripts/generate_cbor_vectors.py | 32 +++++++ src/go/cmd/client/main.go | 74 ++++++++++++++++ src/go/cmd/server/main.go | 88 ++++++++++++++++++ src/go/go.mod | 5 ++ src/go/go.sum | 4 + src/go/packet.go | 147 ++++++++++++++++++++++++++++--- src/go/packet_test.go | 145 ++++++++++++++++++++++++++++++ src/python/client.py | 45 ++++++++++ src/python/packet/packet.py | 120 +++++++++++++++++++++++-- src/python/server.crt | 33 +++++++ src/python/server.key | 52 +++++++++++ src/python/server.py | 57 ++++++++++++ tests/python/requirements.txt | 4 +- tests/python/test_protocol.py | 147 +++++++++++++++++++++++++++++++ tests/python/test_vectors.py | 4 +- tests/vectors/handshake.cbor | 2 +- tests/vectors/header.cbor | 2 +- tests/vectors/packet.cbor | 2 +- 18 files changed, 939 insertions(+), 24 deletions(-) create mode 100644 scripts/generate_cbor_vectors.py create mode 100644 src/go/cmd/client/main.go create mode 100644 src/go/cmd/server/main.go create mode 100644 src/go/go.sum create mode 100644 src/go/packet_test.go create mode 100644 src/python/client.py create mode 100644 src/python/server.crt create mode 100644 src/python/server.key create mode 100644 src/python/server.py create mode 100644 tests/python/test_protocol.py diff --git a/scripts/generate_cbor_vectors.py b/scripts/generate_cbor_vectors.py new file mode 100644 index 0000000..6d324b0 --- /dev/null +++ b/scripts/generate_cbor_vectors.py @@ -0,0 +1,32 @@ +import json +import cbor +from pathlib import Path + +def main(): + vectors_dir = Path(__file__).resolve().parent.parent / 'tests' / 'vectors' + json_files = list(vectors_dir.glob('*.json')) + print(f"Found JSON files: {json_files}") + + for json_file in json_files: + cbor_file = json_file.with_suffix('.cbor') + print(f"Generating {cbor_file} from {json_file}") + + with open(json_file, 'r') as f: + data = json.load(f) + + encoded_data = cbor.dumps(data) + print(f"Encoded data for {json_file.name}: {encoded_data}") + + with open(cbor_file, 'wb') as f: + f.write(encoded_data) + + # Verify the file content + with open(cbor_file, 'rb') as f: + read_data = f.read() + print(f"Read back data for {cbor_file.name}: {read_data}") + assert encoded_data == read_data, f"Verification failed for {cbor_file.name}" + + print("CBOR files generated and verified successfully.") + +if __name__ == "__main__": + main() diff --git a/src/go/cmd/client/main.go b/src/go/cmd/client/main.go new file mode 100644 index 0000000..e3d7263 --- /dev/null +++ b/src/go/cmd/client/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "example.com/packet" + "fmt" + "io/ioutil" + "log" +) + +func main() { + log.Println("Starting Go client...") + + cert, err := ioutil.ReadFile("../../python/server.crt") + if err != nil { + log.Fatalf("client: load server cert: %s", err) + } + certPool := x509.NewCertPool() + if ok := certPool.AppendCertsFromPEM(cert); !ok { + log.Fatal("failed to append certs") + } + + config := tls.Config{RootCAs: certPool} + + conn, err := tls.Dial("tcp", "localhost:8443", &config) + if err != nil { + log.Fatalf("client: dial: %s", err) + } + defer conn.Close() + log.Println("Client connected to server") + + // Create and send packet + header := packet.Header{ + VersionFlags: 1, + MessageType: 1, // Request + SessionID: 12345, + ChannelID: 67890, + MediaTypeSub: 1, // TEXT + CodecFormat: 1, // UTF-8 + SeqNumber: 1, + PriorityQoS: 1, + Extensions: map[string]interface{}{"auth_token": "dummy_token_go"}, + } + p := packet.Packet{ + Header: header, + Payload: []byte("Hello from Go client!"), + } + + reqBytes, err := p.Serialize() + if err != nil { + log.Fatalf("client: serialize error: %s", err) + } + + if _, err := conn.Write(reqBytes); err != nil { + log.Fatalf("client: write error: %s", err) + } + log.Println("Sent packet") + + // Read response + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + log.Fatalf("client: read error: %s", err) + } + log.Printf("Received %d bytes", n) + + respPacket, err := packet.Deserialize(buf[:n]) + if err != nil { + log.Fatalf("client: deserialize error: %s", err) + } + fmt.Printf("Received response: %+v\n", respPacket.Header) + fmt.Printf("Payload: %s\n", string(respPacket.Payload)) +} diff --git a/src/go/cmd/server/main.go b/src/go/cmd/server/main.go new file mode 100644 index 0000000..12dfd4e --- /dev/null +++ b/src/go/cmd/server/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "crypto/tls" + "example.com/packet" + "fmt" + "io" + "log" + "net" +) + +func main() { + log.Println("Starting Go server...") + + cert, err := tls.LoadX509KeyPair("../../python/server.crt", "../../python/server.key") + if err != nil { + log.Fatalf("server: loadkeys: %s", err) + } + config := tls.Config{Certificates: []tls.Certificate{cert}} + + ln, err := tls.Listen("tcp", ":8443", &config) + if err != nil { + log.Fatalf("server: listen: %s", err) + } + defer ln.Close() + + log.Println("Server is listening on port 8443") + + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("server: accept: %s", err) + break + } + log.Printf("Accepted connection from %s", conn.RemoteAddr()) + go handleConnection(conn) + } +} + +func handleConnection(conn net.Conn) { + defer conn.Close() + log.Printf("Handling connection for %s", conn.RemoteAddr()) + + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if err != nil { + if err != io.EOF { + log.Printf("server: read: %s", err) + } + break + } + log.Printf("Received %d bytes", n) + + p, err := packet.Deserialize(buf[:n]) + if err != nil { + log.Printf("server: deserialize error: %s", err) + continue + } + fmt.Printf("Received packet: %+v\n", p.Header) + + // Create and send response + responseHeader := packet.Header{ + VersionFlags: p.Header.VersionFlags, + MessageType: 2, // Response + SessionID: p.Header.SessionID, + ChannelID: p.Header.ChannelID, + SeqNumber: p.Header.SeqNumber + 1, + } + responsePacket := packet.Packet{ + Header: responseHeader, + Payload: []byte("Hello from Go server!"), + } + + respBytes, err := responsePacket.Serialize() + if err != nil { + log.Printf("server: serialize error: %s", err) + continue + } + + if _, err := conn.Write(respBytes); err != nil { + log.Printf("server: write error: %s", err) + break + } + log.Println("Sent response packet") + } + log.Printf("Connection closed for %s", conn.RemoteAddr()) +} diff --git a/src/go/go.mod b/src/go/go.mod index 3746c30..997e1e9 100644 --- a/src/go/go.mod +++ b/src/go/go.mod @@ -1,3 +1,8 @@ module example.com/packet go 1.20 + +require ( + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/x448/float16 v0.8.4 // indirect +) diff --git a/src/go/go.sum b/src/go/go.sum new file mode 100644 index 0000000..17d585a --- /dev/null +++ b/src/go/go.sum @@ -0,0 +1,4 @@ +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= diff --git a/src/go/packet.go b/src/go/packet.go index 6f85e04..3dd9b91 100644 --- a/src/go/packet.go +++ b/src/go/packet.go @@ -1,21 +1,148 @@ package packet +import ( + "bytes" + "encoding/binary" + "fmt" + "github.com/fxamacker/cbor/v2" +) + type Header struct { - ID uint32 - Version uint16 + VersionFlags uint16 + MessageType uint8 + SessionID uint32 + ChannelID uint32 + MediaTypeSub uint8 + CodecFormat uint8 + SeqNumber uint16 + PriorityQoS uint8 + HeaderLength uint16 + PayloadLength uint32 + Extensions map[string]interface{} } type Packet struct { - Header Header - Payload []byte + Header Header + Payload []byte } -func (p *Packet) Serialize() []byte { - // TODO: implement serialization - return []byte{} +func (p *Packet) Serialize() ([]byte, error) { + var buf bytes.Buffer + + // Serialize extensions to CBOR + extBytes, err := cbor.Marshal(p.Header.Extensions) + if err != nil { + return nil, fmt.Errorf("failed to marshal extensions: %w", err) + } + + // Calculate header and payload length + // The fixed header size is 22 bytes + // H(2) + B(1) + I(4) + I(4) + B(1) + B(1) + H(2) + B(1) + H(2) + I(4) = 22 + fixedHeaderSize := 22 + p.Header.HeaderLength = uint16(fixedHeaderSize + len(extBytes)) + p.Header.PayloadLength = uint32(len(p.Payload)) + + // Write fixed-size header fields + if err := binary.Write(&buf, binary.BigEndian, p.Header.VersionFlags); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.MessageType); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.SessionID); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.ChannelID); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.MediaTypeSub); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.CodecFormat); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.SeqNumber); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.PriorityQoS); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.HeaderLength); err != nil { + return nil, err + } + if err := binary.Write(&buf, binary.BigEndian, p.Header.PayloadLength); err != nil { + return nil, err + } + + // Write extensions + if _, err := buf.Write(extBytes); err != nil { + return nil, err + } + + // Write payload + if _, err := buf.Write(p.Payload); err != nil { + return nil, err + } + + return buf.Bytes(), nil } -func Deserialize(data []byte) *Packet { - // TODO: implement deserialization - return &Packet{} +func Deserialize(data []byte) (*Packet, error) { + buf := bytes.NewReader(data) + var packet Packet + + // Read fixed-size header fields + if err := binary.Read(buf, binary.BigEndian, &packet.Header.VersionFlags); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.MessageType); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.SessionID); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.ChannelID); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.MediaTypeSub); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.CodecFormat); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.SeqNumber); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.PriorityQoS); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.HeaderLength); err != nil { + return nil, err + } + if err := binary.Read(buf, binary.BigEndian, &packet.Header.PayloadLength); err != nil { + return nil, err + } + + // Read extensions + fixedHeaderSize := 22 + extLen := int(packet.Header.HeaderLength) - fixedHeaderSize + if extLen > 0 { + extBytes := make([]byte, extLen) + if _, err := buf.Read(extBytes); err != nil { + return nil, fmt.Errorf("failed to read extensions: %w", err) + } + if err := cbor.Unmarshal(extBytes, &packet.Header.Extensions); err != nil { + return nil, fmt.Errorf("failed to unmarshal extensions: %w", err) + } + } + + // Read payload + if packet.Header.PayloadLength > 0 { + packet.Payload = make([]byte, packet.Header.PayloadLength) + if _, err := buf.Read(packet.Payload); err != nil { + return nil, fmt.Errorf("failed to read payload: %w", err) + } + } + + return &packet, nil } diff --git a/src/go/packet_test.go b/src/go/packet_test.go new file mode 100644 index 0000000..659dd61 --- /dev/null +++ b/src/go/packet_test.go @@ -0,0 +1,145 @@ +package packet + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "io/ioutil" + "log" + "reflect" + "testing" + "time" +) + +func TestPacket_SerializeDeserialize(t *testing.T) { + header := Header{ + VersionFlags: 1, + MessageType: 1, + SessionID: 12345, + ChannelID: 67890, + MediaTypeSub: 1, + CodecFormat: 1, + SeqNumber: 1, + PriorityQoS: 1, + Extensions: map[string]interface{}{"auth_token": "dummy_token_go"}, + } + p := Packet{ + Header: header, + Payload: []byte("Hello from Go client!"), + } + + serialized, err := p.Serialize() + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + + deserialized, err := Deserialize(serialized) + if err != nil { + t.Fatalf("Deserialize() error = %v", err) + } + + if !reflect.DeepEqual(p.Header, deserialized.Header) { + t.Errorf("Header mismatch: got %+v, want %+v", deserialized.Header, p.Header) + } + if !bytes.Equal(p.Payload, deserialized.Payload) { + t.Errorf("Payload mismatch: got %s, want %s", string(deserialized.Payload), string(p.Payload)) + } +} + +func TestIntegration_ClientServer(t *testing.T) { + // Server setup + cert, err := tls.LoadX509KeyPair("../python/server.crt", "../python/server.key") + if err != nil { + t.Fatalf("server: loadkeys: %s", err) + } + config := tls.Config{Certificates: []tls.Certificate{cert}} + ln, err := tls.Listen("tcp", "localhost:8444", &config) // Use different port for test + if err != nil { + t.Fatalf("server: listen: %s", err) + } + defer ln.Close() + + serverReady := make(chan struct{}) + go func() { + serverReady <- struct{}{} + conn, err := ln.Accept() + if err != nil { + log.Printf("server accept error: %v", err) + return + } + defer conn.Close() + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + t.Errorf("server read error: %v", err) + return + } + + p, err := Deserialize(buf[:n]) + if err != nil { + t.Errorf("server deserialize error: %v", err) + return + } + + respHeader := Header{ + VersionFlags: p.Header.VersionFlags, + MessageType: 2, + SessionID: p.Header.SessionID, + ChannelID: p.Header.ChannelID, + SeqNumber: p.Header.SeqNumber + 1, + } + respPacket := Packet{Header: respHeader, Payload: []byte("response from test server")} + respBytes, _ := respPacket.Serialize() + _, err = conn.Write(respBytes) + if err != nil { + t.Errorf("server write error: %v", err) + } + }() + + <-serverReady + + // Client setup + clientCert, err := ioutil.ReadFile("../python/server.crt") + if err != nil { + t.Fatalf("client: load server cert: %s", err) + } + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM(clientCert) + clientConfig := tls.Config{RootCAs: certPool, ServerName: "localhost"} + + // Give server a moment to start listening + time.Sleep(100 * time.Millisecond) + + conn, err := tls.Dial("tcp", "localhost:8444", &clientConfig) + if err != nil { + t.Fatalf("client: dial: %s", err) + } + defer conn.Close() + + header := Header{SessionID: 111} + p := Packet{Header: header, Payload: []byte("client test")} + reqBytes, _ := p.Serialize() + _, err = conn.Write(reqBytes) + if err != nil { + t.Fatalf("client write error: %v", err) + } + + respBuf := make([]byte, 1024) + n, err := conn.Read(respBuf) + if err != nil { + t.Fatalf("client read error: %v", err) + } + + resp, err := Deserialize(respBuf[:n]) + if err != nil { + t.Fatalf("client deserialize error: %v", err) + } + + if resp.Header.MessageType != 2 { + t.Errorf("expected message type 2, got %d", resp.Header.MessageType) + } + if !bytes.Equal(resp.Payload, []byte("response from test server")) { + t.Errorf("unexpected payload: %s", string(resp.Payload)) + } +} diff --git a/src/python/client.py b/src/python/client.py new file mode 100644 index 0000000..3512403 --- /dev/null +++ b/src/python/client.py @@ -0,0 +1,45 @@ +import socket +import ssl +from packet.packet import Packet, Header + +def main(): + host = 'localhost' + port = 8443 + server_cert = 'src/python/server.crt' + + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=server_cert) + context.check_hostname = False + + with socket.create_connection((host, port)) as sock: + with context.wrap_socket(sock, server_hostname=host) as ssock: + print(f"Connected to server at {host}:{port}") + + # Create a sample packet to send + header = Header( + version_flags=1, + message_type=1, # Request + session_id=12345, + channel_id=67890, + media_type_sub=1, # TEXT + codec_format=1, # UTF-8 + seq_number=1, + priority_qos=1, + header_length=0, # will be calculated in serialize + payload_length=0, # will be calculated in serialize + extensions={"auth_token": "dummy_token"} + ) + packet = Packet(header, b"Hello from client!") + + print(f"Sending packet: {packet}") + ssock.sendall(packet.serialize()) + + # Receive the response + response_data = ssock.recv(4096) + if response_data: + response_packet = Packet.deserialize(response_data) + print(f"Received response: {response_packet}") + else: + print("No response received from server.") + +if __name__ == "__main__": + main() diff --git a/src/python/packet/packet.py b/src/python/packet/packet.py index 5248a96..44a45f0 100644 --- a/src/python/packet/packet.py +++ b/src/python/packet/packet.py @@ -1,19 +1,125 @@ +import struct +import cbor + class Header: - def __init__(self, id: int, version: int): - self.id = id - self.version = version + def __init__(self, + version_flags: int, + message_type: int, + session_id: int, + channel_id: int, + media_type_sub: int, + codec_format: int, + seq_number: int, + priority_qos: int, + header_length: int, + payload_length: int, + extensions: dict = None): + self.version_flags = version_flags + self.message_type = message_type + self.session_id = session_id + self.channel_id = channel_id + self.media_type_sub = media_type_sub + self.codec_format = codec_format + self.seq_number = seq_number + self.priority_qos = priority_qos + self.header_length = header_length + self.payload_length = payload_length + self.extensions = extensions if extensions is not None else {} + def __repr__(self): + return (f"Header(version_flags={self.version_flags}, " + f"message_type={self.message_type}, " + f"session_id={self.session_id}, " + f"channel_id={self.channel_id}, " + f"media_type_sub={self.media_type_sub}, " + f"codec_format={self.codec_format}, " + f"seq_number={self.seq_number}, " + f"priority_qos={self.priority_qos}, " + f"header_length={self.header_length}, " + f"payload_length={self.payload_length}, " + f"extensions={self.extensions})") class Packet: + # H: unsigned short (2), B: unsigned char (1), I: unsigned int (4) + # The header format is: + # 2B Version/Flags + # 1B Message Type + # 4B Session ID + # 4B Channel ID + # 1B Media Type/Sub + # 1B Codec/Format + # 2B Seq Number + # 1B Priority/QoS + # 2B Header Length + # 4B Payload Length + _HEADER_FORMAT = "!HBIIBBHBHI" + _HEADER_SIZE = struct.calcsize(_HEADER_FORMAT) + def __init__(self, header: Header, payload: bytes = b""): self.header = header self.payload = payload def serialize(self) -> bytes: - # TODO: implement serialization - return b"" + encoded_extensions = cbor.dumps(self.header.extensions) + self.header.header_length = self._HEADER_SIZE + len(encoded_extensions) + self.header.payload_length = len(self.payload) + + header_bytes = struct.pack( + self._HEADER_FORMAT, + self.header.version_flags, + self.header.message_type, + self.header.session_id, + self.header.channel_id, + self.header.media_type_sub, + self.header.codec_format, + self.header.seq_number, + self.header.priority_qos, + self.header.header_length, + self.header.payload_length + ) + return header_bytes + encoded_extensions + self.payload @staticmethod def deserialize(data: bytes) -> "Packet": - # TODO: implement deserialization - return Packet(Header(0, 0), b"") + if len(data) < Packet._HEADER_SIZE: + raise ValueError("Data is too short to be a valid packet.") + + # Unpack the fixed part of the header + header_values = struct.unpack(Packet._HEADER_FORMAT, data[:Packet._HEADER_SIZE]) + + version_flags = header_values[0] + message_type = header_values[1] + session_id = header_values[2] + channel_id = header_values[3] + media_type_sub = header_values[4] + codec_format = header_values[5] + seq_number = header_values[6] + priority_qos = header_values[7] + header_length = header_values[8] + payload_length = header_values[9] + + # Extract extensions + extensions_bytes = data[Packet._HEADER_SIZE:header_length] + extensions = cbor.loads(extensions_bytes) if extensions_bytes else {} + + header = Header( + version_flags=version_flags, + message_type=message_type, + session_id=session_id, + channel_id=channel_id, + media_type_sub=media_type_sub, + codec_format=codec_format, + seq_number=seq_number, + priority_qos=priority_qos, + header_length=header_length, + payload_length=payload_length, + extensions=extensions + ) + + # Extract payload + payload = data[header_length:header_length + payload_length] + + return Packet(header, payload) + + def __repr__(self): + return f"Packet(header={self.header}, payload={self.payload})" diff --git a/src/python/server.crt b/src/python/server.crt new file mode 100644 index 0000000..67c7e56 --- /dev/null +++ b/src/python/server.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFyTCCA7GgAwIBAgIUQVx0lEwLCmj3StOEfrll+/WR4lUwDQYJKoZIhvcNAQEL +BQAwaTELMAkGA1UEBhMCWFgxDjAMBgNVBAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5 +MRUwEwYDVQQKDAxPcmdhbml6YXRpb24xEDAOBgNVBAsMB09yZ1VuaXQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yNTA4MDgwNjE4NDRaFw0yNjA4MDgwNjE4NDRaMGkx +CzAJBgNVBAYTAlhYMQ4wDAYDVQQIDAVTdGF0ZTENMAsGA1UEBwwEQ2l0eTEVMBMG +A1UECgwMT3JnYW5pemF0aW9uMRAwDgYDVQQLDAdPcmdVbml0MRIwEAYDVQQDDAls +b2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDZhdY/sMO/ +GOg/akOBR9WfbWpD1g8Cz0D9Pytg50278bhupb6dKkRWA69nrxviUU1AA2BImggQ +rebqk4ET7cwb+GgiTW7M+prXY9B/uSJKtoogCLB4XHuJcrNUdLsDmfu+V4V5CJE8 +OBWM1NeUd0Dr+rL4OjlRdh+uuOsgv5hZfTfq/At8O4yqLCkYt2uAfqVcJFJ9Y/QH +uYgXGHo2Ng0i8bJ9mmkvrbMbWnsNYk+lTD2CzSke95bcASWArEEl3OefBYRZhpsw +pCn2MIFpa3FOXR3jOrB9KIFPoIh5zX+eZvsg4uSFfUuU2JS+SAtQb2KcR6msPuEE +syf4V1rPQdPxUCdJdBapanaz74pC5VWrEpVenpNlEONgc8NNwk15Yj3h63SVrib4 +sU5MENxeYjxgZDhtI5rbjOl5QpJu2V9+v5/g5ChM9NpWuJFk1ai1pZl15sH4JqwC +A09mQ7gZ9U67/bE0gYiAswEFtkCTRAeWA7q1kFov0g5XVy/lw23EUaVA7RxRP4nR +W3b3Kz05F9RGKu3v/uTTZ/yXHB/0EtmKNEsE2w/A8YY3kCnBslDkk56KKwZtOtaI +PxKyDGUCO6KUciR7kuBHYkM6B09EMC24DXGR+V3NKNOItNdzqBct9RAM0RmYSaU8 +AE73hzXTHODglrtCEpHY1wTfhWLmsID8NwIDAQABo2kwZzAdBgNVHQ4EFgQUPoDr +9Ij3QsLjr8MGwl3UGr/gOZIwHwYDVR0jBBgwFoAUPoDr9Ij3QsLjr8MGwl3UGr/g +OZIwDwYDVR0TAQH/BAUwAwEB/zAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJKoZI +hvcNAQELBQADggIBACgitLU9fQ+OMPw8udAqDGA6A9WQngTlE6I49BVse1PgZDSY +XuAeonXps+PBNDYvfng4+7z0I5yQfT2n8diz5QGNQjPpyVplhBSHnAZk4Kbx5GvX +wxMP7eScUofz/8ZSb0BoLtqrSodkyGjMhcqx40KmR0dU/VOGdVGYsDXJo5UN3+Dy +MNfRy93tdRB3nHLMGUiwXFSKE1daFdcb4tv0hcTbda8BsuhahhxsyKvuerfPvEM7 +iEYjVxQqPZnqVyo3JRG/byvJQHf+qrNUdkZdYgnf6PSu4Bc9wY0f1498OEOxXQ2Z +oDLBvlZIBsOzDg020FxE6DsT942e6u3TJeDrOSCpoE7vG1xvhswe4QbFJBZDlU9a +5/PLCFQL+Cj4kBmnPCTUL1rUdPNJl/1V5tE1M5Oe/kplGHBlWTs+ckRprWqPFBg9 +OB/uUriUASvWv19sYjbng1x/B+PQVVpKhBhh3uuFQ4Gzxu/+j4bdrIRin3YZNUAm +G62Zo4+n6epNgiHFtHsLDepmwdmW0BojspdMk5T2Z6Os3ibwe5ykIb/4OmgkiHBX +JHAxeCHX+kmdhLfUWXjfv5rJUDl6GAr4mgqIX49XoajNMMrIbGSLj0kxJZB8qFxg +Wa0HkqmynK5EO9LhVBl6s02KLNLU9Oi59RkvtAORKJQDzcNDJnGliZ34k8Ec +-----END CERTIFICATE----- diff --git a/src/python/server.key b/src/python/server.key new file mode 100644 index 0000000..10b7e9c --- /dev/null +++ b/src/python/server.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +FcxS7hyg8gRh+9uXzdF/LF5/WCoFNQ== +-----END PRIVATE KEY----- diff --git a/src/python/server.py b/src/python/server.py new file mode 100644 index 0000000..9524fa1 --- /dev/null +++ b/src/python/server.py @@ -0,0 +1,57 @@ +import socket +import ssl +from packet.packet import Packet, Header + +def main(): + host = 'localhost' + port = 8443 + + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile="src/python/server.crt", keyfile="src/python/server.key") + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) as sock: + sock.bind((host, port)) + sock.listen(5) + print(f"Server listening on {host}:{port}") + + with context.wrap_socket(sock, server_side=True) as ssock: + while True: + try: + conn, addr = ssock.accept() + with conn: + print(f"Accepted connection from {addr}") + while True: + data = conn.recv(4096) + if not data: + break + + try: + packet = Packet.deserialize(data) + print(f"Received packet: {packet}") + + # Create a response + response_header = Header( + version_flags=packet.header.version_flags, + message_type=2, # Response + session_id=packet.header.session_id, + channel_id=packet.header.channel_id, + media_type_sub=0, + codec_format=0, + seq_number=packet.header.seq_number + 1, + priority_qos=packet.header.priority_qos, + header_length=0, # will be calculated in serialize + payload_length=0 # will be calculated in serialize + ) + response_packet = Packet(response_header, b"Hello from server!") + conn.sendall(response_packet.serialize()) + except Exception as e: + print(f"Error processing packet: {e}") + break + except ssl.SSLError as e: + print(f"SSL Error: {e}") + except Exception as e: + print(f"An error occurred: {e}") + break + +if __name__ == "__main__": + main() diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index ef5a398..a2d6408 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -1,2 +1,2 @@ -pytest -cbor2 +pytest==7.4.2 +cbor==1.0.0 diff --git a/tests/python/test_protocol.py b/tests/python/test_protocol.py new file mode 100644 index 0000000..faecf01 --- /dev/null +++ b/tests/python/test_protocol.py @@ -0,0 +1,147 @@ +import pytest +import threading +import time +import socket +import ssl +import sys +from pathlib import Path + +# Add the project root to the python path to allow imports +ROOT_DIR = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT_DIR / 'src')) + +from src.python.packet.packet import Packet, Header + +@pytest.fixture +def sample_header(): + return Header( + version_flags=1, + message_type=1, + session_id=12345, + channel_id=67890, + media_type_sub=1, + codec_format=1, + seq_number=1, + priority_qos=1, + header_length=0, + payload_length=0, + extensions={"auth_token": "dummy_token"} + ) + +def test_packet_serialization_deserialization(sample_header): + """Tests that a packet can be serialized and deserialized correctly.""" + packet = Packet(sample_header, b"Hello, this is a test payload.") + + serialized_packet = packet.serialize() + deserialized_packet = Packet.deserialize(serialized_packet) + + assert packet.header.version_flags == deserialized_packet.header.version_flags + assert packet.header.message_type == deserialized_packet.header.message_type + assert packet.header.session_id == deserialized_packet.header.session_id + assert packet.header.channel_id == deserialized_packet.header.channel_id + assert packet.header.media_type_sub == deserialized_packet.header.media_type_sub + assert packet.header.codec_format == deserialized_packet.header.codec_format + assert packet.header.seq_number == deserialized_packet.header.seq_number + assert packet.header.priority_qos == deserialized_packet.header.priority_qos + assert packet.header.extensions == deserialized_packet.header.extensions + assert packet.payload == deserialized_packet.payload + +def test_packet_serialization_deserialization_no_extensions(sample_header): + """Tests packet serialization/deserialization with no extensions.""" + sample_header.extensions = {} + packet = Packet(sample_header, b"Payload without extensions.") + + serialized_packet = packet.serialize() + deserialized_packet = Packet.deserialize(serialized_packet) + + assert deserialized_packet.header.extensions == {} + assert packet.payload == deserialized_packet.payload + +def test_packet_serialization_deserialization_empty_payload(sample_header): + """Tests packet serialization/deserialization with an empty payload.""" + packet = Packet(sample_header, b"") + + serialized_packet = packet.serialize() + deserialized_packet = Packet.deserialize(serialized_packet) + + assert deserialized_packet.payload == b"" + assert packet.header.extensions == deserialized_packet.header.extensions + +# Integration test for client and server + +HOST = 'localhost' +PORT = 8444 # Use a different port to avoid conflict with running server +SERVER_CERT = str(ROOT_DIR / 'src/python/server.crt') +SERVER_KEY = str(ROOT_DIR / 'src/python/server.key') + +def server_thread_func(server_ready_event, stop_event): + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain(certfile=SERVER_CERT, keyfile=SERVER_KEY) + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((HOST, PORT)) + sock.listen(1) + server_ready_event.set() + + sock.settimeout(1.0) # Timeout for accept + + while not stop_event.is_set(): + try: + with context.wrap_socket(sock, server_side=True) as ssock: + conn, addr = ssock.accept() + with conn: + data = conn.recv(4096) + if data: + packet = Packet.deserialize(data) + response_header = Header( + version_flags=packet.header.version_flags, + message_type=2, + session_id=packet.header.session_id, + channel_id=packet.header.channel_id, + media_type_sub=0, + codec_format=0, + seq_number=packet.header.seq_number + 1, + priority_qos=packet.header.priority_qos, + header_length=0, + payload_length=0 + ) + response_packet = Packet(response_header, b"Response from test server") + conn.sendall(response_packet.serialize()) + break # Handle one connection and exit loop + except socket.timeout: + continue # Check stop_event again + except Exception: + break + + +def test_client_server_communication(sample_header): + """Tests that the client and server can communicate over a secure connection.""" + server_ready_event = threading.Event() + stop_event = threading.Event() + + server = threading.Thread(target=server_thread_func, args=(server_ready_event, stop_event)) + server.daemon = True + server.start() + + try: + if not server_ready_event.wait(timeout=5): + pytest.fail("Server did not start in time.") + + context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=SERVER_CERT) + context.check_hostname = False + + with socket.create_connection((HOST, PORT)) as sock: + with context.wrap_socket(sock, server_hostname=HOST) as ssock: + packet_to_send = Packet(sample_header, b"Client test message") + ssock.sendall(packet_to_send.serialize()) + + response_data = ssock.recv(4096) + response_packet = Packet.deserialize(response_data) + + assert response_packet.header.message_type == 2 + assert response_packet.header.seq_number == sample_header.seq_number + 1 + assert response_packet.payload == b"Response from test server" + finally: + stop_event.set() + server.join(timeout=2) diff --git a/tests/python/test_vectors.py b/tests/python/test_vectors.py index a6faddb..8410aea 100644 --- a/tests/python/test_vectors.py +++ b/tests/python/test_vectors.py @@ -1,5 +1,5 @@ import json -import cbor2 +import cbor from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -8,7 +8,7 @@ def load_json(name): return json.loads((ROOT / 'vectors' / f'{name}.json').read_text()) def load_cbor(name): - return cbor2.loads((ROOT / 'vectors' / f'{name}.cbor').read_bytes()) + return cbor.loads((ROOT / 'vectors' / f'{name}.cbor').read_bytes()) def test_header_parity(): assert load_json('header') == load_cbor('header') diff --git a/tests/vectors/handshake.cbor b/tests/vectors/handshake.cbor index 66246cd..8da8289 100644 --- a/tests/vectors/handshake.cbor +++ b/tests/vectors/handshake.cbor @@ -1 +1 @@ -jsession_idfabc123hchannels \ No newline at end of file +\xa2jsession_idfabc123hchannels\x83 \ No newline at end of file diff --git a/tests/vectors/header.cbor b/tests/vectors/header.cbor index 795a3d2..036dd98 100644 --- a/tests/vectors/header.cbor +++ b/tests/vectors/header.cbor @@ -1 +1 @@ -gversionecodecdh264itimestamp[ \ No newline at end of file +\xa3gversionecodecdh264itimestamp[\xcd \ No newline at end of file diff --git a/tests/vectors/packet.cbor b/tests/vectors/packet.cbor index 9b36cc8..01e81df 100644 --- a/tests/vectors/packet.cbor +++ b/tests/vectors/packet.cbor @@ -1 +1 @@ -hsequencehprioritygpayloaddZm9v \ No newline at end of file +\xa3hsequencehprioritygpayloaddZm9v \ No newline at end of file