Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Salsa20 Encryption in CASC

Salsa20 is the primary stream cipher used for encrypting sensitive content in CASC archives. It provides fast, secure encryption for game assets while maintaining streaming capabilities.

Overview

CASC uses Salsa20 with 128-bit (16-byte) keys and the tau (“expand 16-byte k”) constants. Each encrypted BLTE block specifies a 64-bit key name for key store lookup and a 4-byte IV that is extended to 8 bytes by zero-padding.

Algorithm Details

Salsa20 Core

Salsa20 is a stream cipher designed by Daniel J. Bernstein:

  • Key size: 128 bits (16 bytes) in CASC; 256 bits (32 bytes) in standard Salsa20

  • Nonce/IV size: 64 bits (8 bytes)

  • Block size: 512 bits (64 bytes)

  • Rounds: 20 (reduced variants use 8 or 12)

Core Function

#![allow(unused)]
fn main() {
fn salsa20_core(input: &[u32; 16]) -> [u32; 16] {
    let mut x = *input;

    // 20 rounds (10 double-rounds)
    for _ in 0..10 {
        // Column round
        quarter_round(&mut x, 0, 4, 8, 12);
        quarter_round(&mut x, 5, 9, 13, 1);
        quarter_round(&mut x, 10, 14, 2, 6);
        quarter_round(&mut x, 15, 3, 7, 11);

        // Row round
        quarter_round(&mut x, 0, 1, 2, 3);
        quarter_round(&mut x, 5, 6, 7, 4);
        quarter_round(&mut x, 10, 11, 8, 9);
        quarter_round(&mut x, 15, 12, 13, 14);
    }

    // Add input to output
    for i in 0..16 {
        x[i] = x[i].wrapping_add(input[i]);
    }

    x
}

fn quarter_round(x: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) {
    x[b] ^= (x[a].wrapping_add(x[d])).rotate_left(7);
    x[c] ^= (x[b].wrapping_add(x[a])).rotate_left(9);
    x[d] ^= (x[c].wrapping_add(x[b])).rotate_left(13);
    x[a] ^= (x[d].wrapping_add(x[c])).rotate_left(18);
}
}

CASC Implementation

BLTE Encryption Block

In BLTE files, encrypted blocks use format:

[0x45] [key_name_size:1] [key_name:8] [iv_size:1] [iv:4] [type:1]
[encrypted_data...]

Where:

  • 0x45: ‘E’ marker for encrypted block

  • key_name: 64-bit key identifier

  • iv: Initialization vector (1-8 bytes, typically 4)

  • type: 0x53 (‘S’) for Salsa20. 0x41 (‘A’) for ARC4 in legacy CASC versions (not used in TACT 3.13.3+)

Key Lookup

CASC uses a 64-bit key name to look up the 16-byte encryption key from a key store. The agent calls a key getter callback with the key name; there is no key derivation in the encryption path.

#![allow(unused)]
fn main() {
struct CASCKeyManager {
    keys: HashMap<u64, [u8; 16]>,  // key_name -> 16-byte key
}

impl CASCKeyManager {
    pub fn get_key(&self, key_name: u64) -> Option<[u8; 16]> {
        self.keys.get(&key_name).copied()
    }
}
}

IV Modification for Chunks

For multi-chunk BLTE files, the IV is modified per chunk:

#![allow(unused)]
fn main() {
fn modify_iv_for_chunk(base_iv: u32, chunk_index: usize) -> u32 {
    let mut iv_bytes = base_iv.to_le_bytes();

    // XOR with chunk index
    for i in 0..4 {
        iv_bytes[i] ^= ((chunk_index >> (i * 8)) & 0xFF) as u8;
    }

    u32::from_le_bytes(iv_bytes)
}
}

Salsa20 State Setup

State Initialization

