SecureS2S

End-to-end encrypted service-to-service communication using RSA-2048 + AES-256-GCM hybrid cryptography over length-prefixed TCP sockets.

SecurityCryptographyNetworkingPythonAESRSA
SecureS2S screenshot

Hybrid Cryptographic Protocol (RSA + AES)

THE PROBLEM

Pure asymmetric encryption is computationally expensive for bulk data (RSA-2048 encrypts ~250 bytes per operation). Pure symmetric encryption cannot solve the key distribution problem — two peers with no prior shared secret cannot agree on a key without an out-of-band channel.

WHAT I DID

Implemented a hybrid protocol where RSA-2048 is used exclusively for a one-time session key exchange, and AES-256-GCM handles all subsequent message traffic. The public key and the encrypted session key are both sent through the framed transport layer rather than raw sendall/recv calls, so the handshake is immune to the same partial-read issues as the message stream.

Server B — Key Exchangepython
public_key_bytes = crypto.recv_framed(send_socket)
server_public_key = crypto.load_public_key(public_key_bytes)
session_key = crypto.generate_aes_key()
encrypted_session_key = crypto.encrypt_session_key(server_public_key, session_key)
crypto.send_framed(send_socket, encrypted_session_key)
Server A — Key Receptionpython
crypto.send_framed(conn, public_key_bytes)
encrypted_session_key = crypto.recv_framed(conn)
session_key = crypto.decrypt_session_key(private_key, encrypted_session_key)
WHY THIS APPROACH

The RSA key pair is ephemeral — generated fresh on each run. While the current implementation does not preserve the private key across restarts, the architecture cleanly separates the asymmetric key-exchange layer from the symmetric encryption layer, making it straightforward to swap in ECDHE in the future. A pure-RSA approach for all messages would waste CPU and cap message size at the key modulus.

THE IMPACT

Inherits the performance characteristics of AES-GCM (hardware-accelerated on modern x86 via AES-NI) for all application data, while solving the initial key distribution securely and deterministically, since the handshake reuses the same length-prefixed framing as every other message on the wire.

RSA-OAEP with SHA-256

THE PROBLEM

Textbook RSA (raw encryption without padding) is deterministic and malleable — an attacker can encrypt guesses and compare ciphertexts, or manipulate ciphertexts to produce predictable plaintext changes.

WHAT I DID

Applied OAEP (Optimal Asymmetric Encryption Padding) with SHA-256 as both the hash and MGF1 hash function.

OAEP Padding configurationpython
def encrypt_session_key(public_key, session_key):
    encrypted_key = public_key.encrypt(
        session_key,
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )
    return encrypted_key
WHY THIS APPROACH

OAEP introduces randomness and redundancy so that the same plaintext encrypts to different ciphertexts each time, and decryption fails (with high probability) if the ciphertext was tampered with. The alternative considered was PKCS#1 v1.5 padding, which is simpler but has known padding-oracle attack surfaces (Bleichenbacher, Manger). OAEP eliminates these classes of attacks at the cost of ~10 extra bytes per encryption.

THE IMPACT

Eliminates padding-oracle attacks. Follows NIST-recommended configurations for a 128-bit security level, establishing a secure initial wrapper for the symmetric session key.

AES-256-GCM Authenticated Encryption with Per-Message Nonces

THE PROBLEM

Encryption without integrity checking (e.g., AES-CBC + HMAC separately) complicates the implementation and can introduce subtle bugs like padding-oracle attacks or tag-verification timing leaks.

WHAT I DID

Used AES-GCM, an authenticated encryption (AEAD) mode that combines confidentiality and integrity in a single primitive. Each message generates a fresh 96-bit nonce via os.urandom(12) and appends it to the ciphertext.

AES-GCM Encryption with prepended noncepython
def aes_gcm_encrypt(session_key, plaintext: bytes):
    aesgcm = AESGCM(session_key)
    nonce = os.urandom(12)
    ciphertext = aesgcm.encrypt(nonce, plaintext, None)
    return nonce + ciphertext
Decryption with automatic tag validationpython
def aes_gcm_decrypt(session_key, payload: bytes):
    aesgcm = AESGCM(session_key)
    nonce = payload[:12]
    ciphertext = payload[12:]
    plaintext = aesgcm.decrypt(nonce, ciphertext, None)
    return plaintext
