Skip to content

TLS client example

This example creates a netcat-like client for TLS servers using Sandwich. It does not validate any server-side certificates, and should be used for educational purposes only.

Go implementation

The Go version makes usage of Go channels for an efficient implementation. It can be run with:

$ bazelisk run //examples/go/tls_client:tls_client -- -host 127.0.0.1 -port 4444

Its source code is the following (examples/go/tls_client/main.go):

// Copyright (c) SandboxAQ. All rights reserved.
// SPDX-License-Identifier: AGPL-3.0-only

package main

import (
    "flag"
    "fmt"
    "io"
    "log"
    "os"
    "os/signal"
    "syscall"

    swapi "github.com/sandbox-quantum/sandwich/go/proto/sandwich/api/v1"
    sw "github.com/sandbox-quantum/sandwich/go"
    swio "github.com/sandbox-quantum/sandwich/go/io"
    swtunnel "github.com/sandbox-quantum/sandwich/go/tunnel"
)

func createClientConfiguration(cert *string, tls_version *string) *swapi.Configuration {
    EmptyVerifier := &swapi.TLSOptions_EmptyVerifier{
        EmptyVerifier: &swapi.EmptyVerifier{},
    }

    x509_verifier := &swapi.TLSOptions_X509Verifier{
        X509Verifier: &swapi.X509Verifier{
            TrustedCas: []*swapi.Certificate{
                {
                    Source: &swapi.Certificate_Static{
                        Static: &swapi.ASN1DataSource{
                            Data: &swapi.DataSource{
                                Specifier: &swapi.DataSource_Filename{
                                    Filename: *cert,
                                },
                            },
                            Format: swapi.ASN1EncodingFormat_ENCODING_FORMAT_PEM,
                        },
                    },
                },
            },
        },
    }

    config := &swapi.Configuration{
        Impl: swapi.Implementation_IMPL_OPENSSL1_1_1_OQS,
        Opts: &swapi.Configuration_Client{
            Client: &swapi.ClientOptions{
                Opts: &swapi.ClientOptions_Tls{
                    Tls: &swapi.TLSClientOptions{
                        CommonOptions: &swapi.TLSOptions{
                            PeerVerifier: &swapi.TLSOptions_EmptyVerifier{
                                EmptyVerifier: &swapi.EmptyVerifier{},
                            },
                        },
                    },
                },
            },
        },
    }

    tls13config := &swapi.TLSv13Config{
        Compliance: &swapi.Compliance{
            ClassicalChoice: swapi.ClassicalAlgoChoice_CLASSICAL_ALGORITHMS_ALLOW,
        },
        Ke: []string{
            "kyber768",
            "p256_kyber512",
            "prime256v1",
        },
    }

    tls12config := &swapi.TLSv12Config{
        Ciphersuite: []string{
            "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
            "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
            "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
            "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
            "TLS_RSA_WITH_AES_256_GCM_SHA384",
            "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
            "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
            "TLS_RSA_WITH_AES_128_GCM_SHA256",
        },
    }

    switch *tls_version {
    case "tls12":
        config.GetClient().GetTls().CommonOptions.Tls12 = tls12config
    case "tls13":
        config.GetClient().GetTls().CommonOptions.Tls13 = tls13config
    default:
        log.Fatalln("TLS version is not supported")
    }

    if len(*cert) == 0 {
        config.GetClient().GetTls().CommonOptions.PeerVerifier = EmptyVerifier
    } else {
        config.GetClient().GetTls().CommonOptions.PeerVerifier = x509_verifier
    }

    return config
}