#![allow(unused)]
fn main() {
struct Salsa20State {
    state: [u32; 16],
    counter: u64,
}

impl Salsa20State {
    pub fn new(key: &[u8; 16], nonce: &[u8; 8]) -> Self {
        let mut state = [0u32; 16];

        // Tau constants "expand 16-byte k" (CASC uses 16-byte keys)
        state[0]  = 0x61707865; // "expa"
        state[5]  = 0x3120646e; // "nd 1"
        state[10] = 0x79622d36; // "6-by"
        state[15] = 0x6b206574; // "te k"

        // 16-byte key placed at positions 1-4 and duplicated at 11-14
        for i in 0..4 {
            let word = u32::from_le_bytes([
                key[i * 4],
                key[i * 4 + 1],
                key[i * 4 + 2],
                key[i * 4 + 3],
            ]);
            state[1 + i] = word;
            state[11 + i] = word;  // Duplicate for 16-byte key mode
        }

        // Counter (initially 0)
        state[8] = 0;
        state[9] = 0;

        // Nonce
        state[6] = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]);
        state[7] = u32::from_le_bytes([nonce[4], nonce[5], nonce[6], nonce[7]]);

        Salsa20State { state, counter: 0 }
    }
}
}

Encryption/Decryption

Stream Generation

#![allow(unused)]
fn main() {
impl Salsa20State {
    pub fn generate_keystream(&mut self, output: &mut [u8]) {
        let mut pos = 0;

        while pos < output.len() {
            // Generate next block
            let block = salsa20_core(&self.state);

            // Convert to bytes
            let block_bytes = unsafe {
                std::slice::from_raw_parts(
                    block.as_ptr() as *const u8,
                    64
                )
            };

            // Copy to output
            let copy_len = std::cmp::min(64, output.len() - pos);
            output[pos..pos + copy_len]
                .copy_from_slice(&block_bytes[..copy_len]);

            // Increment counter
            self.increment_counter();
            pos += copy_len;
        }
    }

    fn increment_counter(&mut self) {
        self.counter += 1;
        self.state[8] = (self.counter & 0xFFFFFFFF) as u32;
        self.state[9] = (self.counter >> 32) as u32;
    }
}
}

Decryption Process

#![allow(unused)]
fn main() {
pub fn decrypt_salsa20(
    ciphertext: &[u8],
    key: &[u8; 32],
    nonce: &[u8; 8]
) -> Vec<u8> {
    let mut state = Salsa20State::new(key, nonce);
    let mut keystream = vec![0u8; ciphertext.len()];
    state.generate_keystream(&mut keystream);

    // XOR ciphertext with keystream
    let mut plaintext = Vec::with_capacity(ciphertext.len());
    for i in 0..ciphertext.len() {
        plaintext.push(ciphertext[i] ^ keystream[i]);
    }

    plaintext
}
}

CASC-Specific Usage

BLTE Decryption

#![allow(unused)]
fn main() {
fn decrypt_blte_chunk(
    chunk_data: &[u8],
    chunk_index: usize,
    key_manager: &CASCKeyManager
) -> Result<Vec<u8>> {
    // Parse encryption header
    let key_name_size = chunk_data[0] as usize;
    let key_name = u64::from_le_bytes(
        chunk_data[1..1 + key_name_size].try_into()?
    );

    let iv_offset = 1 + key_name_size;
    let iv_size = chunk_data[iv_offset] as usize;
    let base_iv = u32::from_le_bytes(
        chunk_data[iv_offset + 1..iv_offset + 1 + iv_size].try_into()?
    );

    let cipher_type = chunk_data[iv_offset + 1 + iv_size];

    if cipher_type != 0x53 {  // 'S' for Salsa20
        return Err("Not Salsa20 encrypted");
    }

    // Get encryption key
    let key = key_manager.get_key(key_name)
        .ok_or("Key not found")?;

    // Modify IV for chunk
    let iv = modify_iv_for_chunk(base_iv, chunk_index);
    let mut nonce = [0u8; 8];
    nonce[..4].copy_from_slice(&iv.to_le_bytes());

    // Decrypt data
    let encrypted_offset = iv_offset + 1 + iv_size + 1;
    let ciphertext = &chunk_data[encrypted_offset..];

    Ok(decrypt_salsa20(ciphertext, &key, &nonce))
}
}