WHY THIS APPROACH

NIST SP 800-38D recommends a 96-bit nonce for AES-GCM as the optimal trade-off between security and message-size limits. Using kernel-level entropy (os.urandom) instead of a counter means nonce reuse is probabilistically infeasible — you would need roughly 2^48 messages for a 50% collision probability. The alternative (AES-CBC + HMAC-SHA256 in encrypt-then-MAC order) would require careful coordination of two keys and two primitives. GCM eliminates this coordination risk by design.

THE IMPACT

Confidentiality and integrity guaranteed without coordination risk. Nonce reuse attacks are mathematically prevented. Any tampered ciphertext is rejected automatically during decryption, and the failure is caught and logged rather than crashing the receive loop.

Length-Prefixed Message Framing over TCP

THE PROBLEM

TCP is a byte stream, not a message protocol — it makes no guarantee that one sendall() call on one end corresponds to one recv() call on the other. A single recv(4096) can return a partial message, a fully buffered message, or several concatenated messages depending on network timing, which silently corrupts AES-GCM decryption (wrong nonce boundary, truncated tag) or the RSA handshake itself.

WHAT I DID

Added a small framing layer to crypto.py: send_framed() prefixes every payload with a 4-byte big-endian length header via struct.pack, and recv_framed() reads that header first, then loops on recv_exact() until the declared number of bytes has actually arrived. Every socket write and read in both servers — the public key, the wrapped session key, and every encrypted chat message — now goes through this layer instead of calling socket.sendall()/recv() directly.

Blocking read until N bytes are collectedpython
def recv_exact(sock, n: int) -> bytes:
    buf = bytearray()
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            return b''
        buf.extend(chunk)
    return bytes(buf)
Length-prefixed send/receivepython
def send_framed(sock, data: bytes):
    sock.sendall(struct.pack('>I', len(data)) + data)


def recv_framed(sock):
    header = recv_exact(sock, 4)
    if not header:
        return None
    (length,) = struct.unpack('>I', header)
    data = recv_exact(sock, length)
    if not data and length != 0:
        return None
    return data
WHY THIS APPROACH

A fixed-size length header is the simplest framing scheme that fully solves the boundary problem without adding a parser or a delimiter that could collide with binary ciphertext. The alternative of using a delimiter byte (like newline) is unsafe here because the payloads are raw encrypted bytes that can legitimately contain any byte value, including the delimiter itself. recv_exact() is a short blocking loop that keeps calling recv() until the requested number of bytes is collected or the socket closes, which is the standard fix for TCP short reads.

THE IMPACT

Removes an entire class of intermittent, hard-to-reproduce bugs where fast consecutive sends (e.g., a message immediately followed by its ACK) could be coalesced or split by the kernel. The handshake and every message are now read exactly once, byte-for-byte, regardless of network conditions or message size.

Full-Duplex Communication with Thread Gating

THE PROBLEM

TCP sockets are inherently symmetrical but Python's input() blocks the calling thread. If the send and receive loops share a single thread, the user cannot receive messages while typing, or cannot send while waiting for a message. On top of that, the receive loop may need to send an ACK before the outbound socket to the peer has finished connecting.

WHAT I DID

Split the communication into two daemon threads per server — one dedicated to receiving, one to sending. Two threading.Event flags coordinate them: key_established gates outgoing chat messages until the session key is derived, and send_ready gates any outbound write (including ACKs fired from the receive thread) until the outbound socket has actually finished connecting.

Thread delegation and synchronizationpython
t_recv = threading.Thread(target=receive_handler, daemon=True)
t_send = threading.Thread(target=send_handler, daemon=True)
t_recv.start()
t_send.start()
t_recv.join()
Gating outgoing chat messages on the session keypython
def send_handler():
    global send_socket_global
    key_established.wait()
    # Proceed to connect or send only after the key is secured
WHY THIS APPROACH

A single-threaded select/poll-based event loop would work but adds complexity (selectors module, non-blocking I/O, manual buffering) with no benefit for a two-peer interactive chat. Threading keeps each logical path (send, receive) as a simple blocking loop. Splitting the synchronization into two separate events, instead of one, was necessary because the receive thread needs to send ACKs independently of whether the user has started typing — a single "ready" flag conflated two different preconditions.

THE IMPACT

