This commit is contained in:
mghazwi 2026-04-06 08:01:00 +02:00
commit cf4510cb21
7 changed files with 383 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
#IDE Related
.idea
# Cargo build
/target
Cargo.lock
/output
# Profile-guided optimization
/tmp
pgo-data.profdata
# MacOS nuisances
.DS_Store

13
Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "lioness-blockcipher"
version = "0.1.0"
edition = "2024"
description = "LIONESS large-block cipher using RustCrypto ChaCha20 and BLAKE2 with TurboSHAKE as KDF"
license = "MIT OR Apache-2.0"
[dependencies]
anyhow = "1"
blake2 = "0.10"
chacha20 = "0.10"
sha3 = "0.11.0"
zeroize = { version = "1", features = ["zeroize_derive"] }

6
LICENSE.md Normal file
View File

@ -0,0 +1,6 @@
All crates of this repo are licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.

63
README.md Normal file
View File

@ -0,0 +1,63 @@
### Lioness large-block cipher with ChaCha20, Blake2b, and Turboshake.
### Warning
This code has not been formally audited, Use at your own risk or ask a cryptographers before use.
### Overview
[Lioness](https://www.cl.cam.ac.uk/~rja14/Papers/bear-lion.pdf) is a large block cipher built from
- Stream cipher,
- Hash function,
- Key derivation function (KDF), although this can be remove if the input key is large enough to cover the four sub-keys used.
In here we use:
- Chacha20 from [rustcrypto streamciphers](https://github.com/RustCrypto/stream-ciphers)
- Blake2b from [rustcrypto hashes](https://github.com/RustCrypto/hashes/tree/master)
- turboshake KDF from [rustcrypto SHA3](https://github.com/RustCrypto/hashes/tree/master/sha3)
The security of lioness reduce to the security of the underlying stream cipher or
the hash function.
See the [paper](https://www.cl.cam.ac.uk/~rja14/Papers/bear-lion.pdf) for more information.
### How to use
Here is an example of how to use the `Lioness_blockcipher` create.
Use a 32-byte master key and encrypt or decrypt a block in place:
```rust
use lioness_blockcipher::{Lioness, MasterKey};
fn main() -> anyhow::Result<()> {
let mut key: MasterKey = [0x42; 32];
let cipher = Lioness::new(&key);
// Blocks must be at >32 bytes long
let mut block = b"this is a long plaintext block and must stay a secret".to_vec();
let original = block.clone();
cipher.encrypt_in_place(&mut block)?;
cipher.decrypt_in_place(&mut block)?;
assert_eq!(block, original);
Ok(())
}
```
Some notes:
- Encryption and decryption are both in-place for now.
- The block length need to be bigger than `32` bytes because Lioness splits the block into two where the left part is 32-byte, and the right part can't be empty. might support small blocks in the future, but for Sphinx use-case, this should work.
- If you need authenticity, make sure to prepend the plaintext with `k` zeros and check the zeros after decryption. This will be supported in the future... see [integrity example](./examples/integrity.rs)
### TODO
- [ ] Add more tests, examples, and benchmarks ...
- [ ] Make it generic for any compatible cipher, keyed_hash, and KDF.
- [ ] Compare with another implementation ... maybe with Haskel when available.
- [ ] Add a version with API which prepend the plaintext with k-zeros and checks authenticity after decryption.
- [ ] impl enc and dec to the API to work beside encrypt_in_place and decrypt_in_place.
- ...

27
examples/integrity.rs Normal file
View File

@ -0,0 +1,27 @@
use anyhow::Result;
use lioness_blockcipher::{Lioness, MasterKey};
const K: usize = 32;
fn main() -> Result<()> {
let key: MasterKey = [0x42; 32];
let cipher = Lioness::new(&key);
let payload = b"this plaintext msg is prefixed with k zeros before encryption";
let mut plaintext = vec![0u8; K];
plaintext.extend_from_slice(payload);
let mut block = plaintext.clone();
cipher.encrypt_in_place(&mut block)?;
// tamper with the ciphertext
block[K + 5] ^= 0x01;
cipher.decrypt_in_place(&mut block)?;
if block[..K].iter().all(|&b| b != 0) {
println!("tampering detected i.e. zero-prefix check failed");
}
Ok(())
}

View File

@ -0,0 +1,20 @@
use lioness_blockcipher::{Lioness, MasterKey};
fn main() -> anyhow::Result<()> {
let key: MasterKey = [0x42; 32];
let cipher = Lioness::new(&key);
// Blocks must be at >32 bytes long
let mut block = b"this is a long plaintext block and must stay a secret".to_vec();
let original = block.clone();
cipher.encrypt_in_place(&mut block)?;
cipher.decrypt_in_place(&mut block)?;
assert_eq!(block, original);
println!("success!");
Ok(())
}

240
src/lib.rs Normal file
View File

@ -0,0 +1,240 @@
use anyhow::{Result, anyhow};
use blake2::Blake2bMac;
use blake2::digest::{KeyInit as BlakeKeyInit, Mac, consts::U32};
use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use sha3::{
Shake128,
digest::{ExtendableOutput, Update, XofReader},
};
use zeroize::{Zeroize, ZeroizeOnDrop};
// We expect the input key to be of size 32 bytes (128-bits)
// because in sphinx this is the size of the shared key `s` between the sender and each hop.
// This shared key is then used to derive all the needed keys to encrypt the payload
pub const MASTER_KEY_LEN: usize = 32;
// For LIONESS, the length of the left part of the key (after splitting block into left `L` and right `R`)
// must be the same size as:
// - the stream cipher key
// - the output (digest) of the keyed-hash function
// this is because:
// - in the stream cipher round you xor `L` with stream cipher key
// - in the hash round, you xor `L` with hash digest
pub const LEFT_LEN: usize = 32;
// ChaCha20 expects a key size of 32 bytes.
pub const STREAM_KEY_LEN: usize = 32;
// we use hash key of size 64. Rustcrypto blake2b accepts any key size
// but will pad to 64 anyway, 32 would also work
pub const HASH_KEY_LEN: usize = 64;
// we expect the block length `m` to be big
// it need to be bigger than the `left` length or stream cipher key size (these are equal)
// the paper states that |L| = k , and that |R| = m-k , so it implies that m > k
// there are probably ways to support small blocks, but for the sphinx case this would work.
pub const MIN_BLOCK_LEN: usize = LEFT_LEN + 1;
// the size of needed key material to output from the KDF,
// we need 2 steam keys and 2 hash keys
pub const KEY_MATERIAL_LEN: usize = 2 * STREAM_KEY_LEN + 2 * HASH_KEY_LEN;
// the master key supplied from user
pub type MasterKey = [u8; MASTER_KEY_LEN];
// chacha IV
const CHACHA20_IV: [u8; 12] = *b"chacha20_iv\0";
/// We need 4 keys, one for each round. Key sizes depend on if it is a cipher or hash round.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
struct RoundKeys {
k1: [u8; STREAM_KEY_LEN],
k2: [u8; HASH_KEY_LEN],
k3: [u8; STREAM_KEY_LEN],
k4: [u8; HASH_KEY_LEN],
}
/// LIONESS large-block cipher with:
/// - ChaCha20 stream cipher
/// - BLAKE2b keyed-hash (MAC) truncated to 32 bytes
/// - TurboSHAKE256 KDF for deriving sub-keys from a 32-byte "master" input key,
///
/// WARNING: integrity/authenticity is not guaranteed by the LIONESS large-block cipher
/// This is because LIONESS is not an AEAD but one can add an authentication check by
/// simply prepending the plaintext with `k` bytes of zeros
/// a safe value for `k` would be 32 bytes which is what the Sphinx paper suggests.
/// However, this prepending is not part of the code here.
#[derive(Clone, ZeroizeOnDrop)]
pub struct Lioness {
round_keys: RoundKeys,
}
impl Lioness {
/// Create a new LIONESS instance from a 32-byte user supplied "master" key.
pub fn new(master_key: &MasterKey) -> Self {
Self {
round_keys: derive_round_keys(master_key),
}
}
/// Encrypt a single wide block in place. What it does is the following:
/// - Split block `B` into left `L` and right `R` parts, `B` = `L|R` where `|L| = k` and `|R| = m-k`
/// `m` is the message/plaintext size and `k` is the size of both streamcipher key and hash output
/// - using `S` as the stream cipher, `K_i` as the round key, and `H` as the hash, apply the four rounds:
/// 1. R = R ^ S(L ^ K_1)
/// 2. L = L ^ H_{K_2}(R)
/// 3. R = R ^ S(L ^ K_3)
/// 4. L = L ^ H_{K_4}(R)
pub fn encrypt_in_place(&self, block: &mut [u8]) -> Result<()> {
if block.len() < MIN_BLOCK_LEN {
return Err(anyhow!("block must be at least {} bytes", MIN_BLOCK_LEN));
}
// B = L|R
let (left, right) = block.split_at_mut(LEFT_LEN);
// R = R ^ S(L ^ K_1)
stream_round(left, right, &self.round_keys.k1);
// L = L ^ H_{K_2}(R)
hash_round(left, right, &self.round_keys.k2)?;
// R = R ^ S(L ^ K_3)
stream_round(left, right, &self.round_keys.k3);
// L = L ^ H_{K_4}(R)
hash_round(left, right, &self.round_keys.k4)?;
Ok(())
}
/// Decrypt a single wide block in place.
/// Same as encryption but with the four steps flipped so from 4 -> 1
pub fn decrypt_in_place(&self, block: &mut [u8]) -> Result<()> {
if block.len() < MIN_BLOCK_LEN {
return Err(anyhow!("blocks must be at least {} bytes", MIN_BLOCK_LEN));
}
// B = L|R
let (left, right) = block.split_at_mut(LEFT_LEN);
// L = L ^ H_{K_4}(R)
hash_round(left, right, &self.round_keys.k4)?;
// R = R ^ S(L ^ K_3)
stream_round(left, right, &self.round_keys.k3);
// L = L ^ H_{K_2}(R)
hash_round(left, right, &self.round_keys.k2)?;
// R = R ^ S(L ^ K_1)
stream_round(left, right, &self.round_keys.k1);
Ok(())
}
}
/// derive all 4 keys from the master key using the KDF i.e. turboshake in here.
fn derive_round_keys(master_key: &MasterKey) -> RoundKeys {
// WARNING: this uses the default domain separation 0x1f
let mut kdf = Shake128::default();
kdf.update(master_key);
let mut reader = kdf.finalize_xof();
let mut material = [0u8; KEY_MATERIAL_LEN];
reader.read(&mut material);
let mut k1 = [0u8; STREAM_KEY_LEN];
let mut k2 = [0u8; HASH_KEY_LEN];
let mut k3 = [0u8; STREAM_KEY_LEN];
let mut k4 = [0u8; HASH_KEY_LEN];
k1.copy_from_slice(&material[..STREAM_KEY_LEN]);
k2.copy_from_slice(&material[STREAM_KEY_LEN..STREAM_KEY_LEN + HASH_KEY_LEN]);
k3.copy_from_slice(&material[STREAM_KEY_LEN + HASH_KEY_LEN..2 * STREAM_KEY_LEN + HASH_KEY_LEN]);
k4.copy_from_slice(&material[2 * STREAM_KEY_LEN + HASH_KEY_LEN..]);
material.zeroize();
RoundKeys { k1, k2, k3, k4 }
}
/// apply the steam cipher round
/// R = R ^ S(L ^ K_i)
fn stream_round(left: &[u8], right: &mut [u8], subkey: &[u8; STREAM_KEY_LEN]) {
let mut round_key = [0u8; STREAM_KEY_LEN];
// K = L ^ K_i
xor(left, subkey, &mut round_key);
// C = S(L ^ K)
let mut cipher = ChaCha20::new(&round_key.into(), &CHACHA20_IV.into());
// R = R ^ C
cipher.apply_keystream(right);
round_key.zeroize();
}
/// apply the hash round
/// L = L ^ H_{K_i}(R)
fn hash_round(left: &mut [u8], right: &[u8], subkey: &[u8; HASH_KEY_LEN]) -> Result<()> {
let mut h = <Blake2bMac<U32> as BlakeKeyInit>::new_from_slice(subkey)?;
Mac::update(&mut h, right);
let mut digest = [0u8; LEFT_LEN];
// D = H_{K_i}(R)
digest.copy_from_slice(&h.finalize().into_bytes());
// L = L ^ D
xor_in_place(left, &digest);
digest.zeroize();
Ok(())
}
fn xor(left: &[u8], right: &[u8], out: &mut [u8]) {
assert_eq!(left.len(), right.len());
assert_eq!(left.len(), out.len());
for ((dst, lhs), rhs) in out.iter_mut().zip(left.iter()).zip(right.iter()) {
*dst = *lhs ^ *rhs;
}
}
fn xor_in_place(bufer: &mut [u8], mask: &[u8]) {
assert_eq!(bufer.len(), mask.len());
for (dest, src) in bufer.iter_mut().zip(mask.iter()) {
*dest ^= *src;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_key() -> MasterKey {
let mut key = [0u8; MASTER_KEY_LEN];
for (i, byte) in key.iter_mut().enumerate() {
*byte = i as u8;
}
key
}
#[test]
fn rejects_short_blocks() {
let cipher = Lioness::new(&get_test_key());
let mut block = [0u8; LEFT_LEN];
assert!(cipher.encrypt_in_place(&mut block).is_err());
}
#[test]
fn enc_dec_round_trip() {
let cipher = Lioness::new(&get_test_key());
let mut block = vec![0x84u8; 4096];
let original = block.clone();
cipher.encrypt_in_place(&mut block).unwrap();
assert_ne!(block, original);
cipher.decrypt_in_place(&mut block).unwrap();
assert_eq!(block, original);
}
#[test]
fn same_input_same_key() {
let key = get_test_key();
let cipher_a = Lioness::new(&key);
let cipher_b = Lioness::new(&key);
let mut outa = vec![0x42u8; 512];
let mut outb = outa.clone();
cipher_a.encrypt_in_place(&mut outa).unwrap();
cipher_b.encrypt_in_place(&mut outb).unwrap();
assert_eq!(outa, outb);
}
}