twoway: Encrypted Request-Response Messaging in Go

twoway: Encrypted Request-Response Messaging in Go
github.com/confidentsecurity/twoway

Confident Security runs a provably private AI inference engine called CONFSEC. And we've done a lot of engineering to make sure nobody can ever see a submitted prompt. For example, while we can see that requests come in, we have no idea where they're from, what client sent them, and, most importantly, cannot know what's in them.

This might sound like an issue, but it's by design. It allows us to guarantee to our clients that no one is snooping on their requests, not even us.

One way to make such a guarantee is to put it in a legal contract and use a hardened web server and connection-level encryption. While this can work, it will be up to humans to enforce the rules.

On CONFSEC, the enforcement of these rules is baked into the request-response cycle. It's enforced via cryptography, software, and hardware in a way that is similar to Apple's PCC.

The core of this enforcement is as follows: When a client makes a request, it will encrypt the request in such a way that only a server running the expected software and hardware will be able to decrypt it.

Additionally, to make side-channel attacks on client extremely impractical, we anonymize requests by routing them through an Oblivious HTTP (OHTTP) relay. This means that our systems only know about the relay's IP address.

Both OHTTP and our client-to-server communication involve encrypted request-response messages. Which brings us to our recently open-sourced project: confsec/twoway.

High-Level Overview of twoway

confsec/twoway is our easy-to-use Go package that provides encrypted request-response message flows.

Specifically, twoway allows a sender to send a request message to one (or more) receivers and for those receiver(s) to send back a response message. twoway then guarantees the integrity of this roundtrip by cryptographically tying the response message to the request message. More about that later.

Messages consist of raw bytes or of chunks of raw bytes to facilitate streaming. Note that this means twoway makes no commitment or coupling to the medium with which you choose to exchange bytes: TCP, HTTP, semaphore, etc.

Data is moved around using the common io.Reader interface. Making it a breeze to work with.

twoway builds on top of Hybrid Public Key Encryption (HPKE) specifically; it uses the great cloudflare/circl/hpke package. It supports all HPKE algorithms provided by circl out of the box.

Brief Intro to HPKE

HPKE involves two parties: a sender and a receiver. The sender can send encrypted
messages to the receiver if it has the receiver's public key.

The "Hybrid" in HPKE means it uses asymmetric encryption to safely establish a
shared secret, then uses that for symmetric encryption. This is useful because
symmetric algorithms are much more performant for large amounts of data.

Specifically, HPKE combines three algorithms into a cipher suite:

  • KEM (Key Encapsulation Mechanism): Creates a shared secret between sender and
    receiver using the receiver's public key.
  • KDF (Key Derivation Function): Derives cryptographic keys from the shared secret.
  • AEAD (Authenticated Encryption with Associated Data): Encrypts/decrypts the actual
    data and guarantees integrity of metadata.

HPKE models sender/receiver state using so-called contexts. When sending/receiving messages, this looks as follows.

When a sender is ready to send a message, it will take the following steps:

  1. Sender creates a sender context for the receiver's public key. This will also result in the encapsulated key for that context.
  2. Sender seals (encrypts) one or more messages using the sender context.
  3. Sender provides the encapsulated key and the sealed messages to the receiver.

On the receiver side:

  1. For each sender, the receiver creates a receiver context for its private key and the sender's encapsulated key.
  2. The receiver uses this receiver context to open (decrypt) the sender's sealed messages (which must be opened in the order they were encrypted in).

Messages in Two Directions: OHTTP-Compatible Flow

HPKE models a flow in one direction: sender-to-receiver.

twoway models a flow in two directions: sender-to-receiver-to-sender if you will.

To achieve this, twoway uses HPKE. Specifically, it uses HPKE as described in RFC 9458 (OHTTP) Section 4 and Section 6 of the draft RFC for chunked OHTTP.

The main difference from the flow we saw in the HPKE section is that each request-response cycle uses fresh HPKE contexts.

For the response sealing/opening, both the sender and receiver need to agree on a key. This is done by calling the HPKE Export function, which returns the same pseudorandom key material in both contexts. This key material is then used to derive a new symmetric key.

