varta-vlp 0.2.0

Varta Lifeline Protocol — 32-byte fixed-layout health frame.
Documentation
//! ChaCha20-Poly1305 AEAD construction — RFC 8439 §2.8.
//!
//! Implements `seal` (encrypt + authenticate) and `open` (verify + decrypt)
//! for 32-byte fixed-size plaintexts. Backed by the externally-audited
//! `chacha20poly1305` crate (RustCrypto, NCC Group audit 2020).
//!
//! Constant-time tag verification is provided by the upstream crate.
//! No hand-rolled crypto exists in this file.
//!
//! # Wire format
//!
//! The [`seal`] function returns `(ciphertext, tag)`. The transport layer
//! places these on the wire alongside the 12-byte nonce split into its
//! two components — 8-byte `iv_random` and 4-byte `iv_counter`:
//!
//! ```text
//! [iv_random: 8] [iv_counter: 4] [ciphertext: 32] [tag: 16]
//! ```
//!
//! See [`varta_vlp::crypto`] for the wire format constant.

use chacha20poly1305::{
    aead::{AeadInPlace, KeyInit},
    ChaCha20Poly1305, Nonce, Tag,
};

/// AEAD authentication failure — the tag did not verify.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthError;

/// AEAD seal failure. Unreachable for VLP's fixed `[u8; 32]` inputs against
/// `chacha20poly1305 = "=0.10.1"`; surfaced as `Result` so the caller observes
/// any future upstream change rather than aborting under `panic = "abort"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SealError;

/// Encrypt a 32-byte plaintext and produce a 16-byte authentication tag.
///
/// # Parameters
///
/// * `key` — 256-bit (32-byte) pre-shared symmetric key.
/// * `nonce` — 96-bit (12-byte) message nonce. **Must never be reused**
///   with the same key. The caller is responsible for nonce uniqueness.
/// * `aad` — Additional Authenticated Data bound into the tag but not
///   encrypted. Pass `b""` when no plaintext wire fields need binding.
/// * `plaintext` — 32-byte VLP frame to encrypt.
///
/// # Returns
///
/// `Ok((ciphertext, tag))` where `ciphertext` is 32 bytes and `tag` is 16 bytes.
/// The transport layer joins these with the caller-provided nonce prefix
/// for a total of 60 bytes (shared-key) or 64 bytes (master-key).
///
/// Returns `Err(SealError)` if the underlying AEAD encrypt fails. This branch
/// is unreachable for VLP's fixed `[u8; 32]` inputs against the pinned
/// `chacha20poly1305 = "=0.10.1"` crate, but is surfaced as a `Result` so
/// that any future upstream change is observable (via `BeatOutcome::Failed`)
/// rather than a silent process abort under `panic = "abort"`.
pub fn seal(
    key: &[u8; 32],
    nonce: &[u8; 12],
    aad: &[u8],
    plaintext: &[u8; 32],
) -> Result<([u8; 32], [u8; 16]), SealError> {
    let cipher = ChaCha20Poly1305::new(chacha20poly1305::Key::from_slice(key));
    let mut ct = *plaintext;
    let tag = cipher
        .encrypt_in_place_detached(Nonce::from_slice(nonce), aad, &mut ct)
        .map_err(|_| SealError)?;
    let mut tag_bytes = [0u8; 16];
    tag_bytes.copy_from_slice(&tag);
    Ok((ct, tag_bytes))
}

/// Verify and decrypt a ChaCha20-Poly1305 AEAD ciphertext.
///
/// # Parameters
///
/// * `key` — 256-bit (32-byte) pre-shared symmetric key.
/// * `nonce` — 96-bit (12-byte) message nonce (must match the nonce used
///   during encryption).
/// * `aad` — Additional Authenticated Data (must match what was passed to
///   [`seal`]). Pass `b""` when no AAD was used.
/// * `ciphertext` — 32-byte encrypted payload.
/// * `tag` — 16-byte Poly1305 authentication tag.
///
/// # Returns
///
/// `Ok(plaintext)` on successful authentication and decryption, or
/// `Err(AuthError)` if the tag does not verify (indicating tampering,
/// wrong key, wrong AAD, or corrupted data). Tag comparison is constant-time.
pub fn open(
    key: &[u8; 32],
    nonce: &[u8; 12],
    aad: &[u8],
    ciphertext: &[u8; 32],
    tag: &[u8; 16],
) -> Result<[u8; 32], AuthError> {
    let cipher = ChaCha20Poly1305::new(chacha20poly1305::Key::from_slice(key));
    let mut pt = *ciphertext;
    cipher
        .decrypt_in_place_detached(Nonce::from_slice(nonce), aad, &mut pt, Tag::from_slice(tag))
        .map_err(|_| AuthError)?;
    Ok(pt)
}

