Files
foxhunt/bin/fxt/src/auth/encryption.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

1031 lines
34 KiB
Rust

//! Encryption format detection and backward compatibility for token storage
//!
//! This module provides:
//! - Format detection to distinguish between hex-encoded (Wave 154) and AES-GCM encrypted (Wave 155) tokens
//! - Encryption/decryption functions using AES-256-GCM authenticated encryption
//! - Backward compatibility layer for seamless migration from hex to encrypted format
//!
//! The detection is based on a simple prefix check: encrypted data starts with "ENC:"
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose, Engine as _};
use common::{error::ErrorCategory, CommonError};
use rand::{rngs::OsRng, RngCore};
/// Encryption format for MFA secrets and tokens
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncryptionFormat {
/// Legacy format: plain hex-encoded secret
/// Example: "4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d"
HexEncoded,
/// New format: AES-GCM encrypted secret with "ENC:" prefix
/// Example: "`ENC:base64_encoded_encrypted_data`"
AesGcmEncrypted,
}
impl EncryptionFormat {
/// Detect encryption format from file content
///
/// # Logic
/// - If data starts with "ENC:", it's AES-GCM encrypted
/// - Otherwise, it's hex-encoded (legacy format)
///
/// # Arguments
/// * `data` - The raw file content (trimmed recommended)
///
/// # Returns
/// The detected encryption format
///
/// # Examples
/// ```
/// use fxt::auth::encryption::EncryptionFormat;
///
/// let hex_data = "4a5b6c7d8e9f0a1b";
/// assert_eq!(EncryptionFormat::detect(hex_data), EncryptionFormat::HexEncoded);
///
/// let encrypted_data = "ENC:abc123==";
/// assert_eq!(EncryptionFormat::detect(encrypted_data), EncryptionFormat::AesGcmEncrypted);
/// ```
pub fn detect(data: &str) -> Self {
if data.starts_with("ENC:") {
EncryptionFormat::AesGcmEncrypted
} else {
EncryptionFormat::HexEncoded
}
}
/// Check if data is encrypted (convenience method)
///
/// # Arguments
/// * `data` - The raw file content
///
/// # Returns
/// `true` if data is in encrypted format, `false` if hex-encoded
///
/// # Examples
/// ```
/// use fxt::auth::encryption::EncryptionFormat;
///
/// assert!(!EncryptionFormat::is_encrypted("4a5b6c7d"));
/// assert!(EncryptionFormat::is_encrypted("ENC:data"));
/// ```
pub fn is_encrypted(data: &str) -> bool {
data.starts_with("ENC:")
}
}
/// Encrypt a token using AES-256-GCM authenticated encryption
///
/// # Format
/// The output format is: "ENC:" + base64(nonce || ciphertext || tag)
/// - nonce: 12 bytes (96 bits) - randomly generated
/// - ciphertext: variable length (same as plaintext)
/// - tag: 16 bytes (128 bits) - authentication tag appended by GCM
///
/// # Arguments
/// * `token` - The plaintext token to encrypt
/// * `key` - The 32-byte (256-bit) AES encryption key
///
/// # Returns
/// * `Ok(String)` - Encrypted token with "ENC:" prefix and base64-encoded data
/// * `Err(CommonError)` - If key length is invalid or encryption fails
///
/// # Security
/// - Uses cryptographically secure random nonce generation (`OsRng`)
/// - GCM mode provides authenticated encryption (confidentiality + integrity)
/// - Each encryption uses a unique nonce (never reuse with same key)
///
/// # Examples
/// ```no_run
/// use fxt::auth::encryption::encrypt_token;
///
/// let key = [0u8; 32]; // 32-byte key (in production, use proper key derivation)
/// let token = "my_secret_token";
/// let encrypted = encrypt_token(token, &key).unwrap();
/// assert!(encrypted.starts_with("ENC:"));
/// ```
pub fn encrypt_token(token: &str, key: &[u8]) -> Result<String, CommonError> {
// Validate key length (AES-256 requires exactly 32 bytes)
if key.len() != 32 {
return Err(CommonError::service(
ErrorCategory::Security,
format!(
"Invalid encryption key length: expected 32 bytes, got {}",
key.len()
),
));
}
// Generate random 12-byte nonce (96 bits, recommended for GCM)
let mut nonce_bytes = [0_u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
// Create AES-256-GCM cipher
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!("Failed to create cipher: {}", e),
)
})?;
// Encrypt the token (GCM automatically appends 16-byte authentication tag)
let ciphertext = cipher.encrypt(nonce, token.as_bytes()).map_err(|e| {
CommonError::service(ErrorCategory::Security, format!("Encryption failed: {}", e))
})?;
// Combine nonce + ciphertext (ciphertext already includes the 16-byte tag)
let mut combined = Vec::with_capacity(12 + ciphertext.len());
combined.extend_from_slice(&nonce_bytes);
combined.extend_from_slice(&ciphertext);
// Encode as base64 with "ENC:" prefix
let encoded = general_purpose::STANDARD.encode(&combined);
Ok(format!("ENC:{}", encoded))
}
/// Decrypt a token using AES-256-GCM authenticated encryption
///
/// # Format
/// The input must be: "ENC:" + base64(nonce || ciphertext || tag)
/// - nonce: 12 bytes (96 bits)
/// - ciphertext: variable length (same as original plaintext)
/// - tag: 16 bytes (128 bits) - authentication tag verified by GCM
///
/// # Arguments
/// * `encrypted` - The encrypted token (must start with "ENC:")
/// * `key` - The 32-byte (256-bit) AES decryption key
///
/// # Returns
/// * `Ok(String)` - Decrypted plaintext token
/// * `Err(CommonError)` - If prefix missing, key invalid, data corrupted, or authentication fails
///
/// # Security
/// - GCM automatically verifies authentication tag before decryption
/// - Wrong key or tampered data will fail authentication
/// - Prevents forgery and modification attacks
///
/// # Errors
/// - Missing "ENC:" prefix
/// - Invalid key length (not 32 bytes)
/// - Base64 decode failure
/// - Data too short (< 28 bytes: 12 nonce + 16 tag minimum)
/// - Decryption failure (wrong key or corrupted data)
/// - Authentication tag verification failure
/// - UTF-8 decode failure
///
/// # Examples
/// ```no_run
/// use fxt::auth::encryption::{encrypt_token, decrypt_token};
///
/// let key = [0u8; 32];
/// let token = "my_secret_token";
/// let encrypted = encrypt_token(token, &key).unwrap();
/// let decrypted = decrypt_token(&encrypted, &key).unwrap();
/// assert_eq!(decrypted, token);
/// ```
pub fn decrypt_token(encrypted: &str, key: &[u8]) -> Result<String, CommonError> {
// Validate key length (AES-256 requires exactly 32 bytes)
if key.len() != 32 {
return Err(CommonError::service(
ErrorCategory::Security,
format!("Invalid key length: expected 32 bytes, got {}", key.len()),
));
}
// Validate "ENC:" prefix
if !encrypted.starts_with("ENC:") {
return Err(CommonError::service(
ErrorCategory::Security,
"Missing ENC: prefix".to_owned(),
));
}
// Strip "ENC:" prefix and decode base64
let base64_data = encrypted.get(4..).ok_or_else(|| {
CommonError::service(
ErrorCategory::Security,
"Encrypted data too short for ENC: prefix".to_owned(),
)
})?;
let combined = general_purpose::STANDARD.decode(base64_data).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!("Base64 decode failed: {}", e),
)
})?;
// Validate minimum length: 12 bytes (nonce) + 16 bytes (tag) = 28 bytes
if combined.len() < 28 {
return Err(CommonError::service(
ErrorCategory::Security,
format!(
"Data too short: expected at least 28 bytes, got {}",
combined.len()
),
));
}
// Extract nonce (first 12 bytes) -- length validated above (>= 28).
let nonce_bytes = combined.get(..12).ok_or_else(|| {
CommonError::service(ErrorCategory::Security, "Nonce extraction failed".to_owned())
})?;
let nonce = Nonce::from_slice(nonce_bytes);
// Extract ciphertext + tag (remaining bytes, tag is last 16 bytes included in ciphertext)
let ciphertext = combined.get(12..).ok_or_else(|| {
CommonError::service(ErrorCategory::Security, "Ciphertext extraction failed".to_owned())
})?;
// Create AES-256-GCM cipher
let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!("Cipher creation failed: {}", e),
)
})?;
// Decrypt and verify authentication tag (GCM does both automatically)
let plaintext_bytes = cipher.decrypt(nonce, ciphertext).map_err(|e| {
CommonError::service(ErrorCategory::Security, format!("Decryption failed: {}", e))
})?;
// Convert decrypted bytes to UTF-8 string
let plaintext = String::from_utf8(plaintext_bytes).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!("UTF-8 decode failed: {}", e),
)
})?;
Ok(plaintext)
}
/// Auto-read token with format detection (backward compatibility)
///
/// This function automatically detects whether the stored token is:
/// - Legacy hex-encoded format (Wave 154)
/// - New AES-GCM encrypted format (Wave 155)
///
/// And returns the plaintext token in both cases.
///
/// # Arguments
/// * `encrypted_data` - Either hex-encoded or "ENC:" prefixed encrypted data
/// * `key` - 32-byte decryption key (only used for encrypted format)
///
/// # Returns
/// * `Ok(String)` - The plaintext token
/// * `Err(CommonError)` - If decoding/decryption fails
///
/// # Migration Strategy
/// This function enables seamless migration:
/// - Read: Supports both hex (old) and encrypted (new) formats
/// - Write: Use `write_token_encrypted()` to always write encrypted format
/// - Result: Automatic migration on first token refresh
///
/// # Examples
/// ```no_run
/// use fxt::auth::encryption::read_token_auto;
///
/// let key = [0u8; 32];
///
/// // Read legacy hex-encoded token (Wave 154)
/// let hex_token = "6d795f746f6b656e"; // "my_token" in hex
/// let plaintext = read_token_auto(hex_token, &key).unwrap();
/// assert_eq!(plaintext, "my_token");
///
/// // Read new encrypted token (Wave 155)
/// let encrypted = "ENC:base64data";
/// let plaintext = read_token_auto(encrypted, &key).unwrap();
/// ```
pub fn read_token_auto(encrypted_data: &str, key: &[u8]) -> Result<String, CommonError> {
match EncryptionFormat::detect(encrypted_data) {
EncryptionFormat::HexEncoded => {
// Legacy format: decode hex and return plaintext
let token_bytes = hex::decode(encrypted_data).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!(
"Failed to decode hex-encoded token (backward compatibility): {}",
e
),
)
})?;
String::from_utf8(token_bytes).map_err(|e| {
CommonError::service(
ErrorCategory::Security,
format!("Hex-decoded token is not valid UTF-8: {}", e),
)
})
},
EncryptionFormat::AesGcmEncrypted => {
// New format: decrypt using AES-GCM
decrypt_token(encrypted_data, key)
},
}
}
/// Always write token in encrypted format (migration helper)
///
/// This function enforces the migration strategy: all new writes use encrypted format.
/// When combined with `read_token_auto()`, this enables seamless migration:
/// 1. First read after Wave 155: `read_token_auto()` handles old hex format
/// 2. First write after Wave 155: `write_token_encrypted()` converts to encrypted format
/// 3. Subsequent operations: encrypted format throughout
///
/// # Arguments
/// * `token` - The plaintext token to encrypt and store
/// * `key` - 32-byte encryption key
///
/// # Returns
/// * `Ok(String)` - Encrypted data in format: "ENC:base64(nonce || ciphertext || tag)"
/// * `Err(CommonError)` - If encryption fails
///
/// # Examples
/// ```no_run
/// use fxt::auth::encryption::{read_token_auto, write_token_encrypted};
///
/// let key = [0u8; 32];
///
/// // Migration scenario:
/// // 1. Read old hex-encoded token
/// let hex_token = "6d795f746f6b656e"; // "my_token" in hex
/// let plaintext = read_token_auto(hex_token, &key).unwrap();
///
/// // 2. Write in new encrypted format (migration happens here)
/// let encrypted = write_token_encrypted(&plaintext, &key).unwrap();
/// assert!(encrypted.starts_with("ENC:"));
///
/// // 3. Future reads will use encrypted format
/// let plaintext2 = read_token_auto(&encrypted, &key).unwrap();
/// assert_eq!(plaintext, plaintext2);
/// ```
pub fn write_token_encrypted(token: &str, key: &[u8]) -> Result<String, CommonError> {
// Always use encrypted format for new writes
encrypt_token(token, key)
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::string_slice, clippy::unwrap_used, clippy::expect_used, clippy::shadow_unrelated, clippy::shadow_reuse, clippy::assertions_on_result_states, clippy::str_to_string)]
mod tests {
use super::*;
#[test]
fn test_detect_hex_format() {
// Typical hex-encoded MFA secret (32 chars = 16 bytes)
let hex_secret = "4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d";
assert_eq!(
EncryptionFormat::detect(hex_secret),
EncryptionFormat::HexEncoded
);
// Short hex string
let short_hex = "abc123";
assert_eq!(
EncryptionFormat::detect(short_hex),
EncryptionFormat::HexEncoded
);
// Empty string (edge case - treated as hex)
let empty = "";
assert_eq!(
EncryptionFormat::detect(empty),
EncryptionFormat::HexEncoded
);
// Hex string with whitespace (should be trimmed before detection)
let hex_with_whitespace = " 4a5b6c7d8e9f0a1b ";
assert_eq!(
EncryptionFormat::detect(hex_with_whitespace.trim()),
EncryptionFormat::HexEncoded
);
}
#[test]
fn test_detect_encrypted_format() {
// Typical encrypted format with base64 data
let encrypted = "ENC:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=";
assert_eq!(
EncryptionFormat::detect(encrypted),
EncryptionFormat::AesGcmEncrypted
);
// Minimal encrypted format (just prefix)
let minimal = "ENC:";
assert_eq!(
EncryptionFormat::detect(minimal),
EncryptionFormat::AesGcmEncrypted
);
// Encrypted format with short data
let short_encrypted = "ENC:abc";
assert_eq!(
EncryptionFormat::detect(short_encrypted),
EncryptionFormat::AesGcmEncrypted
);
// Encrypted format with whitespace after prefix
let encrypted_with_space = "ENC: data";
assert_eq!(
EncryptionFormat::detect(encrypted_with_space),
EncryptionFormat::AesGcmEncrypted
);
}
#[test]
fn test_is_encrypted() {
// Hex-encoded (not encrypted)
assert!(!EncryptionFormat::is_encrypted("4a5b6c7d8e9f0a1b"));
assert!(!EncryptionFormat::is_encrypted(""));
assert!(!EncryptionFormat::is_encrypted("random_string"));
// Encrypted format
assert!(EncryptionFormat::is_encrypted("ENC:data"));
assert!(EncryptionFormat::is_encrypted("ENC:"));
assert!(EncryptionFormat::is_encrypted(
"ENC:YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo="
));
}
#[test]
fn test_edge_cases() {
// String starting with "enc:" (lowercase) - treated as hex
let lowercase = "enc:data";
assert_eq!(
EncryptionFormat::detect(lowercase),
EncryptionFormat::HexEncoded
);
assert!(!EncryptionFormat::is_encrypted(lowercase));
// String containing "ENC:" but not at start - treated as hex
let middle = "data_ENC:something";
assert_eq!(
EncryptionFormat::detect(middle),
EncryptionFormat::HexEncoded
);
assert!(!EncryptionFormat::is_encrypted(middle));
// Mixed case prefix - treated as hex
let mixed_case = "EnC:data";
assert_eq!(
EncryptionFormat::detect(mixed_case),
EncryptionFormat::HexEncoded
);
assert!(!EncryptionFormat::is_encrypted(mixed_case));
}
#[test]
fn test_consistency_between_methods() {
// Verify detect() and is_encrypted() are consistent
let test_cases = vec![
("4a5b6c7d", false),
("ENC:data", true),
("", false),
("ENC:", true),
("enc:data", false),
("random", false),
];
for (data, expected_encrypted) in test_cases {
let detected_format = EncryptionFormat::detect(data);
let is_encrypted = EncryptionFormat::is_encrypted(data);
// Verify consistency
assert_eq!(
is_encrypted, expected_encrypted,
"is_encrypted mismatch for: {}",
data
);
assert_eq!(
detected_format,
if expected_encrypted {
EncryptionFormat::AesGcmEncrypted
} else {
EncryptionFormat::HexEncoded
},
"detect mismatch for: {}",
data
);
}
}
// Tests for encrypt_token()
#[test]
fn test_encrypt_token_success() {
// Test successful encryption with valid 32-byte key
let key = [0_u8; 32];
let token = "my_test_token_12345";
let result = encrypt_token(token, &key);
assert!(result.is_ok(), "Encryption should succeed with valid key");
let encrypted = result.unwrap();
// Verify format
assert!(
encrypted.starts_with("ENC:"),
"Encrypted token should start with 'ENC:' prefix"
);
// Verify it's detected as encrypted
assert!(
EncryptionFormat::is_encrypted(&encrypted),
"Output should be detected as encrypted format"
);
// Verify length (4 + base64_len(12 + token_len + 16))
// Base64 expands data by ~4/3
// Data size: 12 (nonce) + token.len() + 16 (tag)
let data_size = 12 + token.len() + 16;
let base64_len = data_size.div_ceil(3) * 4; // Base64 length calculation
let expected_len = 4 + base64_len; // "ENC:" + base64
assert_eq!(
encrypted.len(),
expected_len,
"Encrypted token length should match expected format"
);
}
#[test]
fn test_encrypt_token_base64_decodable() {
// Test that output is valid base64
let key = [1_u8; 32];
let token = "test_token";
let encrypted = encrypt_token(token, &key).unwrap();
// Remove "ENC:" prefix and decode base64
let base64_data = &encrypted[4..];
let decoded = general_purpose::STANDARD.decode(base64_data);
assert!(decoded.is_ok(), "Encrypted data should be valid base64");
let decoded_bytes = decoded.unwrap();
// Verify decoded length (12 nonce + token_len + 16 tag)
let expected_len = 12 + token.len() + 16;
assert_eq!(
decoded_bytes.len(),
expected_len,
"Decoded data should have correct length"
);
}
#[test]
fn test_encrypt_token_invalid_key_length() {
// Test encryption fails with wrong key length
let token = "test_token";
// Test various invalid key lengths
let invalid_keys = vec![
vec![0_u8; 16], // Too short (AES-128)
vec![0_u8; 24], // Too short (AES-192)
vec![0_u8; 31], // One byte short
vec![0_u8; 33], // One byte too long
vec![0_u8; 0], // Empty
vec![0_u8; 64], // Too long
];
for key in invalid_keys {
let result = encrypt_token(token, &key);
assert!(
result.is_err(),
"Encryption should fail with key length {}",
key.len()
);
// Verify error message mentions key length
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert!(
err_msg.contains("key length") || err_msg.contains("32 bytes"),
"Error should mention key length issue: {}",
err_msg
);
}
}
#[test]
fn test_encrypt_token_different_outputs() {
// Test that same input produces different outputs (due to random nonce)
let key = [2_u8; 32];
let token = "same_token";
let encrypted1 = encrypt_token(token, &key).unwrap();
let encrypted2 = encrypt_token(token, &key).unwrap();
// Both should have "ENC:" prefix
assert!(encrypted1.starts_with("ENC:"));
assert!(encrypted2.starts_with("ENC:"));
// But the encrypted data should be different (different nonces)
assert_ne!(
encrypted1, encrypted2,
"Multiple encryptions of same data should produce different outputs"
);
}
#[test]
fn test_encrypt_token_empty_string() {
// Test encrypting empty string (edge case)
let key = [3_u8; 32];
let token = "";
let result = encrypt_token(token, &key);
assert!(result.is_ok(), "Should be able to encrypt empty string");
let encrypted = result.unwrap();
assert!(encrypted.starts_with("ENC:"));
// Length should be: 4 + base64_len(12 + 0 + 16) = 4 + base64_len(28)
// Base64 of 28 bytes = 38 chars (28 * 4/3 rounded up to multiple of 4)
assert_eq!(encrypted.len(), 4 + 40); // "ENC:" + base64(28 bytes)
}
#[test]
fn test_encrypt_token_long_string() {
// Test encrypting long string
let key = [4_u8; 32];
let token = "a".repeat(1000); // 1000-character token
let result = encrypt_token(&token, &key);
assert!(result.is_ok(), "Should be able to encrypt long strings");
let encrypted = result.unwrap();
assert!(encrypted.starts_with("ENC:"));
// Verify length calculation
let data_size = 12 + token.len() + 16; // nonce + plaintext + tag
let base64_len = data_size.div_ceil(3) * 4;
assert_eq!(encrypted.len(), 4 + base64_len);
}
#[test]
fn test_encrypt_token_special_characters() {
// Test encrypting string with special characters
let key = [5_u8; 32];
let tokens = vec![
"token-with-dashes",
"token_with_underscores",
"token.with.dots",
"token@with#special$chars%",
"token with spaces",
"token\nwith\nnewlines",
"token\twith\ttabs",
"token\u{1f680}with\u{1f600}emojis",
];
for token in tokens {
let result = encrypt_token(token, &key);
assert!(
result.is_ok(),
"Should encrypt token with special chars: {:?}",
token
);
let encrypted = result.unwrap();
assert!(encrypted.starts_with("ENC:"));
}
}
// Tests for decrypt_token()
#[test]
fn test_decrypt_token_success() {
// Test successful decryption with valid key
let key = [0_u8; 32];
let token = "test_token_to_decrypt";
// Encrypt first
let encrypted = encrypt_token(token, &key).unwrap();
// Decrypt
let decrypted = decrypt_token(&encrypted, &key);
assert!(decrypted.is_ok(), "Decryption should succeed");
let decrypted_token = decrypted.unwrap();
assert_eq!(decrypted_token, token, "Decrypted should match original");
}
#[test]
fn test_decrypt_token_missing_prefix() {
// Test that decryption fails without "ENC:" prefix
let key = [0_u8; 32];
let invalid_data = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=";
let result = decrypt_token(invalid_data, &key);
assert!(result.is_err(), "Should fail without ENC: prefix");
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("ENC:") || err_msg.contains("prefix"),
"Error should mention missing prefix: {}",
err_msg
);
}
#[test]
fn test_decrypt_token_invalid_base64() {
// Test that decryption fails with invalid base64
let key = [0_u8; 32];
let invalid_data = "ENC:not_valid_base64!!!";
let result = decrypt_token(invalid_data, &key);
assert!(result.is_err(), "Should fail with invalid base64");
}
#[test]
fn test_decrypt_token_data_too_short() {
// Test that decryption fails with data too short
let key = [0_u8; 32];
// Valid base64 but data too short (less than 28 bytes)
let short_data = general_purpose::STANDARD.encode([0_u8; 10]);
let invalid_data = format!("ENC:{}", short_data);
let result = decrypt_token(&invalid_data, &key);
assert!(result.is_err(), "Should fail with data too short");
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("too short") || err_msg.contains("28 bytes"),
"Error should mention data too short: {}",
err_msg
);
}
#[test]
fn test_decrypt_token_wrong_key() {
// Test that decryption fails with wrong key
let key1 = [0_u8; 32];
let key2 = [1_u8; 32];
let token = "test_token";
// Encrypt with key1
let encrypted = encrypt_token(token, &key1).unwrap();
// Try to decrypt with key2
let result = decrypt_token(&encrypted, &key2);
assert!(
result.is_err(),
"Should fail when decrypting with wrong key"
);
}
#[test]
fn test_decrypt_token_tampered_data() {
// Test that decryption fails with tampered data
let key = [0_u8; 32];
let token = "test_token";
// Encrypt
let encrypted = encrypt_token(token, &key).unwrap();
// Tamper with multiple positions in the base64 payload to guarantee
// AES-GCM authentication failure regardless of the random nonce.
let mut chars: Vec<char> = encrypted.chars().collect();
for &pos in &[6, 12, 18, 24] {
if let Some(c) = chars.get_mut(pos) {
*c = if *c == 'A' { 'B' } else { 'A' };
}
}
let tampered: String = chars.into_iter().collect();
// Try to decrypt tampered data
let result = decrypt_token(&tampered, &key);
// Should fail (either base64 decode or authentication failure)
assert!(result.is_err(), "Should fail when decrypting tampered data");
}
// Tests for read_token_auto() (backward compatibility)
#[test]
fn test_read_token_auto_hex_format() {
// Test reading legacy hex-encoded token (Wave 154 format)
let key = [0_u8; 32];
let token = "my_token";
let hex_encoded = hex::encode(token);
let result = read_token_auto(&hex_encoded, &key);
assert!(
result.is_ok(),
"Should read hex-encoded token (backward compatibility)"
);
let plaintext = result.unwrap();
assert_eq!(plaintext, token, "Decoded token should match original");
}
#[test]
fn test_read_token_auto_encrypted_format() {
// Test reading new AES-GCM encrypted token (Wave 155 format)
let key = [0_u8; 32];
let token = "my_token";
// Encrypt using Wave 155 format
let encrypted = encrypt_token(token, &key).unwrap();
// Read using auto-detection
let result = read_token_auto(&encrypted, &key);
assert!(result.is_ok(), "Should read encrypted token");
let plaintext = result.unwrap();
assert_eq!(plaintext, token, "Decrypted token should match original");
}
#[test]
fn test_read_token_auto_format_detection() {
// Test that auto-detection works correctly for both formats
let key = [0_u8; 32];
let token = "test_token";
// Wave 154 format (hex)
let hex_encoded = hex::encode(token);
let hex_format = EncryptionFormat::detect(&hex_encoded);
assert_eq!(
hex_format,
EncryptionFormat::HexEncoded,
"Should detect hex format"
);
// Wave 155 format (encrypted)
let encrypted = encrypt_token(token, &key).unwrap();
let enc_format = EncryptionFormat::detect(&encrypted);
assert_eq!(
enc_format,
EncryptionFormat::AesGcmEncrypted,
"Should detect encrypted format"
);
}
#[test]
fn test_read_token_auto_invalid_hex() {
// Test error handling for invalid hex encoding
let key = [0_u8; 32];
let invalid_hex = "not_valid_hex!!!";
let result = read_token_auto(invalid_hex, &key);
assert!(
result.is_err(),
"Should fail with invalid hex (backward compatibility)"
);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("hex") || err_msg.contains("backward compatibility"),
"Error should mention hex decoding: {}",
err_msg
);
}
#[test]
fn test_read_token_auto_invalid_encrypted() {
// Test error handling for invalid encrypted format
let key = [0_u8; 32];
let invalid_encrypted = "ENC:not_valid_base64!!!";
let result = read_token_auto(invalid_encrypted, &key);
assert!(result.is_err(), "Should fail with invalid encrypted data");
}
// Tests for write_token_encrypted() (migration helper)
#[test]
fn test_write_token_encrypted_always_encrypted() {
// Test that write_token_encrypted() always returns encrypted format
let key = [0_u8; 32];
let token = "my_token";
let result = write_token_encrypted(token, &key);
assert!(result.is_ok(), "Should write token in encrypted format");
let encrypted = result.unwrap();
// Verify format
assert!(
encrypted.starts_with("ENC:"),
"Output should always have ENC: prefix"
);
// Verify detection
assert!(
EncryptionFormat::is_encrypted(&encrypted),
"Output should be detected as encrypted"
);
}
#[test]
fn test_write_token_encrypted_roundtrip() {
// Test that write → read roundtrip works
let key = [0_u8; 32];
let token = "roundtrip_token";
// Write (encrypt)
let encrypted = write_token_encrypted(token, &key).unwrap();
// Read (decrypt)
let decrypted = read_token_auto(&encrypted, &key).unwrap();
assert_eq!(decrypted, token, "Roundtrip should preserve token");
}
// Tests for migration scenario
#[test]
fn test_migration_scenario() {
// Test complete migration scenario: hex → auto-read → encrypted write
let key = [0_u8; 32];
let token = "migration_token";
// Step 1: Start with Wave 154 hex-encoded token
let hex_encoded = hex::encode(token);
assert!(
!EncryptionFormat::is_encrypted(&hex_encoded),
"Initial format should be hex (Wave 154)"
);
// Step 2: Read old format using auto-detection
let plaintext = read_token_auto(&hex_encoded, &key).unwrap();
assert_eq!(plaintext, token, "Should read hex format correctly");
// Step 3: Write in new encrypted format (migration happens here)
let encrypted = write_token_encrypted(&plaintext, &key).unwrap();
assert!(
encrypted.starts_with("ENC:"),
"New format should be encrypted (Wave 155)"
);
// Step 4: Future reads use encrypted format
let plaintext2 = read_token_auto(&encrypted, &key).unwrap();
assert_eq!(plaintext2, token, "Should read encrypted format correctly");
// Verify migration completed
assert_ne!(
hex_encoded, encrypted,
"Format should have changed from hex to encrypted"
);
}
#[test]
fn test_migration_multiple_tokens() {
// Test migration with multiple different tokens
let key = [0_u8; 32];
let tokens = vec![
"token1",
"token2_with_underscores",
"token-3-with-dashes",
"token.4.with.dots",
];
for token in tokens {
// Old format (hex)
let hex_encoded = hex::encode(token);
// Read old format
let plaintext = read_token_auto(&hex_encoded, &key).unwrap();
assert_eq!(plaintext, token);
// Write new format
let encrypted = write_token_encrypted(&plaintext, &key).unwrap();
assert!(encrypted.starts_with("ENC:"));
// Read new format
let plaintext2 = read_token_auto(&encrypted, &key).unwrap();
assert_eq!(plaintext2, token);
}
}
#[test]
fn test_migration_idempotent() {
// Test that migrating already-migrated tokens doesn't break
let key = [0_u8; 32];
let token = "already_migrated_token";
// Start with encrypted format (already migrated)
let encrypted1 = write_token_encrypted(token, &key).unwrap();
// Read and write again (should still work)
let plaintext = read_token_auto(&encrypted1, &key).unwrap();
let encrypted2 = write_token_encrypted(&plaintext, &key).unwrap();
// Both encrypted versions should decrypt to same token
let decrypted1 = read_token_auto(&encrypted1, &key).unwrap();
let decrypted2 = read_token_auto(&encrypted2, &key).unwrap();
assert_eq!(decrypted1, token);
assert_eq!(decrypted2, token);
// But ciphertexts will differ (different nonces)
assert_ne!(
encrypted1, encrypted2,
"Different encryptions should produce different ciphertexts"
);
}
}