libsoliton 0.1.3

Core cryptographic library for the LO protocol — hybrid post-quantum key exchange, signatures, ratchet, and storage encryption
Documentation
//! XChaCha20-Poly1305 authenticated encryption (RFC 8439 + HChaCha20 extension).
//!
//! Constant-time by construction — ChaCha20 uses only ARX operations (add,
//! rotate, xor), so there are no secret-dependent table lookups or cache-timing
//! channels regardless of hardware. The `chacha20poly1305` crate provides the
//! XChaCha20 variant with 24-byte nonces (birthday bound ~2^96 vs ~2^48 for
//! 12-byte nonces).

use crate::error::{Error, Result};
use zeroize::Zeroizing;

use chacha20poly1305::aead::AeadInPlace;
use chacha20poly1305::{KeyInit, Tag, XChaCha20Poly1305, XNonce};

/// Poly1305 authentication tag size (bytes).
const TAG_LEN: usize = 16;

/// Encrypt plaintext with XChaCha20-Poly1305.
///
/// Returns ciphertext || 16-byte tag as a plain `Vec<u8>`. Ciphertext is not
/// secret material, so no `Zeroizing` wrapper (unlike `aead_decrypt`).
///
/// # Security
///
/// The plaintext staging buffer is wrapped in `Zeroizing` and zeroized on error.
/// After in-place encryption, the buffer contains only ciphertext (non-secret),
/// so `mem::take` safely extracts it.
#[must_use = "ciphertext must be transmitted or stored"]
#[allow(clippy::explicit_auto_deref)] // &mut *buffer makes the Zeroizing deref explicit and visible
pub fn aead_encrypt(
    key: &[u8; 32],
    nonce: &[u8; 24],
    plaintext: &[u8],
    aad: &[u8],
) -> Result<Vec<u8>> {
    let cipher = XChaCha20Poly1305::new(key.into());
    let nonce = XNonce::from_slice(nonce);

    // Zeroizing wrapper ensures plaintext is zeroized on the error path.
    // Pre-allocate for plaintext + TAG_LEN-byte Poly1305 tag to avoid reallocation.
    let mut buffer = Zeroizing::new({
        let cap = plaintext
            .len()
            .checked_add(TAG_LEN)
            .ok_or(Error::AeadFailed)?;
        let mut v = Vec::with_capacity(cap);
        v.extend_from_slice(plaintext);
        v
    });
    // chacha20poly1305 encrypt can only error if the buffer exceeds the
    // cipher's internal length limit. AeadFailed is correct — this is not a
    // caller-size error but a cipher implementation limit.
    let tag = cipher
        .encrypt_in_place_detached(nonce, aad, &mut *buffer)
        .map_err(|_| Error::AeadFailed)?;

    // After encryption, *buffer contains ciphertext (not plaintext) — Zeroizing's
    // job is done. extend_from_slice won't reallocate (capacity pre-reserved).
    // mem::take extracts the Vec; Zeroizing drops an empty Vec.
    // Capacity is pre-reserved above; this cannot fail in correct code.
    // Debug-only because it guards an allocation invariant, not a security property.
    debug_assert!(
        buffer.capacity() >= buffer.len() + TAG_LEN,
        "aead_encrypt: unexpected capacity — reallocation would bypass Zeroizing"
    );
    buffer.extend_from_slice(&tag);
    Ok(std::mem::take(&mut *buffer))
}

