Okay, so I’m not sure exactly how I’ll format this in the future, but I figure it’s useful to send some “companion code” around with the bullshit assertions I make to the world, about tech stuff, while under-the-influence.

So, yesterday was very system-y, today we’re gonna get down to the “in practice” portion of the actual encryption/decryption that will facilitate this miraculously robust and infinitely scalable e2e encrypted, content sharing system, which only lives in my head.

Anyway…

Here’s the implementation for the onion’d symmetrical, stream-cipher, ChaCha20 blended nicely with the asymmetrical, elliptic-curve-cryptography algo, x25519 which facilitates key exchange and lends itself to the public/private key stuff necessary for the e2e encrypted-ness of the system. And finally, signed using another ECC algo - this one oriented toward cryptographic signing - ed25519.

Encrypting and Signing

Before we dive into the nitty-gritty, it’s important for us to first specify the I/O for our cryptographic superset(?). In order to encrypt content for our set of users, we will need all the receiver_pub_exchange_keys (these are stored in the pod’s encrypted metadata). We will also need the raw content which will be encrypted, and finally, we need the ed25519 signing_key. Once we’ve encrypted the content, we need to grab out the values which will be stored and transmitted. These items are the “top level” nonce (this is the nonce which is paired with the encrypted_content at decryption time), the “list” (it’s actually) a single, continuous “byte array” or in Rust terms, a Vec<u8> (more on formatting in a sec), the actually encrypted content, and the encrypted content’s signature.

At the end, our function signature should look something like this:

pub fn encrypt_and_sign_content(
    // the sender's signing key, later used for verification
    // on the server, at write time, *and* by other users
    // verifying the source of the content
    signing_key: [u8; 32],

    // raw content to be encrypted
    content: Vec<u8>,

    // list of public exchange keys for all
    // recipients. this list is retrieved from
    // the target pod's "pub exchange key" registry
    receiver_pub_exchange_keys: Vec<[u8; 32]>,

) -> ([u8; 12], Vec<u8>, Vec<u8>) { // (nonce, key_sets, encrypted_content)
    // TODO: do stuff...
}

A word on formatting: there are multiple situations where we are choosing to interact with bytes and join/prefix byte arrays with things like signatures, or instead of serializing for each individual record’s properties in a struct, and it allows us to avoid additional storage overhead.

Now to dig into the “key sets”, which are potentially the spot where most people could get tripped up. The key_sets property, is a contiguous byte array, with each recipient’s key_set actually being its own contiguous, fixed length, array of bytes. The format of a given recipient’s key_set looks as follows:

// 256 [u8; 32] receiver_pub_exchange_key -> for indexing into the indexedDB table that has the AES + passcode encrypted private key
// 256 [u8; 32] sender_pub_exchange_key -> for pairing with the AES + passcode encrypted key to create a shared secret to decrypt the once content key
// 96 [u8; 12] encrypted_content_key_nonce -> for use with the encrypted content key
// 256 [u8; 32] encrypted_content_key -> decrypted by the shared secret and nonce

[...receiver_pub_exchange_key, ...sender_pub_exchange_key, ...encrypted_content_key_nonce, ...encrypted_content_key]

Given that each “list item” or key_set is of a fixed length, we can easily split and iterate through. And one nest deeper, we can do the same with the keys themselves, which are also of fixed length.

the key_sets value is a contiguous byte array of fixed-length key_sets, which themselves are composed of fixed-length keys

And then finally, the encrypted_content is just that - but with a little extra. The encrypted_content is actually the encrypted content, prefixed with the [u8; 64] “External” signature, which will be used by the server to validate the authenticity of the request.

“External” signing is when the encrypted content is prefixed with a signature. “Internal” signing is when the content itself is prefixed with a signature, prior to encryption

“External” signing is something that will be done in this algorithm, as it is needed for the server to verify the authenticity of the sender. However this signature is used only once, and “ripped” from the payload before writing to the database, as to better preserve the anonymity of the sender where it is stored. Recipients will be able to verify the authenticity of content via the “Internal” signature, which is itself included in the encrypted payload.