func main() {
    host := flag.String("host", "", "Host to connect to")
    port := flag.Int64("port", 0, "TCP port to connect to")
    tls_version := flag.String("tls_version", "", "TLS version: --tls_version tls13 or tls12")
    cert := flag.String("server_cert", "", "Server certificates")

    flag.Parse()

    if *port == 0 || *host == "" {
        log.Fatalln("Please provide a client host and port!")
    }

    if *tls_version == "" {
        log.Fatalln("Please provide a TLS protocol version, e.g --tls_version tls13 or tls12")
    }

    swio, ioerr := swio.IOTCPClient(*host, uint16(*port))
    if ioerr != nil {
        log.Fatalln("Error connecting to destination:", ioerr)
        return
    }

    sw_lib_ctx := sw.NewSandwich()

    ctx, err := swtunnel.NewTunnelContext(sw_lib_ctx, createClientConfiguration(cert, tls_version))
    if err != nil {
        log.Fatalln("Error create tunnel context:", err)
        return
    }

    tunnel, err := swtunnel.NewTunnelWithReadWriter(ctx, swio, &swapi.TunnelConfiguration{
        Verifier: &swapi.TunnelVerifier{
            Verifier: &swapi.TunnelVerifier_EmptyVerifier{
                EmptyVerifier: &swapi.EmptyVerifier{},
            },
        },
    })
    if err != nil {
        log.Fatalln(err)
    }

    err = tunnel.Handshake()
    if err != nil {
        log.Fatalln(err)
    }

    errChannel := make(chan error)

    // Copy data from stdin to destination using io.Copy
    go func() {
        _, err := io.Copy(tunnel, os.Stdin)
        errChannel <- err
    }()

    // Copy data from source to stdout using io.Copy
    go func() {
        _, err := io.Copy(os.Stdout, tunnel)
        errChannel <- err
    }()

    // Handle Ctrl+C signal to gracefully close the connection
    interruptChannel := make(chan os.Signal, 1)
    signal.Notify(interruptChannel, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-interruptChannel
        os.Exit(0)
    }()

    // Wait for the data copying goroutine to finish
    err = <-errChannel
    if err != nil && err != io.EOF {
        fmt.Println("Error:", err)
    }
}

Python implementation

The Python version can be run with:

$ bazelisk run //examples/python/tls_client:tls_client -- --host 127.0.0.1 -p 4444

Its source code is the following (examples/python/tls_client/main.py):

# Copyright (c) SandboxAQ. All rights reserved.
# SPDX-License-Identifier: AGPL-3.0-only

import selectors
import socket
import sys
from multiprocessing.connection import Connection
from typing import BinaryIO

import pysandwich.proto.api.v1.compliance_pb2 as Compliance
import pysandwich.proto.api.v1.configuration_pb2 as SandwichTunnelProto
import pysandwich.proto.api.v1.verifiers_pb2 as SandwichVerifiers
import pysandwich.errors as SandwichErrors
import pysandwich.io_helpers as SandwichIOHelpers
import pysandwich.tunnel as SandwichTunnel
from pysandwich.proto.api.v1.tunnel_pb2 import TunnelConfiguration
from pysandwich.sandwich import Sandwich


def create_client_conf(tls: str) -> SandwichTunnelProto:
    """Create Client configuration."""
    conf = SandwichTunnelProto.Configuration()
    conf.impl = SandwichTunnelProto.IMPL_BORINGSSL_OQS

    match tls:
        case "tls13":
            # Sets TLS 1.3 Compliance, Key Establishment (KE), and Ciphersuite.
            tls13 = conf.client.tls.common_options.tls13
            tls13.ke.append("X25519")
            tls13.compliance.classical_choice = Compliance.CLASSICAL_ALGORITHMS_ALLOW
            tls13.ciphersuite.extend(["TLS_CHACHA20_POLY1305_SHA256"])
        case "tls12":
            tls12 = conf.client.tls.common_options.tls12
            ciphers = [
                "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
                "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
                "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
                "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
                "TLS_RSA_WITH_AES_256_GCM_SHA384",
                "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
                "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
                "TLS_RSA_WITH_AES_128_GCM_SHA256",
            ]
            tls12.ciphersuite.extend(ciphers)
        case _:
            raise NotImplementedError("TLS version is not supported")

    conf.client.tls.common_options.empty_verifier.CopyFrom(
        SandwichVerifiers.EmptyVerifier()
    )

    return conf


