How to Build Secure Tunnels with Sandwich in Python

by James Howe and Duc Nguyen. Posted on Dec 19, 2023
“a painting of a footlong sandwich, arriving to the platform on the London underground.

“a painting of a footlong sandwich, arriving to the platform on the London underground.

The Joy of Cooking Secure Tunnels in Python

Welcome to the SandboxAQ blog, where we’re all about making complex concepts digestible and, dare we say, delicious! Today, we’re serving up a hearty helping of Sandwich - our very own crypto-agile library that we launched this year in August. If you’ve ever wondered what goes into making a Sandwich (library, not lunch), pull up a chair and get ready to feast on some knowledge.

Sandwich is an open-source crypto-agile cryptography library developed by SandboxAQ. It aims to provide a unified, easy-to-use API for developers to implement cryptographic protocols in their applications by supporting popular and trusted cryptography libraries. The library is designed to abstract away many of the choices developers face when adding cryptography to their projects, enabling future centralized management and monitoring. It also supports post-quantum cryptography, enabling developers to experiment with quantum-resistant schemes.

TLDR: In this post, we will take a deep dive into the three ingredients that make up a perfect Sandwich: context-time configuration, tunnel-time configuration, and an I/O interface. We’ll take a look at how each ingredient adds its unique flavor to the mix, and how they all come together to create a secure and efficient tunnel.

We’ll also show you how to cook up your own Sandwich using our library, with code examples in Python. These example recipes range from choosing a backend (OpenSSL or BoringSSL) to configuring your TLS connection, setting up your I/O interface, and we’ll guide you through each step of the process.

Finally, to cap it all off, we’ll serve up a classic “echo” client-server example using our Sandwich library. We provide code examples here but these examples can also be found in complete Python scripts in the Sandwich GitHub. It’s like a cooking show but for code! So, grab your apron, heat up your IDE, and let’s get cooking with Sandwich!

1. Tunnel concepts

In Sandwich, a tunnel is a secure channel that enables data travel between a client and server while being encrypted to ensure the communication is confidential.

To create a tunnel in Sandwich, we’ll need three ingredients:

  1. A context-time configuration
  2. A tunnel-time configuration
  3. An I/O interface

All configurations in Sandwich are defined in Protobuf format.

1.1 A context-time configuration

The context-time configuration allows you to define the overall structure of the TLS tunnel via Protobuf format.

To begin, we need to select a backend. Right now, Sandwich supports two backends:

  1. OpenSSL 3.2 with oqs-provider
  2. OQS’ fork of BoringSSL

For this example we’ll choose OpenSSL 3.2 and oqs-provider by Open Quantum Safe. This backend choice is flexible, and you can always switch to another backend provider without recompiling the Sandwich library. That kind of flexibility is an example of how Sandwich enables crypto-agility.

To decide on our backend choice, we simply write:

impl: IMPL_OPENSSL3_OQS_PROVIDER

Second, we select either the client or server this configuration is applied.

Let’s say this configuration is applied for client-side.

client {
  tls {
    ....
  }
}

Next, we specify the configuration for TLS connection. To do this, we can add a certificate to the X509 trust store, or define ciphersuites for TLS version 1.2 or 1.3. We can also specify the Key Exchange (KE) algorithm in TLS 1.3. In the below example, our selected KE algorithm is the NIST PQC standard, kyber512. In addition, we can add the ALPN protocol in case we are connecting to a HTTP site.

tls {
  common_options {
    ... snip ...
    tls12 {
      ciphersuite: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
    }
    tls13 {
      ke: "kyber512"
      ciphersuite: "TLS_AES_128_GCM_SHA256"
    }
    alpn_protocols: "h2"
    alpn_protocols: "http/1.1"
  }
}

1.2 A tunnel-time configuration

The tunnel-time configuration is a special kind of configuration, where the information is more suitable to provide at runtime.