The Guts

Just so you have context, we’re gonna use a few external packages and my Cargo.toml looks like the this:

[package]
name = "crypto"
version = "0.1.0"
edition = "2021"

[dependencies]
rand_core = "0.6.4"
aes = "0.8.3"
chacha20poly1305 = "0.10.1"
x25519-dalek = { version =  "2.0.0-rc.3", features = ["static_secrets"] }
ed25519-dalek = { version = "2.0.0-rc.3", features = ["rand_core"] }

For this first chunk (the complex/blended/hybrid/whatever encryption algorithm), we’ll only actually depend on chacha20poly1305, x25519-dalek and ed25519-dalek. For the sake of keeping things consistent, I’ll use the aes crate to show how I’ll encrypt content locally, but it’s really simple and following the aes crate docs is really straightforward.

As described yesterday, our super fun function, first, creates a “once key” or the “one time use, private key for symmetrical encryption of the actual content”, which enables us to build the cipher which we’ll use to encrypt our content (once, for all users). With ChaCha20 we also need to generate a nonce which is a random number that is technically “safe” to share publicly and will be bolted onto the stored record later, after being used to encrypt the content.

This is pretty simple and looks like this:

// ChaCha20 private key creation for *one time use* with this message *only*
let private_content_key = chacha20poly1305::ChaCha20Poly1305::generate_key(&mut rand_core::OsRng);

// the cipher which can actually be used to encrypt the content
let content_cipher = chacha20poly1305::ChaCha20Poly1305::new(&private_content_key);

// a once generated random number that ChaCha20 needs for encryption and decryption time
let nonce = chacha20poly1305::ChaCha20Poly1305::generate_nonce(&mut rand_core::OsRng);

Okay, so now that we have our “once private key” (and all its associated friends) created, we can start to do some interesting things to the content.

We now have all the information we need to encrypt the raw content (remember, we only encrypt content once, then we “hide” the keys used to encrypt, with with the shared secrets generated via the x25519 pub/priv key pairing). But we also need to do our “internal” signing so that users who decrypt our payload, can verify its authenticity. So… the process for signing, and encrypting looks like this:

// `&signing_key` is a reference to the `signing_key: [u8; 32]` passed into the `encrypt_and_sign_content` fn
let signing_key = ed25519_dalek::SigningKey::from_bytes(&signing_key);

// `&content` same as above, in that it comes from the `content: Vec<u8>` param off the `encrypt_and_sign_content` fn
let content_signature_internal = signing_key.sign(&content).to_vec();

// the next 2 lines merge the `signature` and `content` - essentially [...signature, ...content]
let mut content_prefixed_with_signature: Vec<u8> = content_signature_internal.clone();
content_prefixed_with_signature.extend(content);

// encrypt the content with the `nonce`, `content` which has been prefixed with its `signature`
let encrypted_content = content_cipher.encrypt(
    &nonce, 
    content_prefixed_with_signature.as_ref()
).expect("failed to encrypt content.");

So at this point we have our content encryption done. We’re left with the encrypted content, and the private key and nonce which are needed to decrypt the encrypted content. Now we have to figure out how to store this one-time ChaCha20 private key in a way that makes it only accessible to the intended recipients of this message.

Well, we know that we need to use some kind of asymmetrical, private/public key encryption scheme which will allow us to “hide” this key on the record.

Asymmetrical, private/public key, encryption? Omg, looks like this is where the x25519 comes in. So… to cut to the chase… we’re gonna encrypt the one-time ChaCha20 private key with the “shared secret” that can only be generated from the pairing of another (but this time x25519) one-time private key, and the publicly registered, public key, of an intended recipient. And by storing the public key associated to this “one time” sender_private_key, despite its private counterpart - more or less - being “thrown away”, that sender_public_exchange_key can be pared with the private key which corresponds to the receiver_public_exchange_key that was used to generate the initial shared secret.