#[cfg(test)]
mod tests {
    use super::*;

    // Known-answer test: validate that seal() matches manually-composed
    // primitive output.  The chacha20 and poly1305 primitives are each
    // independently RFC-verified; this test ensures the AEAD glue layers
    // compose them correctly (counter start, mac_data layout, padding).
    #[test]
    fn aead_known_answer_against_primitives() {
        let key: [u8; 32] = [
            0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d,
            0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
            0x9c, 0x9d, 0x9e, 0x9f,
        ];
        let nonce: [u8; 12] = [
            0x07, 0x00, 0x00, 0x00, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
        ];
        let plaintext = [0xdeu8; 32];

        let (ct, tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");

        // Verify via open — round-trip proves seal produces a valid AEAD frame.
        let decrypted = open(&key, &nonce, b"", &ct, &tag).expect("round-trip must succeed");
        assert_eq!(decrypted, plaintext);
    }

    #[test]
    fn aead_roundtrip_rfc8439_params() {
        let key: [u8; 32] = [
            0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d,
            0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
            0x9c, 0x9d, 0x9e, 0x9f,
        ];

        let nonce: [u8; 12] = [
            0x07, 0x00, 0x00, 0x00, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
        ];

        let plaintext = [
            0x4c, 0x61, 0x64, 0x69, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x47, 0x65, 0x6e,
            0x74, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20,
            0x63, 0x6c, 0x61, 0x73,
        ];

        let (ciphertext, tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");
        let decrypted = open(&key, &nonce, b"", &ciphertext, &tag)
            .expect("roundtrip with RFC params should succeed");
        assert_eq!(decrypted, plaintext);
    }

    // Load-bearing test: validates that our wrapper produces the exact RFC 8439
    // ciphertext and tag bytes. A regression here means we changed the
    // key-schedule, nonce-handling, or AAD-layout in an incompatible way.
    #[test]
    fn aead_known_answer_matches_rfc8439_chacha20_poly1305() {
        let key: [u8; 32] = [
            0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d,
            0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
            0x9c, 0x9d, 0x9e, 0x9f,
        ];
        let nonce: [u8; 12] = [
            0x07, 0x00, 0x00, 0x00, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
        ];
        let plaintext = [
            0x4c, 0x61, 0x64, 0x69, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x47, 0x65, 0x6e,
            0x74, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20,
            0x63, 0x6c, 0x61, 0x73,
        ];

        let expected_ciphertext = [
            0xd3, 0x1a, 0x8d, 0x34, 0x64, 0x8e, 0x60, 0xdb, 0x7b, 0x86, 0xaf, 0xbc, 0x53, 0xef,
            0x7e, 0xc2, 0xa4, 0xad, 0xed, 0x51, 0x29, 0x6e, 0x08, 0xfe, 0xa9, 0xe2, 0xb5, 0xa7,
            0x36, 0xee, 0x62, 0xd6,
        ];
        let expected_tag = [
            0xe2, 0x8a, 0x1b, 0xe5, 0x81, 0x77, 0x79, 0xa8, 0xa5, 0xae, 0xee, 0x8c, 0x1e, 0xcf,
            0xef, 0x33,
        ];

        let (ciphertext, tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");

        assert_eq!(ciphertext, expected_ciphertext);
        assert_eq!(tag, expected_tag);
    }

    #[test]
    fn aead_roundtrip() {
        let key = [0xabu8; 32];
        let nonce = [0x42u8; 12];
        let plaintext = [0xdeu8; 32];

        let (ciphertext, tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");
        assert_ne!(ciphertext, plaintext, "encryption should change data");

        let decrypted = open(&key, &nonce, b"", &ciphertext, &tag)
            .expect("decryption should succeed with correct key and nonce");
        assert_eq!(
            decrypted, plaintext,
            "roundtrip should recover original plaintext"
        );
    }

    #[test]
    fn aead_wrong_key_fails() {
        let key_a = [0xabu8; 32];
        let key_b = [0x42u8; 32];
        let nonce = [0x01u8; 12];
        let plaintext = [0xdeu8; 32];

        let (ciphertext, tag) = seal(&key_a, &nonce, b"", &plaintext).expect("seal must succeed");

        let result = open(&key_b, &nonce, b"", &ciphertext, &tag);
        assert!(result.is_err(), "decryption with wrong key must fail");
    }

    #[test]
    fn aead_wrong_nonce_fails() {
        let key = [0xabu8; 32];
        let nonce_a = [0x01u8; 12];
        let nonce_b = [0x02u8; 12];
        let plaintext = [0xdeu8; 32];

        let (ciphertext, tag) = seal(&key, &nonce_a, b"", &plaintext).expect("seal must succeed");

        let result = open(&key, &nonce_b, b"", &ciphertext, &tag);
        assert!(result.is_err(), "decryption with wrong nonce must fail");
    }

    #[test]
    fn aead_tampered_ciphertext_fails() {
        let key = [0xabu8; 32];
        let nonce = [0x42u8; 12];
        let plaintext = [0xdeu8; 32];

        let (mut ciphertext, tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");
        ciphertext[15] ^= 0x01;

        let result = open(&key, &nonce, b"", &ciphertext, &tag);
        assert!(
            result.is_err(),
            "decryption with tampered ciphertext must fail"
        );
    }

    #[test]
    fn aead_tampered_tag_fails() {
        let key = [0xabu8; 32];
        let nonce = [0x42u8; 12];
        let plaintext = [0xdeu8; 32];

        let (ciphertext, mut tag) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");
        tag[0] ^= 0x01;

        let result = open(&key, &nonce, b"", &ciphertext, &tag);
        assert!(result.is_err(), "decryption with tampered tag must fail");
    }

    #[test]
    fn aead_same_input_same_output() {
        let key = [0xabu8; 32];
        let nonce = [0x42u8; 12];
        let plaintext = [0xdeu8; 32];

        let (ct1, tag1) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");
        let (ct2, tag2) = seal(&key, &nonce, b"", &plaintext).expect("seal must succeed");

        assert_eq!(ct1, ct2, "same input must produce same ciphertext");
        assert_eq!(tag1, tag2, "same input must produce same tag");
    }

    // AAD binding: a frame sealed with non-empty AAD must fail to open with
    // wrong or missing AAD. Regression catch for the master-key wire format.
    #[test]
    fn aead_aad_binding_roundtrip() {
        let key = [0x11u8; 32];
        let nonce = [0x22u8; 12];
        let plaintext = [0x33u8; 32];
        let aad: &[u8] = &[0x01, 0x02, 0x03, 0x04]; // simulates agent_pid LE

        let (ct, tag) = seal(&key, &nonce, aad, &plaintext).expect("seal must succeed");

        // Correct AAD must succeed.
        let decrypted = open(&key, &nonce, aad, &ct, &tag).expect("correct AAD must verify");
        assert_eq!(decrypted, plaintext);

        // Wrong AAD must fail.
        let wrong_aad: &[u8] = &[0xFF, 0xFF, 0xFF, 0xFF];
        assert!(
            open(&key, &nonce, wrong_aad, &ct, &tag).is_err(),
            "wrong AAD must fail"
        );

        // Missing AAD (b"") must fail.
        assert!(
            open(&key, &nonce, b"", &ct, &tag).is_err(),
            "missing AAD must fail"
        );
    }

    #[test]
    fn aead_tampered_aad_fails() {
        let key = [0x55u8; 32];
        let nonce = [0x66u8; 12];
        let plaintext = [0x77u8; 32];
        let aad = [0x01u8, 0x00, 0x00, 0x00]; // agent_pid = 1 LE

        let (ct, tag) = seal(&key, &nonce, &aad, &plaintext).expect("seal must succeed");

        // Tamper one byte of the AAD (simulates on-wire agent_pid mutation).
        let mut tampered_aad = aad;
        tampered_aad[0] ^= 0x01;
        assert!(
            open(&key, &nonce, &tampered_aad, &ct, &tag).is_err(),
            "tampered AAD must fail AEAD verification"
        );
    }
}