Rocket + sodiumoxide = ♥

End-to-end encryption in a REST API

Disclaimer: I cannot guarantee the safety of the code in this post. Consider this as a proof of concept.

Disclaimer two: as pointed out on reddit, hard-coding certificates and having properly-configured TLS will do you much more good than this demo.

Let’s assume you have a reasonable degree of trust in the TLS setup for your Rocket application. Nevertheless, whether out of paranoia or auditors, or as a just-in-case precaution, you want to go a step further and use end-to-end encryption for data transfers.

A safe bet for high-performance, hard-to-mess-up encryption in Rust is sodiumoxide. A wrapper for libsodium, sodiumoxide uses the idea of putting data into ‘boxes’, and using the key(s) and a nonce to open and close these boxes. In this case, we’ll be using its asymmetric component, the curve25519xsalsa20poly1305 construction. Given Alice’s public key and Bob’s private key, or vice versa, it computes a shared secret, and encrypts data with an authenticated stream cipher.

There are numerous language bindings to libsodium, as well as to NaCl and tweetnacl, so clients for our service should have no issue there. Likewise, for simplicity and compatibility, let’s use JSON strings with base64-encoded fields. The client will need to send the public key and nonce it used along with the ciphertext. We can represent this as a simple struct, deriving (de)serialize via serde.

#[derive(Serialize, Deserialize, Debug)]
struct CryptoBox {
    pubkey: String,
    nonce: String,
    ciphertext: String,
}

How to handle keys

This can get tricky. By that I mean it will be tricky for non-trivial setups.

The simplest way is probably to save a keypair to files and load them in. Then, you simply need to bundle your public key with your application(s).

// for the constants
use sodiumoxide::crypto::box_::*;

lazy_static! {
    static ref PUBLIC_KEY: [u8; PUBLICKEYBYTES] = include_bytes!("../public.key");
    static ref SECRET_KEY: [u8; SECRETKEYBYTES] = include_bytes!("../secret.key");
}

Ah, but what if you want to change keys? You’d need to update your application. Perhaps key rotation? You could instead bundle your public signing key with your application(s), and have a GET to retrieve the currently-used public key for encryption, signed with the master key.

But what if there’s going to be a number of users for the service, or you have some niche situation in mind? Another idea, again assuming that we have a reasonable degree of trust in our TLS, is to just have the aforementioned GET for a current public key, and not worry about long-term keys. We could even dynamically generate keys every launch if we wanted.


use sodiumoxide::crypto::box_;
use sodiumoxide::crypto::box_::*;
use data_encoding::base64;


lazy_static! {
    static ref KEY_PAIR: (PublicKey, SecretKey) = box_::gen_keypair();
}



#[get("/pubkey")]
fn pubkey() -> String {
    base64::encode((*KEY_PAIR).0.as_ref())
}

Decryption

Below is my messy implementation of decrypt() for CryptoBox. Again, we have the client’s public key, the message nonce, and ciphertext as base64-encoded strings. We decode these to Vec<u8>, but sodiumoxide requires them to be fixed-length so here I’m just copying the values into arrays. Then, we ‘open’ the ‘box’ with this information and our secret key (here: the second part of our static ref KEY_PAIR tuple).

impl CryptoBox {
    fn decrypt(&self) -> Vec<u8> {
        // sodiumoxide needs fixed-length arrays,
        // not slices
        let pubkey_decoded = base64::decode(self.pubkey.as_bytes()).unwrap();
        assert!(pubkey_decoded.len() == PUBLICKEYBYTES);

        let mut pubkey_bytes = [0u8; PUBLICKEYBYTES];
        for i in 0..PUBLICKEYBYTES {
            pubkey_bytes[i] = pubkey_decoded[i];
        }


        let nonce_decoded = base64::decode(self.nonce.as_bytes()).unwrap();
        assert!(nonce_decoded.len() == NONCEBYTES);

        let mut nonce_bytes = [0u8; NONCEBYTES];
        for i in 0..NONCEBYTES {
            nonce_bytes[i] = nonce_decoded[i];
        }


        let pk = PublicKey(pubkey_bytes);
        let nonce = Nonce(nonce_bytes);
        let ciphertext = base64::decode(self.ciphertext.as_bytes()).unwrap();

        box_::open(&ciphertext, &nonce, &pk, &(*KEY_PAIR).1).unwrap()
    }
}

Passing data

This is the really easy part, thanks to Rocket. To define a POST route that takes a JSON-serialized CryptoBox, we just, well, annotate a function as a POST route and have it accept JSON<CryptoBox>. Quite simple, really. As an example, if we send an encrypted string to this route, it’ll return the plaintext.

#[post("/test", format = "application/json", data = "<cryptobox>")]
fn test(cryptobox: JSON<CryptoBox>) -> String {
    let plaintext = cryptobox.0.decrypt();
    String::from_utf8(plaintext).unwrap()
}

Now, as for sending data, a client will:

I found reqwest nice to use for making a Rust client. It has a very high-level API that lets you just use HashMaps as JSON in your request.

Below is the code I used to test the above route on a local Rocket instance.

extern crate reqwest;
use std::io::Read;

extern crate data_encoding;
use data_encoding::base64;

extern crate sodiumoxide;
use sodiumoxide::crypto::box_;
use sodiumoxide::crypto::box_::*;

use std::collections::HashMap;



fn get_server_key() -> PublicKey {
    let mut response = reqwest::get("http://localhost:8000/pubkey")
        .expect("Failed to GET server key");
    println!("{}", response.status());

    let mut buf = Vec::new();
    response.read_to_end(&mut buf)
        .expect("Failed to read server public key response");

    let pubkey_decoded = base64::decode(&buf).unwrap();
    assert!(pubkey_decoded.len() == PUBLICKEYBYTES);

    let mut pubkey_bytes = [0u8; PUBLICKEYBYTES];
    for i in 0..PUBLICKEYBYTES {
        pubkey_bytes[i] = pubkey_decoded[i];
    }

    PublicKey(pubkey_bytes)
}




fn main() {
    sodiumoxide::init();

    let (local_pk, local_sk) = box_::gen_keypair();
    let nonce = box_::gen_nonce();
    let srv_pk = get_server_key();

    let plaintext = b"Hello world!";
    let ciphertext = box_::seal(plaintext, &nonce, &srv_pk, &local_sk);

    let mut json = HashMap::new();
    json.insert("pubkey", base64::encode(local_pk.as_ref()));
    json.insert("nonce", base64::encode(nonce.as_ref()));
    json.insert("ciphertext", base64::encode(&ciphertext));

    let client = reqwest::Client::new().unwrap();
    let mut response = client.post("http://localhost:8000/test")
        .json(&json)
        .send()
        .expect("Test POST failed");

    let mut buf = String::new();
    response.read_to_string(&mut buf).expect("Failed to read POST response");

    println!("{}", buf)
}

Author

Shawn Kinkade — February, 2017