long story short: trust me that the x25519 public/private key exchange hell, does make sense and is secure, even if I’m struggling to explain how

The code for handling this one-time pub/priv x25519 which is used to generate the shared secret with the publicly registered recipient_public_exchange_key looks like this:

// the "one-time" keys generated for this particular message
let sender_priv_exchange_key = x25519_dalek::EphemeralSecret::random_from_rng(rand_core::OsRng);
let sender_pub_exchange_key = x25519_dalek::PublicKey::from(&sender_priv_exchange_key);

// the "shared secret" created by the pairing of `receiver_pub_exchange_key` and the brand new, one-time `sender_priv_exchange_key`
let shared_secret = sender_priv_exchange_key.diffie_hellman(&x25519_dalek::PublicKey::from(receiver_pub_exchange_key));

// creates the ChaCha20 cipher that is keyed with the `shared_secret` generated above
let shared_secret_cipher = chacha20poly1305::ChaCha20Poly1305::new(shared_secret.as_bytes().into());

// the `nonce` used to decrypt the `encrypted_content_key`
let encrypted_content_key_nonce = chacha20poly1305::ChaCha20Poly1305::generate_nonce(&mut rand_core::OsRng);

// encrypts the `private_content_key` generated for *content* encryption earlier
let encrypted_content_key = shared_secret_cipher.encrypt(
    &encrypted_content_key_nonce,
    private_content_key.as_ref()
).expect("failed to encrypt content");

Obviously we’re doing a couple things in order here (and keep in mind this is just for once recipient and we have a list of recipients):

  1. Creating some x25519 exchange keys which will only be valid for this message
  2. Generating a shared secret with the one-time private key
  3. Encrypting the ChaCha20 content key with the generated shared secret

So, what?

Well, now we have some more pieces (again just for this one recipient) which will allow us to construct their key_set and bolt that key_set onto the end of the key_sets byte array. To create the key_set based on the constraints we outlined above, and to stay consistent with formatting we do the following:

// initialize vector that will hold the bytes
let mut key_set: Vec<u8> = vec![];

// cram that shit in their, just make sure you do it in order
key_set.extend(receiver_pub_exchange_key.iter());
key_set.extend(sender_pub_exchange_key.as_bytes());
key_set.extend(encrypted_content_key_nonce);
key_set.extend(encrypted_content_key);

So, cool. Now we have a keyset for a recipient, and with these 4 items and the rest of the payload, we have the ability to decrypt with the information we have securely stored, locally.

As I mentioned, we need to create a list of key_sets which are just that once key_set smashed onto the end of another byte array called key_sets. Just to put the whole thing together, the for loop’d version looks something like:

// !! stored as a contiguous "byte array" or `Vec<u8>` which represents list of all the recipient-specific "key sets."
// !! each "key set" is set in the following format, and "butts up" against the next segment for the next recipient
// !! which is also of equal length (256 + 256 + 96 + 256).

// 256 receiver_pub_exchange_key -> for indexing into the indexedDB table that has the AES + passcode encrypted private key
// 256 sender_pub_exchange_key -> for pairing with the AES + passcode encrypted key to create a shared secret to decrypt the once content key
// 96 encrypted_content_key_nonce -> for use with the encrypted content key
// 256 encrypted_content_key -> decrypted by the shared secret and nonce
let mut key_sets: Vec<u8> = vec![];

