How to Build Secure Tunnels with Sandwich in Python
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:
- A context-time configuration
- A tunnel-time configuration
- 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:
- OpenSSL 3.2 with
oqs-provider
- 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)
: Readn
bytes from a cleartext connectionwrite(buffer: bytes)
: Writebuffer
to a cleartext connectionclose()
: 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?
handshake()
: Performs TLS Handshakeread(n: int)
: Readsn
bytes from tunnelwrite(buffer: bytes)
: Writesbuffer
to tunnelclose()
: 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.