Menu
Public repository / f5fc06029891d1736ec4b97b432afc98ebe85c08
AkurAI-VPN / crates/akurai-crypto/src/chacha20poly1305.rs
Latest change 881474cc4c65fa45abfd7fd2a05a9ed130ba18d9 - feat(crypto): zero-dep X25519 + ChaCha20-Poly1305 + BLAKE2s/HMAC + Noise HKDF by olibuijr
//! ChaCha20-Poly1305 AEAD — RFC 8439, pure `std`, no `unsafe`.
// ── ChaCha20 ─────────────────────────────────────────────────────────────────
/// The 16-word ChaCha20 state.
#[derive(Clone)]
struct ChaChaState([u32; 16]);
impl ChaChaState {
fn new(key: &[u8; 32], nonce: &[u8; 12], counter: u32) -> Self {
// §2.3 initial state
let mut s = [0u32; 16];
// "expa", "nd 3", "2-by", "te k"
s[0] = 0x6170_7865;
s[1] = 0x3320_646e;
s[2] = 0x7962_2d32;
s[3] = 0x6b20_6574;
for i in 0..8 {
s[4 + i] = u32::from_le_bytes(key[i * 4..i * 4 + 4].try_into().unwrap());
}
s[12] = counter;
s[13] = u32::from_le_bytes(nonce[0..4].try_into().unwrap());
s[14] = u32::from_le_bytes(nonce[4..8].try_into().unwrap());
s[15] = u32::from_le_bytes(nonce[8..12].try_into().unwrap());
ChaChaState(s)
}
/// Quarter-round in place.
#[inline]
fn qr(s: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) {
s[a] = s[a].wrapping_add(s[b]);
s[d] ^= s[a];
s[d] = s[d].rotate_left(16);
s[c] = s[c].wrapping_add(s[d]);
s[b] ^= s[c];
s[b] = s[b].rotate_left(12);
s[a] = s[a].wrapping_add(s[b]);
s[d] ^= s[a];
s[d] = s[d].rotate_left(8);
s[c] = s[c].wrapping_add(s[d]);
s[b] ^= s[c];
s[b] = s[b].rotate_left(7);
}
/// Produce one 64-byte keystream block.
fn block(&self) -> [u8; 64] {
let mut work = self.0;
for _ in 0..10 {
// column rounds
Self::qr(&mut work, 0, 4, 8, 12);
Self::qr(&mut work, 1, 5, 9, 13);
Self::qr(&mut work, 2, 6, 10, 14);
Self::qr(&mut work, 3, 7, 11, 15);
// diagonal rounds
Self::qr(&mut work, 0, 5, 10, 15);
Self::qr(&mut work, 1, 6, 11, 12);
Self::qr(&mut work, 2, 7, 8, 13);
Self::qr(&mut work, 3, 4, 9, 14);
}
let mut out = [0u8; 64];
for (i, (&wi, &si)) in work.iter().zip(self.0.iter()).enumerate() {
let word = wi.wrapping_add(si);
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_le_bytes());
}
out
}
}
/// XOR `data` with the ChaCha20 keystream; counter starts at `counter`.
fn chacha20_xor(key: &[u8; 32], nonce: &[u8; 12], counter: u32, data: &mut [u8]) {
let mut state = ChaChaState::new(key, nonce, counter);
let mut pos = 0;
while pos < data.len() {
let block = state.block();
let len = (data.len() - pos).min(64);
for i in 0..len {
data[pos + i] ^= block[i];
}
pos += 64;
state.0[12] = state.0[12].wrapping_add(1);
}
}
// ── Poly1305 ──────────────────────────────────────────────────────────────────
/// Process a message with Poly1305 using a 32-byte one-time key.
///
/// Uses 5×u64 limbs in base 2^26, matching the djb reference implementation.
/// All intermediate products stay within u64 (each limb ≤ 2^26, r ≤ 2^26, sum
/// of 5 products ≤ 5 · 2^52 which fits in u64).
fn poly1305_mac(key: &[u8; 32], msg: &[u8]) -> [u8; 16] {
// clamp r (§2.5.1)
let mut r_bytes = [0u8; 16];
r_bytes.copy_from_slice(&key[..16]);
r_bytes[3] &= 0x0f;
r_bytes[7] &= 0x0f;
r_bytes[11] &= 0x0f;
r_bytes[15] &= 0x0f;
r_bytes[4] &= 0xfc;
r_bytes[8] &= 0xfc;
r_bytes[12] &= 0xfc;
// load r as little-endian 128-bit, split into 5×26-bit limbs
let r128 = u128::from_le_bytes(r_bytes);
let r0 = (r128 & 0x3ff_ffff) as u64;
let r1 = ((r128 >> 26) & 0x3ff_ffff) as u64;
let r2 = ((r128 >> 52) & 0x3ff_ffff) as u64;
let r3 = ((r128 >> 78) & 0x3ff_ffff) as u64;
let r4 = ((r128 >> 104) & 0x3ff_ffff) as u64;
// 5 * r[1..4] for the mod-2^130-5 reduction shortcut
let s1 = r1 * 5;
let s2 = r2 * 5;
let s3 = r3 * 5;
let s4 = r4 * 5;
let mut h: [u64; 5] = [0; 5];
let mut offset = 0;
loop {
let remaining = msg.len().saturating_sub(offset);
if remaining == 0 {
break;
}
let chunk_len = remaining.min(16);
let mut block = [0u8; 17];
block[..chunk_len].copy_from_slice(&msg[offset..offset + chunk_len]);
block[chunk_len] = 1; // hibit
// load as 130-bit little-endian
let n0 = u64::from_le_bytes(block[0..8].try_into().unwrap());
let n1 = u64::from_le_bytes(block[8..16].try_into().unwrap());
let n2 = block[16] as u64;
// split into 5×26-bit limbs and add to h
h[0] += n0 & 0x3ff_ffff;
h[1] += (n0 >> 26) & 0x3ff_ffff;
h[2] += ((n0 >> 52) | (n1 << 12)) & 0x3ff_ffff;
h[3] += (n1 >> 14) & 0x3ff_ffff;
h[4] += ((n1 >> 40) | (n2 << 24)) & 0x3ff_ffff;
// h = h * r mod p
// Each d_i is a sum of 5 terms each ≤ (2^26 + some carry) * 2^26 < 2^53,
// so the sum of 5 fits comfortably in u64.
let d0 = h[0] * r0 + h[1] * s4 + h[2] * s3 + h[3] * s2 + h[4] * s1;
let d1 = h[0] * r1 + h[1] * r0 + h[2] * s4 + h[3] * s3 + h[4] * s2;
let d2 = h[0] * r2 + h[1] * r1 + h[2] * r0 + h[3] * s4 + h[4] * s3;
let d3 = h[0] * r3 + h[1] * r2 + h[2] * r1 + h[3] * r0 + h[4] * s4;
let d4 = h[0] * r4 + h[1] * r3 + h[2] * r2 + h[3] * r1 + h[4] * r0;
// carry propagation
let c0 = d0 >> 26;
h[0] = d0 & 0x3ff_ffff;
let c1 = (d1 + c0) >> 26;
h[1] = (d1 + c0) & 0x3ff_ffff;
let c2 = (d2 + c1) >> 26;
h[2] = (d2 + c1) & 0x3ff_ffff;
let c3 = (d3 + c2) >> 26;
h[3] = (d3 + c2) & 0x3ff_ffff;
let c4 = (d4 + c3) >> 26;
h[4] = (d4 + c3) & 0x3ff_ffff;
// c4 * 2^130 ≡ c4 * 5 (mod p)
h[0] += c4 * 5;
let c0b = h[0] >> 26;
h[0] &= 0x3ff_ffff;
h[1] += c0b;
offset += 16;
}
// final carry propagation
let c1 = h[1] >> 26;
h[1] &= 0x3ff_ffff;
let c2 = (h[2] + c1) >> 26;
h[2] = (h[2] + c1) & 0x3ff_ffff;
let c3 = (h[3] + c2) >> 26;
h[3] = (h[3] + c2) & 0x3ff_ffff;
let c4 = (h[4] + c3) >> 26;
h[4] = (h[4] + c3) & 0x3ff_ffff;
h[0] += c4 * 5;
let c0 = h[0] >> 26;
h[0] &= 0x3ff_ffff;
h[1] += c0;
// compute h - p conditionally; use constant-time select via mask
let mut g: [u64; 5] = [0; 5];
g[0] = h[0].wrapping_add(5);
let b = g[0] >> 26;
g[0] &= 0x3ff_ffff;
g[1] = h[1].wrapping_add(b);
let b = g[1] >> 26;
g[1] &= 0x3ff_ffff;
g[2] = h[2].wrapping_add(b);
let b = g[2] >> 26;
g[2] &= 0x3ff_ffff;
g[3] = h[3].wrapping_add(b);
let b = g[3] >> 26;
g[3] &= 0x3ff_ffff;
g[4] = h[4].wrapping_add(b).wrapping_sub(1 << 26);
// g[4] >> 63 == 1 → underflow → h < p → keep h (mask = 0)
// g[4] >> 63 == 0 → no underflow → h >= p → select g (mask = all-ones)
let mask = (g[4] >> 63).wrapping_sub(1);
h[0] = (h[0] & !mask) | (g[0] & mask);
h[1] = (h[1] & !mask) | (g[1] & mask);
h[2] = (h[2] & !mask) | (g[2] & mask);
h[3] = (h[3] & !mask) | (g[3] & mask);
h[4] = (h[4] & !mask) | (g[4] & mask);
// reassemble 128-bit h, then add s (key[16..32] as little-endian u128)
let h128 = (h[0] as u128)
| ((h[1] as u128) << 26)
| ((h[2] as u128) << 52)
| ((h[3] as u128) << 78)
| ((h[4] as u128) << 104);
let s128 = u128::from_le_bytes(key[16..32].try_into().unwrap());
let tag128 = h128.wrapping_add(s128);
tag128.to_le_bytes()
}
// ── AEAD MAC input ────────────────────────────────────────────────────────────
fn pad16_len(n: usize) -> usize {
let r = n % 16;
if r == 0 {
0
} else {
16 - r
}
}
fn mac_data(aad: &[u8], ciphertext: &[u8]) -> Vec<u8> {
let mut v = Vec::with_capacity(
aad.len() + pad16_len(aad.len()) + ciphertext.len() + pad16_len(ciphertext.len()) + 16,
);
v.extend_from_slice(aad);
v.extend(std::iter::repeat_n(0u8, pad16_len(aad.len())));
v.extend_from_slice(ciphertext);
v.extend(std::iter::repeat_n(0u8, pad16_len(ciphertext.len())));
v.extend_from_slice(&(aad.len() as u64).to_le_bytes());
v.extend_from_slice(&(ciphertext.len() as u64).to_le_bytes());
v
}
// ── Public AEAD API ───────────────────────────────────────────────────────────
/// AEAD seal. Returns `ciphertext || 16-byte Poly1305 tag`.
pub fn seal(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> Vec<u8> {
// one-time Poly1305 key from block counter 0
let mut poly_key = [0u8; 64];
let block0 = ChaChaState::new(key, nonce, 0).block();
poly_key.copy_from_slice(&block0);
let otk: &[u8; 32] = poly_key[..32].try_into().unwrap();
// encrypt with counter 1
let mut ct = plaintext.to_vec();
chacha20_xor(key, nonce, 1, &mut ct);
// compute tag
let mac_input = mac_data(aad, &ct);
let tag = poly1305_mac(otk, &mac_input);
ct.extend_from_slice(&tag);
ct
}
/// AEAD open. Constant-time tag verify; returns plaintext on success.
pub fn open(
key: &[u8; 32],
nonce: &[u8; 12],
aad: &[u8],
ciphertext_and_tag: &[u8],
) -> Option<Vec<u8>> {
if ciphertext_and_tag.len() < 16 {
return None;
}
let (ct, received_tag) = ciphertext_and_tag.split_at(ciphertext_and_tag.len() - 16);
// one-time Poly1305 key
let block0 = ChaChaState::new(key, nonce, 0).block();
let otk: &[u8; 32] = block0[..32].try_into().unwrap();
// compute expected tag
let mac_input = mac_data(aad, ct);
let expected_tag = poly1305_mac(otk, &mac_input);
// constant-time compare
let diff = expected_tag
.iter()
.zip(received_tag.iter())
.fold(0u8, |acc, (&a, &b)| acc | (a ^ b));
if diff != 0 {
return None;
}
// decrypt
let mut plaintext = ct.to_vec();
chacha20_xor(key, nonce, 1, &mut plaintext);
Some(plaintext)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn from_hex(s: &str) -> Vec<u8> {
let s: String = s
.chars()
.filter(|c| !c.is_whitespace() && *c != ':')
.collect();
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
fn to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{:02x}", x)).collect()
}
/// RFC 8439 §2.4.2 — ChaCha20 encryption test vector.
#[test]
fn chacha20_rfc8439_2_4_2() {
let key: [u8; 32] = (0x00u8..=0x1fu8).collect::<Vec<_>>().try_into().unwrap();
let nonce_bytes = from_hex("000000000000004a00000000");
let nonce: [u8; 12] = nonce_bytes.try_into().unwrap();
let plaintext = b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.";
let mut ct = plaintext.to_vec();
chacha20_xor(&key, &nonce, 1, &mut ct);
// first 16 bytes
assert_eq!(&to_hex(&ct[..16]), "6e2e359a2568f98041ba0728dd0d6981");
// full ciphertext from RFC 8439 §2.4.2
let expected = from_hex(
"6e2e359a 2568f980 41ba0728 dd0d6981\
e97e7aec 1d4360c2 0a27afcc fd9fae0b\
f91b65c5 524733ab 8f593dab cd62b357\
1639d624 e65152ab 8f530c35 9f0861d8\
07ca0dbf 500d6a61 56a38e08 8a22b65e\
52bc514d 16ccf806 818ce91a b7793736\
5af90bbf 74a35be6 b40b8eed f2785e42\
874d",
);
assert_eq!(ct, expected, "full ChaCha20 ciphertext mismatch");
}
/// RFC 8439 §2.5.2 — Poly1305 MAC test vector.
#[test]
fn poly1305_rfc8439_2_5_2() {
let key_bytes =
from_hex("85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b");
let key: [u8; 32] = key_bytes.try_into().unwrap();
let msg = b"Cryptographic Forum Research Group";
let tag = poly1305_mac(&key, msg);
assert_eq!(to_hex(&tag), "a8061dc1305136c6c22b8baf0c0127a9");
}
/// RFC 8439 §2.8.2 — AEAD seal test vector.
#[test]
fn aead_seal_rfc8439_2_8_2() {
let key_bytes: Vec<u8> = (0x80u8..=0x9fu8).collect();
let key: [u8; 32] = key_bytes.try_into().unwrap();
let nonce_bytes = from_hex("070000004041424344454647");
let nonce: [u8; 12] = nonce_bytes.try_into().unwrap();
let aad = from_hex("50515253c0c1c2c3c4c5c6c7");
let plaintext = b"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.";
let result = seal(&key, &nonce, &aad, plaintext);
let ct = &result[..result.len() - 16];
let tag = &result[result.len() - 16..];
// full ciphertext from RFC §2.8.2
let expected_ct = from_hex(
"d31a8d34648e60db7b86afbc53ef7ec2\
a4aded51296e08fea9e2b5a736ee62d6\
3dbea45e8ca9671282fafb69da92728b\
1a71de0a9e060b2905d6a5b67ecd3b36\
92ddbd7f2d778b8c9803aee328091b58\
fab324e4fad675945585808b4831d7bc\
3ff4def08e4b7a9de576d26586cec64b\
6116",
);
assert_eq!(ct, expected_ct.as_slice(), "ciphertext mismatch");
assert_eq!(to_hex(tag), "1ae10b594f09e26a7e902ecbd0600691");
}
/// Round-trip: open(seal(...)) == plaintext.
#[test]
fn round_trip() {
let key = [0x42u8; 32];
let nonce = [0x24u8; 12];
let aad = b"header";
let plaintext = b"hello world, this is a test message";
let sealed = seal(&key, &nonce, aad, plaintext);
let opened = open(&key, &nonce, aad, &sealed).expect("open failed");
assert_eq!(opened, plaintext);
}
/// Tamper: flip a tag byte → open returns None.
#[test]
fn tamper_detected() {
let key = [0x11u8; 32];
let nonce = [0x22u8; 12];
let aad = b"aad";
let plaintext = b"secret";
let mut sealed = seal(&key, &nonce, aad, plaintext);
// flip last byte of tag
let last = sealed.len() - 1;
sealed[last] ^= 0x01;
assert!(open(&key, &nonce, aad, &sealed).is_none());
}
/// Tamper: flip a ciphertext byte → open returns None.
#[test]
fn tamper_ciphertext_detected() {
let key = [0x33u8; 32];
let nonce = [0x44u8; 12];
let aad = b"more-aad";
let plaintext = b"another secret message";
let mut sealed = seal(&key, &nonce, aad, plaintext);
sealed[0] ^= 0xff;
assert!(open(&key, &nonce, aad, &sealed).is_none());
}
}