for receiver_pub_exchange_key in receiver_pub_exchange_keys {
    let sender_priv_exchange_key = x25519_dalek::EphemeralSecret::random_from_rng(rand_core::OsRng);
    let sender_pub_exchange_key = x25519_dalek::PublicKey::from(&sender_priv_exchange_key);

    let shared_secret = sender_priv_exchange_key.diffie_hellman(&x25519_dalek::PublicKey::from(receiver_pub_exchange_key));

    let shared_secret_cipher = chacha20poly1305::ChaCha20Poly1305::new(shared_secret.as_bytes().into());
    let encrypted_content_key_nonce = chacha20poly1305::ChaCha20Poly1305::generate_nonce(&mut rand_core::OsRng);

    let encrypted_content_key = shared_secret_cipher.encrypt(
        &encrypted_content_key_nonce,
        private_content_key.as_ref()
    ).expect("failed to encrypt content");

    key_sets.extend(receiver_pub_exchange_key.iter());
    key_sets.extend(sender_pub_exchange_key.as_bytes());
    key_sets.extend(encrypted_content_key_nonce);
    key_sets.extend(encrypted_content_key);
}

putting it together for all recipients, in a loop

Sorry for the duplicate comments in the code block, I just know some people only like to look at code, and think reading is for nerds.

Finally, we need to add the “external” signature (for server verification), and below our key_sets building, for loop, we do the following to deliver that outcome:

let encrypted_content_signature_external = signing_key.sign(&encrypted_content).to_vec();
let mut encrypted_content_prefixed_with_signature = encrypted_content_signature_external.clone();

encrypted_content_prefixed_with_signature.extend(encrypted_content);

prefix the “external” signature to the encrypted content

And because chunks of code, broken up over an article, without including the whole contiguous chunk somewhere is the most annoying thing in the world, here’s the whole encrypt_and_sign_content thingy:

use chacha20poly1305::aead::{Aead, AeadCore, KeyInit as ChaChaKeyInit};
use ed25519_dalek::Signer;

pub fn encrypt_and_sign_content(
    signing_key: [u8; 32],
    content: Vec<u8>,
    receiver_pub_exchange_keys: Vec<[u8; 32]>,
) -> ([u8; 12], Vec<u8>, Vec<u8>) {
    let signing_key = ed25519_dalek::SigningKey::from_bytes(&signing_key);

    let private_content_key =
        chacha20poly1305::ChaCha20Poly1305::generate_key(&mut rand_core::OsRng);
    let content_cipher = chacha20poly1305::ChaCha20Poly1305::new(&private_content_key);

    let nonce = chacha20poly1305::ChaCha20Poly1305::generate_nonce(&mut rand_core::OsRng);

    let content_signature_internal = signing_key.sign(&content).to_vec();
    let mut content_prefixed_with_signature: Vec<u8> = content_signature_internal.clone();

    content_prefixed_with_signature.extend(content);

    let encrypted_content = content_cipher
        .encrypt(&nonce, content_prefixed_with_signature.as_ref())
        .expect("failed to encrypt content");

    let mut key_sets: Vec<u8> = vec![];

    for receiver_pub_exchange_key in receiver_pub_exchange_keys {
        let sender_priv_exchange_key =
            x25519_dalek::EphemeralSecret::random_from_rng(rand_core::OsRng);
        let sender_pub_exchange_key = x25519_dalek::PublicKey::from(&sender_priv_exchange_key);

        let shared_secret = sender_priv_exchange_key
            .diffie_hellman(&x25519_dalek::PublicKey::from(receiver_pub_exchange_key));

        let shared_secret_cipher =
            chacha20poly1305::ChaCha20Poly1305::new(shared_secret.as_bytes().into());
        let encrypted_content_key_nonce =
            chacha20poly1305::ChaCha20Poly1305::generate_nonce(&mut rand_core::OsRng);

        let encrypted_content_key = shared_secret_cipher
            .encrypt(&encrypted_content_key_nonce, private_content_key.as_ref())
            .expect("failed to encrypt content");

        key_sets.extend(receiver_pub_exchange_key.iter());
        key_sets.extend(sender_pub_exchange_key.as_bytes());
        key_sets.extend(encrypted_content_key_nonce);
        key_sets.extend(encrypted_content_key);
    }

    let encrypted_content_signature_external = signing_key.sign(&encrypted_content).to_vec();
    let mut encrypted_content_prefixed_with_signature: Vec<u8> =
        encrypted_content_signature_external.clone();

    encrypted_content_prefixed_with_signature.extend(encrypted_content);

    (
        nonce.into(),
        key_sets,
        encrypted_content_prefixed_with_signature,
    )
}