def is_localhost(hostname: str):
    try:
        # Get the IP address for the given hostname
        ip_address = socket.gethostbyname(hostname)

        # Check if the IP address is a localhost IP address
        return ip_address in ("127.0.0.1", "::1")
    except socket.gaierror:
        # If the hostname cannot be resolved, it's not localhost
        return False


def create_client_tun_conf(hostname: str) -> TunnelConfiguration:
    tun_conf = TunnelConfiguration()
    if not is_localhost(hostname):
        tun_conf.server_name_indication = hostname
    tun_conf.verifier.empty_verifier.CopyFrom(SandwichVerifiers.EmptyVerifier())

    return tun_conf


def run_client(
    host: str,
    port: int,
    input_r: Connection | BinaryIO,
    output_w: Connection | BinaryIO,
    client_ctx_conf: SandwichTunnel.Context,
):
    """Connect to server with a Context"""
    while True:
        try:
            client_io = socket.create_connection((host, port))
            break
        except ConnectionRefusedError:
            pass
    swio = SandwichIOHelpers.io_socket_wrap(client_io)
    client_tun_conf = create_client_tun_conf(host)

    client = SandwichTunnel.Tunnel(
        client_ctx_conf,
        swio,
        client_tun_conf,
    )
    assert client is not None

    client.handshake()
    state = client.state()
    assert (
        state == client.State.STATE_HANDSHAKE_DONE
    ), f"Expected state HANDSHAKE_DONE, got {state}"

    sel = selectors.DefaultSelector()
    sel.register(input_r, selectors.EVENT_READ, data=None)
    sel.register(client_io, selectors.EVENT_READ, data=None)

    client_io.setblocking(False)

    while True:
        events = sel.select(timeout=None)

        for key, _ in events:
            if key.fileobj is client_io:
                try:
                    data = client.read(1024)
                except SandwichErrors.RecordPlaneWantReadException:
                    continue

                if not data:
                    sel.unregister(client_io)
                else:
                    if isinstance(output_w, Connection):
                        output_w.send_bytes(data)
                    else:
                        output_w.write(b">")
                        output_w.write(data)
                        output_w.flush()

            elif key.fileobj is input_r:
                if isinstance(input_r, Connection):
                    data = input_r.recv_bytes(16)
                else:
                    data = input_r.readline()

                if not data:
                    sel.unregister(input_r)
                else:
                    client_io.setblocking(True)
                    client.write(data)
                    client_io.setblocking(False)

    client.close()


def main(
    hostname: str,
    port: int,
    tls: str,
    input_r: Connection | BinaryIO,
    output_w: Connection | BinaryIO,
):
    sw = Sandwich()
    client_conf = create_client_conf(tls)
    client_ctx = SandwichTunnel.Context.from_config(sw, client_conf)

    run_client(hostname, port, input_r, output_w, client_ctx)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(prog="TLS client using Sandwich")
    parser.add_argument(
        "-p",
        "--port",
        type=int,
        help="Port to connect to (defaults to 443)",
        default=443,
        required=True,
    )
    parser.add_argument("--host", type=str, help="Host to connect to", required=True)
    parser.add_argument(
        "--tls_version",
        type=str,
        help="TLS version: --tls_version tls13 or tls12",
        required=True,
    )
    args = parser.parse_args()

    main(args.host, args.port, args.tls_version, sys.stdin.buffer, sys.stdout.buffer)

Rust implementation

The Rust version can be run with:

$ bazelisk run //examples/rust/tls_client:tls_client -- --hostname 127.0.0.1 --port 4444

It only works for Linux so far, as it's using the epoll API.

Its source code is the following (examples/rust/tls_client/main.rs):