In the below example, we set the Server Name Indication (SNI) to our website. If we don’t want to verify a website via its Subject Alternative Names (SAN) or X.509 Certificate Authorities, we can always choose empty_verifier, it’s just that the identity of the website we connect to is not verified.

verifier {
  empty_verifier {
  }
}
server_name_indication: "sandboxaq.com"

1.3 An I/O interface

We’ve designed a straightforward I/O interface specifically for tunnel communication. Our interface is versatile and can be used with any other interface that offers the following functionalities:

  • read(n: int): Read n bytes from a cleartext connection
  • write(buffer: bytes): Write buffer to a cleartext connection
  • close(): Close a connection

Our I/O interface is adaptable and can be used in a variety of ways. For instance, it can be used with a socket connection as a Sandwich I/O, or for data transmission through a Linux Pipe. For those who are proficient in Python, you’ll find that our I/O interface is compatible with ssl.MemoryBIO or io.BytesIO, allowing you to use them as a Sandwich I/O interface!

We know that people love sockets, and it’s often the case that your TLS connection is built on top of TCP/IP socket connection. So in Sandwich, we provide socket APIs, too.

1.3.1 Socket for client-side

Creating a socket I/O interface at client-side in Python is simple: “Make a TCP connection to an address via a hostname and a port number”, you can either use a battery-included Python socket module or built-in Sandwich socket API.

In the below example, we can either use python_socket or sandwich_socket as a Sandwich I/O interface.

# Creates I/O interface using Python socket
python_socket = socket.create_connection(('sandboxaq.com', 443))

# Creates I/O interface using built-in Sandwich socket API
sandwich_socket = io_helper.io_client_tcp_new('sandboxaq.com', 443, is_blocking=False)

1.3.2 Socket for server-side

Creating an I/O interface at server-side in Python is a little bit more work. In Python, we can use a socketserver module or use our built-in Sandwich Listener API.

Below is an example that uses a battery-included Python socket.create_server.

import socket

server = socket.create_server(('localhost', port))
while True:
    python_socket, _ = server.accept()

Or you can use the Sandwich socket API Listener module, which is like socket.create_server, but it supports experimental protocol-enhanced features like TurboTLS.

server = listener.create_tcp_listener('localhost', port)
server.listen()

while True:
    sandwich_socket = server.accept()

In both examples, either python_socket or sandwich_socket can be used as a Sandwich I/O interface.

2. Sandwich TLS tunnel creation

Now it’s time to create the classic echo server and client examples, using the Sandwich TLS tunnel API.

First, we will convert context-time the Protobuf configurations, context_config, into a Sandwich context object. For brevity, let’s assume we receive the unserialized context configuration from somewhere, and then we can create sandwich_context object.

We can create a Sandwich context object from two methods. If the Protobuf configuration is serialized (usually for transfer and storage purposes), then we can use Context.from_bytes(), if the Protobuf configuration is unserialized, then we can use Context.from_config().

from sandwich.python.tunnel import Context
from sandwich.python.sandwich import Sandwich

context_config = open('context_config').read()

sandwich_context = Context.from_config(Sandwich(), context_config)

Second, we converge the Sandwich context we just created, the Sandwich I/O interface, and the tunnel-time configuration altogether before we start the TLS handshake.

from sandwich.python.tunnel import Tunnel

tunnel_config = open('tunnel_config').read()

sandwich_tunnel = Tunnel(sandwich_context, sandwich_socket, tunnel_config)

Now, we have a Sandwich tunnel ready. So what can a TLS tunnel do?

  1. handshake(): Performs TLS Handshake
  2. read(n: int): Reads n bytes from tunnel
  3. write(buffer: bytes): Writes buffer to tunnel
  4. close(): Closes tunnel

The tunnel method is similar to the I/O interface, except that we have an additional handshake method since it’s a TLS connection.

Now let’s move to the classical client and server example.

2.1 Sandwich Client example

from client_configurations import client_ctx, tunnel_conf