So yea, that’s some fun encrypting. Time to decrypt and verify.

Decrypting and Verifying

Whelp, now we know how to encrypt our content, and unless there’s a some gaping hole in my strategy, or understanding of cryptography, but that ball should be pretty resilient.

So, when we start with decryption and verification, we also have to start at I/O. As a “decrypting entity” we, fortunately, get to care about fewer parts of “the ball” than the encrypting entity does. For one, we only care about our key_set, so right away we can start listing out our parameters with that in mind.

In order to verify the signature (prefixed to and encrypted in the same “ball” as the content), we need the sender’s public verification key. In order to get this, we can pull it off the user’s account (or some local cache, or pod metadata). For this reason, the sender_pub_verification_key is the first parameter we accept in our decrypt_and_verify fn.

The next two pieces of data we need are the sender_pub_exchange_key (this is that one-time public x25519 key created, new, for each message), and the receiver_priv_exchange_key (this key is stored in a local database and is AES256 encrypted, but addressable by the public key it was created alongside). As we remember, the receiver_pub_exchange_key is stored in the key_set and allows us to retrieve the encrypted private key that is needed to generate the shared secret for that particular message.

Next, we need the encrypted_content_key_nonce (for decryption of the one-time generated, private ChaCha20 key), and the encrypted_content_key itself (which, again, is encrypted using the shared secret, generated by the pairing of the sender_public_exchange_key and the receiver_private_exchange_key).

And for the last parameters, we need the actual encrypted_content and the content_nonce (which is paired with the x25519, computed, shared secret to decrypt the encrypted_content).

For the output, we just need the Vec<u8> which is the “decrypted content.”

So, after all those nonsense words, we should start with a function signature that looks something like this:

pub fn decrypt_and_verify(
    // used to verify the payload *inside* the encrypted "ball"
    sender_pub_verification_key: [u8; 32],

    // used to create the shared secret, which is the
    // key for the `encrypted_content_key` and
    // pairs with the `encrypted_content_key_nonce`
    sender_pub_exchange_key: [u8; 32],
    receiver_priv_exchange_key: [u8; 32],

    // for use "unpacking" or decrypting the immediately
    // following `encrypted_content_key`. is paired
    // with the shared secret, derived from the
    // `sender_pub_exchange_key` and `receiver_priv_exchange_key`
    encrypted_content_key_nonce: [u8; 12],
    encrypted_content_key: [u8; 32],

    // used when decrypting the `encrypted_content` param below
    content_nonce: [u8; 12],
    encrypted_content: Vec<u8>,
) -> Vec<u8> {
    // TODO: fill in the guts...
}

Because we don’t have any special formatting for any of the values that are passed in (stupid key_sets… why make things more complicated?), we can jump right back into #TheGuts.

The Guts

The first thing to recognize is that all of these values are being passed around as either fixed length byte arrays, or Vec<u8>s. The reason for this is that all other systems should be able to reasonably think about just storing keys as bytes, and then inside our fun, little land of cryptography, we deal with all the crate-specific APIs.

That being said, the first thing we have to do is convert them all into structs we can actually work with, so you’ll see some of that conversion happen in various places throughout the example.

Okay, now that we have the function signature and our I/O defined, we can first convert our fixed-length, byte arrays into public and secret x25519 keys, and generate a ChaCha20 cipher, based on the shared secret, derived from the sender_pub_exchange_key and receiver_priv_exchange_key.

// "inflate" the fixed-length, byte array into a useful struct
let sender_pub_exchange_key = x25519_dalek::PublicKey::from(sender_pub_exchange_key);
let receiver_priv_exchange_key = x25519_dalek::StaticSecret::from(receiver_priv_exchange_key);