True full-duplex communication without complex event loops or asynchronous I/O overhead. Daemons ensure the application exits cleanly when the main thread shuts down, and the two-event design prevents the receive thread from ever blocking indefinitely on an outbound socket that has not connected yet.

Automatic Delivery Acknowledgements (__ACK__ Protocol)

THE PROBLEM

TCP guarantees delivery at the transport layer but gives no application-level signal that the remote peer has read and decrypted a message. A successful sendall() only means the kernel accepted the data — not that it was processed successfully. Firing that ACK also risks a deadlock if the outbound socket to the peer has not connected yet.

WHAT I DID

Built a lightweight application-level ACK protocol. When a peer receives a non-ACK message, it calls send_ack(), which first waits (with a 5-second timeout) on the send_ready event before encrypting and framing the literal __ACK__ under the session key. The sender displays "(Arrived)" when it decrypts an __ACK__. Any failure — a timed-out wait or a socket error — is caught and reported instead of crashing the receive loop.

Replying with a timeout-guarded ACKpython
def send_ack():
    if not send_ready.wait(timeout=5):
        safe_print("[!] Could not send ACK: outbound channel not ready yet.")
        return
    try:
        ack_payload = crypto.aes_gcm_encrypt(session_key, b"__ACK__")
        crypto.send_framed(send_socket_global, ack_payload)
    except Exception as e:
        safe_print(f"[!] Failed to send ACK: {e}")
Receiving and parsing ACKpython
if msg_text == "__ACK__":
    safe_print(" (Arrived)")
else:
    safe_print(f"[Server B]: {msg_text}")
    send_ack()
WHY THIS APPROACH

The __ACK__ protocol verifies that the entire cryptographic pipeline — decrypt, decode, display — completed successfully. A corrupted ciphertext, a wrong key, or a deserialization error would all prevent the ACK from being sent, so the sender knows immediately that delivery failed. The __ACK__ messages themselves are encrypted and framed identically to normal messages, so a passive network observer cannot distinguish ACKs from data messages by pattern alone. The timeout on send_ready avoids the receive thread hanging forever if the outbound connection never comes up.

THE IMPACT

Application-level certainty of message delivery and successful decryption, strictly more informative than a TCP transport ACK, without introducing a new failure mode where the receive thread could stall indefinitely.

Thread-Safe Console Output

THE PROBLEM

When two threads write to stdout simultaneously (one printing an incoming message, one refreshing the input() prompt), the output overlaps and garbles the display.

WHAT I DID

Implemented a safe_print() function that acquires a threading.Lock, clears the current input line with a carriage return and spaces, writes the message, then re-draws the prompt.

Lock-guarded print with line clearingpython
print_lock = threading.Lock()

def safe_print(msg):
    with print_lock:
        sys.stdout.write('\r' + ' ' * 80 + '\r')
        print(msg)
        sys.stdout.write(PROMPT)
        sys.stdout.flush()
WHY THIS APPROACH

The print_lock serializes all terminal output so that message printing and prompt drawing never interleave. The carriage return + spaces technique overwrites any partial user input that was being typed — this is the standard solution for single-line terminal interfaces where you do not want a full curses dependency.

THE IMPACT

Clean, non-garbled console UI despite asynchronous incoming messages and active user typing.

Key Technical Achievements

Hybrid Cryptography

Successfully bridged the key distribution problem using RSA-2048, while retaining the high-performance bulk encryption speed of AES-256-GCM for subsequent messages.

Robust Padding Defenses

Defending against oracle attacks (Bleichenbacher, Manger) by replacing legacy PKCS#1 v1.5 padding with OAEP and SHA-256.

AEAD Security Guarantees

Authenticated encryption with AES-GCM and probabilistically unique nonces ensures that message tampering and replay attacks are mathematically prevented.

Reliable Message Framing

A 4-byte length-prefix protocol with a recv_exact() loop eliminates TCP short-read and message-coalescing bugs across the handshake and every subsequent message.

Verifiable Delivery

Custom encrypted application-level ACKs, gated by a timeout-guarded readiness check, provide absolute certainty that a message was not just delivered, but successfully decrypted, without risking a stalled receive thread.

Clean Multithreading

Two-event thread gating and safe console I/O enable true full-duplex operation without the heavy footprint of asynchronous event loops or curses libraries.