So here’s the thing, the double ChaCha20 thing was stressful and it requires me to store an additional [u8; 12] for every recipient, on every message. I’m still not an expert in like all the speed and benchmark-y stuff, but I have to assume that the difference between the AES256’s block cipher, and ChaCha20 (or XChaCha20Poly1305 which is what I’m switching to, just cuz of the better-ness) are not that significant when we’re just encrypting the randomly generated ChaCha20 key which is 256 bits. So like, can’t be that much slower, and we store less data, and we get to say that ALL of our private keys are AES256 encrypted - because the private x25519 keys already are AES256 encrypted on-device, and now, also, the ChaCha20 one-time content keys (also private) are AES256 encrypted for each recipient.

I’m not explaining it again, so here’s just my actual code.

Cargo.toml

## Cargo.toml

[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"] }

lib.rs

// src/lib.rs

use aes::{
    cipher::{BlockDecrypt, BlockEncrypt},
    Aes256,
};

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

use ed25519_dalek::{Signer, Verifier};

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()
}

pub fn verify_signature(
    signature: [u8; 64],
    pub_signing_key: [u8; 32],
    content: Vec<u8>,
) -> Result<(), String> {
    let signature = ed25519_dalek::Signature::from_bytes(&signature);
    let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&pub_signing_key) {
        Ok(vk) => vk,
        Err(err) => {
            return Err(format!(
                "failed to generate verifying key from pub signing key: {}",
                err.to_string()
            ))
        }
    };

    match verifying_key.verify(&content, &signature) {
        Ok(_) => Ok(()),
        Err(err) => {
            return Err(format!(
                "failed to verify signature for content: {}",
                err.to_string()
            ))
        }
    }
}

// all "encrypted_content" is signed "externally" (on the outermost layer)
// before being sent to the backend system which will use that signature
// as verification of authenticity. once processed, all data indicating the
// signer, is stripped away from the outside and stored anonymously, with the
// "internal" content (content which is actually encrypted) is, itself signed
// and verifiable, once decrypted
pub fn encrypt_and_sign_content(
    signing_key: [u8; 32],
    content: Vec<u8>,
    receiver_pub_exchange_keys: Vec<[u8; 32]>,
    // returns (nonce, key_sets, encrypted_content)
) -> Result<([u8; 24], Vec<u8>, Vec<u8>), String> {
    let signing_key = ed25519_dalek::SigningKey::from_bytes(&signing_key);

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

    let nonce = chacha20poly1305::XChaCha20Poly1305::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 =
        match content_cipher.encrypt(&nonce, content_prefixed_with_signature.as_ref()) {
            Ok(ec) => ec,
            Err(err) => return Err(format!("failed to encrypt content: {}", err.to_string())),
        };

    // 256 bit [u8; 32] receiver_pub_exchange_key
    // 256 bit [u8; 32] sender_pub_exchange_key
    // 256 bit [u8; 32] encrypted_content_key
    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))
            .to_bytes();

        let encrypted_content_key = block_encrypt(shared_secret, private_content_key.to_vec());

        // pack in all the values...
        key_sets.extend(receiver_pub_exchange_key.iter());
        key_sets.extend(sender_pub_exchange_key.as_bytes());
        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 =
        encrypted_content_signature_external.clone();

    encrypted_content_prefixed_with_signature.extend(encrypted_content);

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

// accepts encrypted content which is not "externally" signed
// as this is not how it will ever be received by the client,
// from the backend system, for the sake of anonymity.
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: [u8; 32],

    nonce: [u8; 24],
    encrypted_content: Vec<u8>,
) -> Result<Vec<u8>, String> {
    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)
        .to_bytes();

    let decrypted_content_key = block_decrypt(shared_secret, encrypted_content_key.to_vec());

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

    let mut content = match content_cipher.decrypt(
        &chacha20poly1305::XNonce::from(nonce),
        encrypted_content.as_ref(),
    ) {
        Ok(c) => c,
        Err(err) => return Err(format!("failed to decrypt content: {}", err.to_string())),
    };

    let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&sender_pub_verification_key)
    {
        Ok(vk) => vk,
        Err(err) => {
            return Err(format!(
                "failed to generate verifying key from sender's pub signature: {}",
                err.to_string()
            ))
        }
    };

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

    // gets the signature off the first strip of bytes
    if content.len() >= 64 {
        fixed_signature_bytes.copy_from_slice(&content[..64]);
        content.drain(..64);
    } else {
        return Err("the decrypted content does not have a signature prefix.".to_string());
    }

    let signature = ed25519_dalek::Signature::from_bytes(&fixed_signature_bytes);

    if verifying_key.verify(&content, &signature).is_ok() {
        Ok(content)
    } else {
        Err("failed to verify signature on internal content.".to_string())
    }
}

Conclusion

Maybe it’s stupid to care this much and maybe people think this is all way too over the top for any real system anyway, but this is still just an experiment. I hope to find something that is close enough to “universal” to be useful for most applications, but I do also want to give us the ability to tweak it when the defaults are necessitating a rule-break.