// derive the shared secret from the `x25519` keys
let shared_secret = receiver_priv_exchange_key.diffie_hellman(&sender_pub_exchange_key);

// create the cipher which is keyed with the `shared_secret`
let shared_secret_cipher = chacha20poly1305::ChaCha20Poly1305::new(shared_secret.as_bytes().into());

create the ChaCha20 cipher for decrypting the content key

Now that our cipher has been created based on the shared secret, we need to decrypt the “content key” (ya know, that ChaCha20 private key we used to encrypt the content at the very beginning). That will look like this:

// decrypt the "content key" which can be used to decrypt the highest level `encrypted_content`
let decrypted_content_key = match shared_secret_cipher.decrypt(
    &chacha20poly1305::Nonce::from(encrypted_content_key_nonce),
    encrypted_content_key.as_ref(),
).expect("failed to decrypt content key");

Once we’ve decrypted the content key, we need to now create the ChaCha20 cipher which will unlock THE CONTENT (hurray! finally to the shit that we actually need in order to display the useful content). And once we’ve created the cipher, we can extract the content! That looks like this:

// creates cipher, keyed by the decrypted *content* key
let content_cipher = chacha20poly1305::ChaCha20Poly1305::new(GenericArray::from_slice(&decrypted_content_key));

// convert the `content_nonce` to a `Nonce` and decrypt the `encrypted_content`
let mut content = content_cipher
    .decrypt(&chacha20poly1305::Nonce::from(content_nonce), encrypted_content.as_ref())
    .expect("failed to decrypt content");

Now that our content is decrypted, we’re actually not quite ready - we still need to verify. Our content (post decryption) is - if you remember - prefixed with the signature for the content. So in order to verify this signature, we first need to extract the leading bytes, and then we need to use ed25519 to actually verify the content against the signature…

// uses the `sender_pub_verification_key` from the arguments passed into the fn
let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&sender_pub_verification_key).expect("failed to create verifying key");

// creates a fixed array where the signature bytes can be dropped in right quick
let mut signature_fixed_bytes: [u8; 64] = [0; 64];

// gets the signature off the first strip of bytes and drains the bytes from the `content` Vec
if content.len() >= 64 {
    signature_fixed_bytes.copy_from_slice(&content[..64]);
    content.drain(..64);
}

// initializes the signature struct with the signature bytes
let signature = ed25519_dalek::Signature::from_bytes(&signature_fixed_bytes);

// verify the signature against the content
let verified = verifying_key.verify(&content, &signature).is_ok();

And now we have both decrypted AND verified the content of the message

To show the whole thing together:

use chacha20poly1305::aead::{generic_array::GenericArray, Aead, KeyInit as ChaChaKeyInit};
use ed25519_dalek::Verifier;

pub fn decrypt_and_verify(
    sender_pub_verification_key: [u8; 32],

    sender_pub_exchange_key: [u8; 32],
    receiver_priv_exchange_key: [u8; 32],

    encrypted_content_key_nonce: [u8; 12],
    encrypted_content_key: [u8; 32],

    content_nonce: [u8; 12],
    encrypted_content: Vec<u8>,
) -> Vec<u8> {
    let sender_pub_exchange_key = x25519_dalek::PublicKey::from(sender_pub_exchange_key);
    let receiver_priv_exchange_key = x25519_dalek::StaticSecret::from(receiver_priv_exchange_key);

    let shared_secret = receiver_priv_exchange_key.diffie_hellman(&sender_pub_exchange_key);
    let shared_secret_cipher =
        chacha20poly1305::ChaCha20Poly1305::new(shared_secret.as_bytes().into());

    let decrypted_content_key = shared_secret_cipher
        .decrypt(
            &chacha20poly1305::Nonce::from(encrypted_content_key_nonce),
            encrypted_content_key.as_ref(),
        )
        .expect("failed to decrypt content key");

    let content_cipher =
        chacha20poly1305::ChaCha20Poly1305::new(GenericArray::from_slice(&decrypted_content_key));

    let mut content = content_cipher
        .decrypt(
            &chacha20poly1305::Nonce::from(content_nonce),
            encrypted_content.as_ref(),
        )
        .expect("failed to decrypt content");

    let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&sender_pub_verification_key)
        .expect("failed to create verifying key");

    let mut signature_fixed_bytes: [u8; 64] = [0; 64];

    if content.len() >= 64 {
        signature_fixed_bytes.copy_from_slice(&content[..64]);
        content.drain(..64);
    }

    let signature = ed25519_dalek::Signature::from_bytes(&signature_fixed_bytes);
    let verified = verifying_key.verify(&content, &signature).is_ok();

    if verified {
        content
    } else {
        // handle your own damn error cases
        vec![]
    }
}