def client_to_server(client_ctx, client_io, tunnel_conf):
    # Sets up client tunnel
    client_tunnel = Tunnel(client_ctx, client_io, tunnel_conf)

    client_tunnel.handshake()

    # Sends data to server
    client_tunnel.write(b"Sandwich is cool")

    # Receive data from server
    echo_response = client_tunnel.read(16)
    assert echo_response == b"Sandwich is cool"

    # Closes client tunnel
    client_tunnel.close()

# Creates I/O interface using built-in Sandwich socket API
client_io = io_helper.io_client_tcp_new('localhost', 12345, is_blocking=True)

client_to_server(client_ctx, client_io, tunnel_conf)

We connect to our echo server at localhost port 12345, the I/O interface client_io is a blocking I/O. We then wrap the client_io in a client_to_server function, which creates a Sandwich Tunnel from the imported client context and tunnel configuration. In this example, the Sandwich tunnel performs a TLS handshake with the server, then checks if a message is echoed properly, and then closes the tunnel connection.

2.2 Server echo example

from server_configurations import server_ctx, tunnel_conf

def server_to_client(server_ctx, server_io, tunnel_conf):
    # Sets up server tunnel
    server_tunnel = Tunnel(server_ctx, server_io, tunnel_conf)

    server_tunnel.handshake()

    # Receives data from client
    data = server_tunnel.read(16)
    assert data == b"Sandwich is cool"

    # Echoes data to client
    server_tunnel.write(data)

    # Closes client tunnel
    server_tunnel.close()

server = listener.create_tcp_listener('localhost', 12345)
server.listen()
while True:
    server_io = listener.accept()
    server_to_client(server_ctx, server_io, tunnel_conf)

This Python script could be described as an underground sandwich delivery system. The server_to_client function acts like a sandwich artisan in a secret tunnel, preparing to receive a special order from a client.

First, the artisan takes out his tools, represented by server_ctx and server_io, along with the tunnel configuration tunnel_conf. He then sets up his secret sandwich delivery tunnel with these tools, represented by server_tunnel = Tunnel(server_ctx, server_io, tunnel_conf)

After setting up the tunnel, the artisan initiates a handshake. This isn’t an ordinary handshake; it’s more like a secret code to verify that the client is legit and ready to place an order.

Then, the artisan waits for the order. The order comes in the form of data, data = server_tunnel.read(16). The order must specifically say "Sandwich is cool", or else the artisan won’t proceed. If the order is anything else, he’ll just throw an AssertionError.

Once he receives the correct order, the sandwich artisan proceeds to prepare the sandwich. But in this case, he doesn’t really make anything new; he just echoes back the order to the client, symbolized by server_tunnel.write(data). It’s as if the client ordered a “Sandwich is cool” sandwich and the artisan replied, “Your ‘Sandwich is cool’ sandwich is ready!”

Finally, after delivering the sandwich, the artisan closes the tunnel until the next order arrives, represented by server_tunnel.close().

The server_to_client function is then continuously called in an infinite loop, ready to accept connections and orders from clients. It’s like the sandwich artisan is always ready, waiting for the secret knock on the door that signals a new client and a new order. The artisan’s location ('localhost') and secret knock code (12345) are set up with server = listener.create_tcp_listener('localhost', 12345). His constant vigilance is represented by server.listen(), and he accepts new clients with server_io = listener.accept().

3. Conclusion

And that’s our Python script, a secret sandwich delivery system in the form of code. Ready to serve “Sandwich is cool” sandwiches to anyone who knows the secret knock.

In this blog post, we have shown you how to use Sandwich to create a (PQ-)secure tunnel in Python. We break down this tutorial into steps to learn how to use (i) the context-time configuration, (ii) the tunnel-time configuration, and (iii) the I/O interface. These three parts allow us to create a secure tunnel from start to finish. You can also find all of these examples in the Sandwich GitHub.

References