Known Keys

CASC uses various encryption keys for different content:

#![allow(unused)]
fn main() {
// Example key names (actual keys not included for legal reasons)
const CINEMATIC_KEY: u64 = 0xFAC5C7F366D20C85;
const ACHIEVEMENT_KEY: u64 = 0x0123456789ABCDEF;
const PVP_KEY: u64 = 0xDEADBEEFCAFEBABE;
}

Performance Optimization

SIMD Implementation

Using SIMD for parallel processing:

#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;

unsafe fn salsa20_core_simd(input: &[u32; 16]) -> [u32; 16] {
    // Load state into SIMD registers
    let mut row0 = _mm_loadu_si128(input[0..4].as_ptr() as *const __m128i);
    let mut row1 = _mm_loadu_si128(input[4..8].as_ptr() as *const __m128i);
    let mut row2 = _mm_loadu_si128(input[8..12].as_ptr() as *const __m128i);
    let mut row3 = _mm_loadu_si128(input[12..16].as_ptr() as *const __m128i);

    // Perform rounds using SIMD operations
    // ... (implementation details)

    // Store results
    let mut output = [0u32; 16];
    _mm_storeu_si128(output[0..4].as_mut_ptr() as *mut __m128i, row0);
    _mm_storeu_si128(output[4..8].as_mut_ptr() as *mut __m128i, row1);
    _mm_storeu_si128(output[8..12].as_mut_ptr() as *mut __m128i, row2);
    _mm_storeu_si128(output[12..16].as_mut_ptr() as *mut __m128i, row3);

    output
}
}

Buffered Decryption

For large files:

#![allow(unused)]
fn main() {
struct BufferedSalsa20 {
    state: Salsa20State,
    buffer: [u8; 4096],
    buffer_pos: usize,
}

impl BufferedSalsa20 {
    pub fn decrypt_stream<R: Read, W: Write>(
        &mut self,
        input: &mut R,
        output: &mut W
    ) -> Result<()> {
        let mut cipher_buffer = [0u8; 4096];

        loop {
            let bytes_read = input.read(&mut cipher_buffer)?;
            if bytes_read == 0 {
                break;
            }

            self.state.generate_keystream(&mut self.buffer[..bytes_read]);

            for i in 0..bytes_read {
                self.buffer[i] ^= cipher_buffer[i];
            }

            output.write_all(&self.buffer[..bytes_read])?;
        }

        Ok(())
    }
}
}

Security Considerations

  1. IV Uniqueness: IVs must not be reused with the same key (CASC handles this via chunk index XOR)
  2. Side Channels: Use constant-time operations for key comparison
  3. Key Storage: CASC encryption keys are static and community-maintained; the TactKeyStore keeps them in memory with redacted debug output

Testing

Test Vectors

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    #[test]
    fn test_salsa20_encryption() {
        let key = [0u8; 32];
        let nonce = [0u8; 8];
        let plaintext = b"Hello, World!";

        let ciphertext = encrypt_salsa20(plaintext, &key, &nonce);
        let decrypted = decrypt_salsa20(&ciphertext, &key, &nonce);

        assert_eq!(plaintext, &decrypted[..]);
    }
}
}

cascette-crypto API

The cascette-crypto crate provides CASC-specific Salsa20 implementation.

Basic Decryption

#![allow(unused)]
fn main() {
use cascette_crypto::salsa20::{decrypt_salsa20, Salsa20Cipher};

// CASC uses 16-byte keys and 4-byte IVs
let key: [u8; 16] = [0x01; 16];
let iv: [u8; 4] = [0x02, 0x03, 0x04, 0x05];
let block_index = 0; // First block in BLTE file

let ciphertext = &[/* encrypted data */];
let plaintext = decrypt_salsa20(ciphertext, &key, &iv, block_index)
    .expect("decryption failed");
}

In-Place Processing