Okay, so encryption and decryption of the content which will traverse the “system” - or go through our backend and be stored in our database, is wrapped (assuming that no angry cryptographic experts tell me that my shit is junk).

Less impressive but maybe yet another opportunity to try and poorly explain the “local storage” model, we can now talk about the AES256 usage for on-device, key encryption.

Local Encryption

So, clearly (and today I guess I’ve switched to “So,” as my annoying paragraph entrypoint) we’ve got a lot of keys moving around, all the time, so that we can keep this data secure. Well, in order to maintain all of this security, we have to do a great job of managing our private - and to a lesser degree, public - keys safely. Because of the “batched forward secrecy” we do need a way to store the history of “one off” private keys that were used for a given “batch” of secrecy. They need to be addressable, but they also need to remain encrypted at all times when not in use. These “batch keys” or “private batch keys” are all stored in an encrypted format, using AES256 encryption. This is the encryption standard that is used to store credit card numbers for payment processors and such.

So, the way we can preserve their encryption, while also preserving their ability to be addressable, we store the k/v pair as the “plaintext” (it’s actually a Uint8Array) public x25519 key as the “k” in the “k/v”, and the AES256 encrypted x25519 private key (used for a “batch”) as the “v” in the “k/v.”

The reason why this works is that the public x25519 key that was used to generate the shared secret with the one-time sender_private_exchange_key, is stored in the key_set for the recipient of the message. This means, that without revealing who the recipient is on the stored object, we give the recipient both the ability to get their key_set off a given message, using the “batched” public key, which is only ever stored (though it’s in plaintext) on the recipient’s device AND the recipient can use that same public key to track down their “batched” receiver_private_exchange_key (in its AES256 encrypted form).

This allows the user to essentially have a floating, ever evolving, disjointed, yet robust, routing table which is mostly opaque to anyone who is able to observe even the most details of the running system.

So anyway, the AES256 encryption algo that we use to encrypt the “private batch keys” is really boring, and as follows:

The Code

use aes::{
    cipher::{generic_array::GenericArray, BlockEncrypt},
    Aes256,
};

pub fn block_encrypt(key: [u8; 32], content: Vec<u8>) -> Vec<u8> {
    let cipher = Aes256::new(GenericArray::from_slice(&key));

    let mut gen_arr_encrypted_content = GenericArray::clone_from_slice(&content);
    cipher.encrypt_block(&mut gen_arr_encrypted_content);

    gen_arr_encrypted_content.to_vec()
}

pub fn block_decrypt(key: [u8; 32], encrypted_content: Vec<u8>) -> Vec<u8> {
    let cipher = Aes256::new(GenericArray::from_slice(&key));

    let mut gen_arr_content = GenericArray::clone_from_slice(&encrypted_content);
    cipher.decrypt_block(&mut gen_arr_content);

    gen_arr_content.to_vec()
}

So anyway, that’s kinda what that looks like.

Conclusion

Again, not really a conclusion for this one, other than that I’ve left a fun “black hole” that is what the server and database implementations look like. I probably won’t share many of those details, but genuinely do want/need additional eyes on the overall encryption mechanisms, so this is “just throwin’ it out there.”