A separate AEAD instance is then used to open/seal the response message (rather than using the HPKE context's built-in AEAD).

For each request-response cycle, the flow looks as follows:

  1. Sender creates a new HPKE sender context and encapsulated key for the receiver's public key.
  2. Sender seals a request message using the context.
  3. Sender provides the encapsulated key and sealed request message to the receiver.
  4. Receiver creates a new HPKE receiver context for the encapsulated key.
  5. Receiver opens the sealed request message using the HPKE receiver context.
  6. Receiver calls HPKE Export on the receiver context and derives the response encryption key.
  7. Receiver seals the response message using a fresh AEAD instance and the response encryption key.
  8. Receiver provides the sealed response message to the sender.
  9. Sender calls HPKE Export on the sender context and derives the response encryption key.
  10. Sender opens the sealed response message using a fresh AEAD instance and the response encryption key.

Quite a few steps!

Go Example

Luckily, most of them are behind the scenes when using twoway.

// let's begin on the sender side.

// We need a few inputs:
// - suite: The HPKE suite to use.
// - receiverPublicKey: The public key of the receiver.
// - keyID: A single byte identifying the used key as required by the OHTTP RFC.
// - rand.Reader: Used by HPKE to generate the encapsulated key.

// create a new request sender to seal one or more request messages.
sender, err := twoway.NewRequestSender(suite, keyID, receiverPublicKey, rand.Reader)
// skipping error handling for brevity.

// create a request sealer that seals a single request message.
reqSealer, err := sender.NewRequestSealer(
    strings.NewReader("Hello World!"),
    []byte("message/plaintext-req"),
)

// reqSealer is an io.Reader that will return the sealed (encrypted) message bytes.

// We could, for example, read all the bytes from it and send them to the receiver.
sealedReqBytes, err := io.ReadAll(reqSealer)

// Let's switch to the receiver side. We also need some inputs.
// - suite: The HPKE suite to use.
// - receiverPrivateKey: The private key of the receiver.
// - keyID: A single byte identifying the used key as required by the OHTTP RFC.
// - rand.Reader: Used to generate a nonce for response messages.

// create a new request receiver that can receive many messages.
receiver, err := twoway.NewRequestReceiver(suite, keyID, receiverPrivateKey, rand.Reader)

// create an opener for the request message we sealed earlier.
// Note the matching media type. We'll get back to that later.
reqOpener, err := reqReceiver.NewRequestOpener(
    bytes.NewReader(sealedReqBytes),
    []byte("message/plaintext-req"),
)

// the reqOpener also is an io.Reader. We can read the plaintext request from it:
plaintextReq, err := io.ReadAll(reqOpener)

// prints: Hello World!
fmt.Printf("%s\n", plaintextReq)

// the request opener is also used to create a response sealer.
respSealer, err := reqOpener.NewResponseSealer(
    strings.NewReader("Goodbye for now!"),
    []byte("message/plaintext-res"),
)

// again, the sealer is an io.Reader
sealedRespBytes, err := io.ReadAll(respSealer)

// Now, let's switch back to the sender side to handle this sealed response.
respOpener, err := reqSealer.NewResponseOpener(
    bytes.NewReader(sealedRespBytes),
    []byte("message/plaintext-res"),
)

// and finally, this opener also is an io.Reader.
plaintextRes, err := io.ReadAll(reqOpener)

// prints: Goodbye for now!
fmt.Printf("%s\n", plaintextRes)

That's it! A fully encrypted request-response cycle. We'll see a few more options applied later.

A Note on OHTTP Support

twoway implements the (encapsulated) message flow of OHTTP. It doesn't implement the HTTP-layer concerns like preventing replay attacks. If you want to use twoway as part of OHTTP, be sure to check out our upcoming OHTTP package instead :)

Authenticated Data and Media Types

One thing that I have not mentioned so far is that beyond the messages, keys, and contexts, there is a bit of metadata in play.

This metadata is not encrypted but is authenticated by the AEAD. The same bytes must be passed between the respective seal/open operations, or they will fail.

Some of this metadata is passed as part of the output from sender to receiver (per the OHTTP RFCs).

  • Key ID: A single byte identifying the receiver key used by the sending context.
  • KEM ID, KDF ID, and AEAD ID: Describing exactly what HPKE algorithms were used to construct the sender context.

But, more importantly, there is one field that's not passed around but still needs to match: the media type of each message. This is why twoway requires each sealer/opener to have a corresponding media type describing the plaintext message payload.

For unchunked OHTTP requests and responses, these would be message/bhttp request and message/bhttp response for example.

The idea here is that tampering with the media type will also make the AEAD open operation fail.

It's also a good idea to use different media for chunked and unchunked messages, as this will cause explicit failures when they are accidentally switched up.

Chunked Messages

twoway supports chunked messages to enable incremental processing of messages as described by the OHTTP draft RFC.

Note: Before using chunked messages in your system, be sure to go over the security considerations in Section 7 of the draft RFC, as chunked messages introduce different risks compared to unchunked messages when it comes to truncation, timing, and limits.

By default twoway will adhere to the chunk limits specified by the OHTTP Draft RFC.

This means that senders should limit the length of their chunks to 16384 bytes of payload data and that receivers are guaranteed to support receiving chunks of this length. These limits can be overwritten in twoway if your use case is different.

So how are chunk boundaries determined?

As you will have seen in the earlier examples, when creating a request or response sealer in twoway, you pass in an io.Reader. When chunking is enabled, the sealer will turn reads from this reader into chunks.

Each read from the underlying reader will result in a new chunk. The sealer will attempt to read up to a configurable maximum chunk length. This defaults to the earlier mentioned 16384 bytes.

This does mean that if you want to enforce uniform chunk lengths for all your chunks, you will have to make sure that all reads return the same length of data.

Twoway openers have no such concerns, as the length of a sealed chunk is included in the chunked message format.

The example below adapts the earlier request sealer and opener to now work with chunked messages instead. The same options can be applied to response sealers and openeners.

// create a chunked request sealer.
reqSealer, err := sender.NewRequestSealer(
    strings.NewReader("Hello World!"),
    []byte("message/plaintext-req-chunked"),
    // options to enable chunking and set a max chunk length of 128 bytes.
    twoway.EnableChunked(),
    twoway.WithMaxChunkPlaintextLen(128),
)

// then later on the receiver side:
reqOpener, err := reqReceiver.NewRequestOpener(
    bytes.NewReader(sealedReqBytes),
    []byte("message/plaintext-req-chunked"),
    // options to enable chunking and set a max chunk length of 128 bytes.
    twoway.EnableChunking(),
    twoway.WithMaxChunkPlaintextLen(128), // breaks compatibility with OHTTP RFC.
)

Multiple Receivers

In the OHTTP-compliant flow we saw earlier, there is exactly one sender and one receiver.

Now what about cases where a sender needs to send to multiple receivers?

For example, at Confident Security, our servers (receivers) each have their own public key and cryptography module, and a client (sender) doesn't know in advance which server has the capacity to handle a request.

twoway supports sending one request message to multiple receivers. It supports this by encrypting the request message once using a random symmetric data encryption key (DEK) and encrypting this key for each receiver using HPKE.

Step by step, this process works as follows:

  1. Sender generates a random symmetric data encryption key (DEK).
  2. Sender sends the request message using the DEK and a fresh AEAD instance.
  3. Then, for each potential receiver:
      1. The sender creates a new HPKE sender context and encapsulated key for the receiver's public key.
      2. The sender seals the DEK using the HPKE sender context and returns the sealed DEK for that receiver.
  4. The sender provides all the encapsulated keys, sealed DEK's and the sealed request message to the receivers.
  5. Then each receiver:
      1. Identifies their encapsulated key and sealed DEK.
      2. Receiver creates a new HPKE receiver context for the encapsulated key.
      3. Receiver unseals the DEK.
      4. Receiver opens the request message using the DEK and a fresh AEAD instance.
      5. Receiver calls HPKE Export and derives the response encryption key.
      6. Receiver seals the response message using a fresh AEAD instance and the response encryption key.
      7. Receiver provides the sealed response message to the sender.
  6. Then the sender, for each received response message:
      1. Identifies the relevant HPKE sender context for the response message.
      2. Sender calls HPKE Export on the sender context and derives the response encryption key.
      3. Sender opens the sealed response message using a fresh AEAD instance and the response encryption key.

In Go, this flow looks very similar to our earlier examples, though. The main difference from the earlier examples is that the sender needs to be created using twoway.NewMultiRequestSender instead of twoway.NewRequestSender.

sender, err := twoway.NewMultiRequestSender(suite, rand.Reader)

// create a request sealer that seals a single request message.
reqSealer, err := sender.NewRequestSealer(
    strings.NewReader("Hello World!"),
    []byte("message/plaintext-req"),
)

// while this sealer implements io.Reader like before, it can also
// be used to encapsulate keys for one or more receivers.
encapKey, respOpenerFunc, err := reqSealer.EncapsulateKey(keyID, receiverPublicKey)

// The rest of the flow is similar to before, with some differences:
// - Receiver needs to be created via NewMultiRequestReceiver instead of NewRequestReceveiver.
// - On the sender side, the response opener needs to be created by calling respOpenerFunc.

Pluggable HPKE Suite

A more advanced topic, but while twoway expects the API defined by cloudflare/hpke, it doesn't depend on the hpke.Suite type directly. This means that you can provide your own implementation for (parts of) the suite. For example, you could implement a suite that calls out to a cryptographic hardware module for some of the HPKE operations.

Conclusion

We hope this post gave you insight in twoway's features, but also how HPKE enables the construction of the request-response flow. If you're working on a Go project that has these kinds of flows, be sure to check out the repository here. We've been using twoway internally at Confident Security for a while and hope it will be useful in your projects as well.