#![allow(unused)]
fn main() {
use cascette_crypto::Salsa20Cipher;

let key: [u8; 16] = [0x42; 16];
let iv: [u8; 4] = [0x11, 0x22, 0x33, 0x44];

let mut cipher = Salsa20Cipher::new(&key, &iv, 0)
    .expect("cipher creation failed");

let mut data = vec![0u8; 1024];
cipher.apply_keystream(&mut data);
}

TACT Key Management

#![allow(unused)]
fn main() {
use cascette_crypto::{TactKeyStore, TactKey};

// Create store with hardcoded WoW keys
let store = TactKeyStore::new();

// Look up key by ID
let key_id = 0xFA505078126ACB3E_u64;
if let Some(key) = store.get(key_id) {
    // Use key for decryption
    println!("Found key: {:02X?}", key);
}

// Add custom key
let mut store = TactKeyStore::empty();
let key = TactKey::from_hex(
    0x1234567890ABCDEF,
    "0123456789ABCDEF0123456789ABCDEF"
).expect("invalid key hex");
store.add(key);

// Load keys from string content (file I/O is caller's responsibility)
let csv_content = "FA505078126ACB3E,BDC51862ABED79B2DE48C8E7E66C6200";
store.load_from_csv(csv_content);

let txt_content = "FA505078126ACB3E BDC51862ABED79B2DE48C8E7E66C6200";
store.load_from_txt(txt_content);
}

Custom Storage Backends

The TactKeyProvider trait allows implementing custom key storage:

#![allow(unused)]
fn main() {
use cascette_crypto::{TactKeyProvider, TactKey, CryptoError};

// Implement for keyring, database, encrypted files, etc.
struct MyKeyStore { /* ... */ }

impl TactKeyProvider for MyKeyStore {
    fn get_key(&self, id: u64) -> Result<Option<[u8; 16]>, CryptoError> {
        // Look up key from your storage backend
        todo!()
    }

    fn add_key(&mut self, key: TactKey) -> Result<(), CryptoError> {
        // Store key in your backend
        todo!()
    }

    // ... other trait methods
}
}

ARC4 (Legacy)

#![allow(unused)]
fn main() {
use cascette_crypto::Arc4Cipher;

// ARC4 used in older BLTE encrypted blocks
let key = b"encryption_key";
let mut cipher = Arc4Cipher::new(key)
    .expect("cipher creation failed");

let encrypted = cipher.encrypt(b"plaintext");

// Decrypt requires fresh cipher instance
let mut cipher = Arc4Cipher::new(key)
    .expect("cipher creation failed");
let decrypted = cipher.decrypt(&encrypted);
}

Implementation Details

CASC-Specific Differences

The CASC Salsa20 variant differs from standard Salsa20:

AspectStandard Salsa20CASC Salsa20
Key size32 bytes16 bytes (duplicated internally)
IV/Nonce size8 bytes4 bytes (extended internally)
Constants“expand 32-byte k”“expand 16-byte k”
Block indexCounter-basedXORed with IV

Key Duplication

CASC uses 16-byte keys with the “expand 16-byte k” (tau) constants:

#![allow(unused)]
fn main() {
// Tau constants for 16-byte keys
state[0]  = 0x61707865; // "expa"
state[5]  = 0x3120646e; // "nd 1"
state[10] = 0x79622d36; // "6-by"
state[15] = 0x6b206574; // "te k"

// Key bytes 0-15 placed at positions 1-4
// Key bytes 0-15 repeated at positions 11-14
}

IV Extension

The IV modification and zero-padding algorithm is documented in the CASC Implementation section above.

Validation Status

  • Integration tests with real WoW encryption keys

  • Test suite validates against known BLTE ‘E’ mode samples

  • Zero-allocation keystream generation for performance

Note: CascLib duplicates the IV (same bug as was in cascette-rs before the fix). The correct behavior is zero-padding.

TACT Key Coverage

The cascette-crypto crate includes hardcoded TACT keys for major WoW expansions:

  • Battle for Azeroth, Shadowlands, The War Within, Classic Era

Keys are stored with redacted debug output to prevent accidental logging.

References