nahui 0.2.0

Authenticated encryption (ChaCha20-Poly1305) and BLAKE3 content hashing for document attestation.
Documentation
//! Verify that failure paths in `encrypt_into` / `decrypt_into` scrub the
//! plaintext bytes they wrote into the caller's buffer — not just shrink
//! `len` past them.
//!
//! This lives as an integration test rather than inside `src/crypto.rs`
//! because inspecting a `Vec`'s spare capacity requires `unsafe`, and the
//! crate itself is `#![forbid(unsafe_code)]`. Here we are a consumer of
//! the crate, so we can reach past `len` to check the bytes directly.

use nahui::crypto::Vault;

const KEY: [u8; 32] = [0x42; 32];
const AAD: &[u8] = b"scrub-test";

/// Read the first `n` bytes of `v`'s spare capacity (bytes at indices
/// `v.len()..v.len()+n`). Caller asserts those bytes were previously
/// written by `v` itself in this test, so they are initialized.
fn peek_spare(v: &Vec<u8>, n: usize) -> Vec<u8> {
    assert!(v.len() + n <= v.capacity());
    let ptr = v.as_ptr();
    unsafe {
        let tail = std::slice::from_raw_parts(ptr.add(v.len()), n);
        tail.to_vec()
    }
}

#[test]
fn decrypt_into_scrubs_plaintext_on_auth_failure() {
    let v = Vault::new(KEY);

    // Pick a plaintext with a distinctive pattern so we can tell it apart
    // from zeros in spare capacity.
    let plaintext: Vec<u8> = (0u8..64).map(|i| 0x80 | i).collect();
    let mut ct = v.encrypt(&plaintext, AAD).unwrap();

    // Tamper so auth fails.
    *ct.last_mut().unwrap() ^= 0xff;

    // Use a fresh Vec with known capacity so spare-cap inspection is meaningful.
    let mut out: Vec<u8> = Vec::with_capacity(256);
    let pre_len = out.len();
    let ct_plaintext_len = ct.len() - 1 - 12 - 16; // version + nonce + tag overhead

    assert!(v.decrypt_into(&ct, AAD, &mut out).is_err());
    assert_eq!(out.len(), pre_len, "len must return to pre-call value");

    // The bytes that held the keystream-XORed plaintext during decryption
    // must now be zero. If the scrub was removed, these would match
    // `plaintext` (possibly the whole thing, possibly mangled by the failed
    // tag check, but definitely not all zero).
    let tail = peek_spare(&out, ct_plaintext_len);
    assert!(
        tail.iter().all(|&b| b == 0),
        "spare capacity must be scrubbed; got {:02x?}",
        &tail[..tail.len().min(16)]
    );
}

#[test]
fn encrypt_into_on_success_leaves_only_ciphertext() {
    // Sanity check complement: on success, the bytes that held plaintext
    // pre-encryption now hold ciphertext, and no plaintext should be
    // findable anywhere in out[0..out.len()].
    let v = Vault::new(KEY);
    let plaintext: Vec<u8> = (0u8..128).map(|i| 0xC0 | (i & 0x3F)).collect();
    let mut out = Vec::with_capacity(512);
    v.encrypt_into(&plaintext, AAD, &mut out).unwrap();

    // No 8-byte window of the output should match a window of plaintext —
    // if it does, encrypt-in-place silently became encrypt-to-copy and
    // left plaintext behind.
    for win in out.windows(8) {
        for pwin in plaintext.windows(8) {
            assert_ne!(win, pwin, "plaintext leaked into ciphertext buffer");
        }
    }
}