/// Decrypt an XChaCha20-Poly1305 ciphertext.
///
/// `ciphertext` must include the 16-byte authentication tag.
/// Returns self-zeroizing plaintext on success, `Error::AeadFailed` if
/// authentication fails.
///
/// # Security
///
/// The returned plaintext is wrapped in `Zeroizing<Vec<u8>>` — it is
/// automatically zeroized on drop. On authentication failure, the partially
/// decrypted buffer is also zeroized by the `Zeroizing` wrapper's `Drop` impl.
#[must_use = "decrypted plaintext must be consumed or zeroized"]
#[allow(clippy::explicit_auto_deref)] // &mut *buffer makes the Zeroizing deref explicit and visible
pub fn aead_decrypt(
    key: &[u8; 32],
    nonce: &[u8; 24],
    ciphertext: &[u8],
    aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>> {
    // AeadFailed (not InvalidLength) to avoid leaking whether the ciphertext
    // was "too short" vs "bad tag" — both indicate tampered or truncated data.
    if ciphertext.len() < TAG_LEN {
        return Err(Error::AeadFailed);
    }

    let cipher = XChaCha20Poly1305::new(key.into());
    let nonce = XNonce::from_slice(nonce);

    let ct_len = ciphertext.len() - TAG_LEN;
    let mut buffer = Zeroizing::new(ciphertext[..ct_len].to_vec());
    let tag = Tag::from_slice(&ciphertext[ct_len..]);

    // On authentication failure, Zeroizing drop zeroizes the partially decrypted buffer.
    cipher
        .decrypt_in_place_detached(nonce, aad, &mut *buffer, tag)
        .map_err(|_| Error::AeadFailed)?;

    Ok(buffer)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::Error;
    use hex_literal::hex;

    /// Deterministic consistency test: encrypt known inputs, verify output is
    /// stable and round-trips correctly. If this test breaks, the AEAD
    /// implementation has changed behavior — all persisted ciphertexts would
    /// become undecryptable.
    #[test]
    fn deterministic_consistency() {
        let key = hex!("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
        let nonce = hex!("000000000000000000000000000000000000000000000001");
        let pt = b"soliton-aead-kat";
        let aad = b"lo-test-v1";

        let ct1 = aead_encrypt(&key, &nonce, pt, aad).unwrap();
        let ct2 = aead_encrypt(&key, &nonce, pt, aad).unwrap();
        // Same inputs must produce identical ciphertext (XChaCha20-Poly1305 is deterministic).
        assert_eq!(ct1, ct2);
        // Ciphertext must be plaintext_len + 16-byte tag.
        assert_eq!(ct1.len(), pt.len() + 16);
        // Must round-trip.
        let decrypted = aead_decrypt(&key, &nonce, &ct1, aad).unwrap();
        assert_eq!(&*decrypted, pt);
    }

    #[test]
    fn round_trip() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let plaintext = b"round trip test";
        let ct = aead_encrypt(&key, &nonce, plaintext, b"").unwrap();
        let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
        assert_eq!(&*pt, plaintext);
    }

    #[test]
    fn round_trip_with_aad() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let aad = b"additional data";
        let ct = aead_encrypt(&key, &nonce, b"secret", aad).unwrap();
        let pt = aead_decrypt(&key, &nonce, &ct, aad).unwrap();
        assert_eq!(&*pt, b"secret");
    }

    #[test]
    fn round_trip_empty_plaintext() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let ct = aead_encrypt(&key, &nonce, b"", b"").unwrap();
        assert_eq!(ct.len(), 16); // tag only
        let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
        assert!(pt.is_empty());
    }

    #[test]
    fn round_trip_large_plaintext() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let plaintext = vec![0xABu8; 65536];
        let ct = aead_encrypt(&key, &nonce, &plaintext, b"").unwrap();
        let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
        assert_eq!(&*pt, &plaintext);
    }

    #[test]
    fn tampered_ciphertext() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let mut ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
        ct[0] ^= 0xFF; // flip byte in CT body
        assert!(matches!(
            aead_decrypt(&key, &nonce, &ct, b""),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn tampered_tag() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let mut ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
        let last = ct.len() - 1;
        ct[last] ^= 0xFF; // flip byte in tag
        assert!(matches!(
            aead_decrypt(&key, &nonce, &ct, b""),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn wrong_key() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
        let wrong_key: [u8; 32] = crate::primitives::random::random_array();
        assert!(matches!(
            aead_decrypt(&wrong_key, &nonce, &ct, b""),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn wrong_nonce() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
        let wrong_nonce: [u8; 24] = crate::primitives::random::random_array();
        assert!(matches!(
            aead_decrypt(&key, &wrong_nonce, &ct, b""),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn wrong_aad() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let ct = aead_encrypt(&key, &nonce, b"plaintext", b"correct").unwrap();
        assert!(matches!(
            aead_decrypt(&key, &nonce, &ct, b"wrong"),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn too_short_ciphertext() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let short = vec![0u8; 15]; // < 16 byte tag
        assert!(matches!(
            aead_decrypt(&key, &nonce, &short, b""),
            Err(Error::AeadFailed)
        ));
    }

    #[test]
    fn decrypt_returns_zeroizing() {
        let key: [u8; 32] = crate::primitives::random::random_array();
        let nonce: [u8; 24] = crate::primitives::random::random_array();
        let ct = aead_encrypt(&key, &nonce, b"test", b"").unwrap();
        // Compile-time type check: result is Zeroizing<Vec<u8>>.
        let result: Zeroizing<Vec<u8>> = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
        assert_eq!(&*result, b"test");
    }
}