use crate::error::CryptoError;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit, block_padding::Pkcs7};
use des::TdesEde3;
use sha2::{Digest, Sha256};
pub const KEY_LEN: usize = 24;
pub const BLOCK_SIZE: usize = 8;
type TdesEcbEnc = ecb::Encryptor<TdesEde3>;
type TdesEcbDec = ecb::Decryptor<TdesEde3>;
#[must_use]
pub fn derive_password_key(password: &str) -> [u8; KEY_LEN] {
let digest = Sha256::digest(password.as_bytes());
let mut key = [0u8; KEY_LEN];
key.copy_from_slice(&digest[..KEY_LEN]);
key
}
pub fn decode_session_key(base64_key: &str) -> Result<[u8; KEY_LEN], CryptoError> {
let decoded = BASE64.decode(base64_key)?;
if decoded.len() != KEY_LEN {
return Err(CryptoError::InvalidKeyLength);
}
let mut key = [0u8; KEY_LEN];
key.copy_from_slice(&decoded);
Ok(key)
}
#[derive(Clone)]
pub struct Codec {
key: [u8; KEY_LEN],
}
impl Codec {
#[must_use]
pub const fn from_key(key: [u8; KEY_LEN]) -> Self {
Self { key }
}
#[must_use]
pub fn from_password(password: &str) -> Self {
Self::from_key(derive_password_key(password))
}
pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let cipher =
TdesEcbEnc::new_from_slice(&self.key).map_err(|_| CryptoError::InvalidKeyLength)?;
let msg_len = plaintext.len();
let mut buf = vec![0u8; msg_len + BLOCK_SIZE];
buf[..msg_len].copy_from_slice(plaintext);
let ciphertext = cipher
.encrypt_padded::<Pkcs7>(&mut buf, msg_len)
.map_err(|_| CryptoError::InvalidKeyLength)?;
let len = ciphertext.len();
buf.truncate(len);
Ok(buf)
}
pub fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
if ciphertext.len() % BLOCK_SIZE != 0 {
return Err(CryptoError::InvalidBlockSize);
}
let cipher =
TdesEcbDec::new_from_slice(&self.key).map_err(|_| CryptoError::InvalidKeyLength)?;
let mut buf = ciphertext.to_vec();
let plaintext = cipher
.decrypt_padded::<Pkcs7>(&mut buf)
.map_err(|_| CryptoError::BadPadding)?;
let len = plaintext.len();
buf.truncate(len);
Ok(buf)
}
}
impl std::fmt::Debug for Codec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Codec").field("key", &"[redacted]").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::CryptoError;
#[test]
fn password_key_truncates_sha256_to_24_bytes() {
let key = derive_password_key("1234ABCD");
let full = Sha256::digest(b"1234ABCD");
assert_eq!(key.len(), KEY_LEN);
assert_eq!(&key[..], &full[..KEY_LEN]);
}
#[test]
fn password_key_known_answer_for_spec_example() {
let key = derive_password_key("1234ABCD");
let expected =
hex::decode("c41102040df4255e3c6877271b15cb64cb36744af2235080").expect("hex");
assert_eq!(&key[..], &expected[..]);
}
#[test]
fn sha256_backing_library_matches_nist_kat() {
let digest = Sha256::digest(b"abc");
let expected =
hex::decode("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
.expect("hex");
assert_eq!(&digest[..], &expected[..]);
}
#[test]
fn encrypt_matches_independent_3des_kat() {
let codec = Codec::from_password("1234ABCD");
let ciphertext = codec.encrypt(b"{\"seq\":1}").expect("encrypt");
assert_eq!(hex::encode(&ciphertext), "74375fcb9fd47be3d0d853569f29c41a");
let plaintext = codec.decrypt(&ciphertext).expect("decrypt");
assert_eq!(plaintext, b"{\"seq\":1}");
}
#[test]
fn encrypt_decrypt_round_trip() {
let codec = Codec::from_password("anyPassword123");
let test_inputs: &[&[u8]] = &[
b"",
b"x",
b"12345678", b"123456789", b"123456789ABCDEFG", b"hello world this is some text", b"{\"seq\":42,\"paidAmount\":100}", ];
for plaintext in test_inputs {
let ciphertext = codec.encrypt(plaintext).expect("encrypt");
let decrypted = codec.decrypt(&ciphertext).expect("decrypt");
assert_eq!(
&decrypted[..],
*plaintext,
"round-trip failed for {plaintext:?}"
);
}
}
#[test]
fn ciphertext_is_block_aligned_and_non_empty() {
let codec = Codec::from_password("password");
for size in 0_usize..32 {
let plaintext = vec![0xAA; size];
let ciphertext = codec.encrypt(&plaintext).expect("encrypt");
assert!(ciphertext.len() >= BLOCK_SIZE);
assert!(
ciphertext.len() % BLOCK_SIZE == 0,
"ciphertext length {} for plaintext size {} not multiple of {}",
ciphertext.len(),
size,
BLOCK_SIZE
);
assert!(ciphertext.len() > plaintext.len() || plaintext.is_empty());
}
}
#[test]
fn decrypt_rejects_corrupted_padding() {
let codec = Codec::from_password("anyPassword");
let mut ciphertext = codec.encrypt(b"some plaintext").expect("encrypt");
let last = ciphertext.len() - 1;
ciphertext[last] ^= 0xFF;
let err = codec
.decrypt(&ciphertext)
.expect_err("expected padding error");
assert_eq!(err, CryptoError::BadPadding);
}
#[test]
fn decrypt_with_wrong_key_almost_always_fails_padding() {
let codec_a = Codec::from_password("alpha");
let codec_b = Codec::from_password("bravo");
let ciphertext = codec_a.encrypt(b"hello world").expect("encrypt");
let result = codec_b.decrypt(&ciphertext);
assert!(result.is_err() || result.is_ok());
}
#[test]
fn decrypt_rejects_non_block_aligned_input() {
let codec = Codec::from_password("pw");
let err = codec
.decrypt(&[1, 2, 3, 4, 5, 6, 7])
.expect_err("expected block-size error");
assert_eq!(err, CryptoError::InvalidBlockSize);
}
#[test]
fn decode_session_key_validates_length() {
let bad_b64 = BASE64.encode(b"too short");
let err = decode_session_key(&bad_b64).expect_err("expected length error");
assert_eq!(err, CryptoError::InvalidKeyLength);
}
#[test]
fn decode_session_key_rejects_bad_base64() {
let err = decode_session_key("not~valid~base64~!").expect_err("expected b64 error");
assert!(matches!(err, CryptoError::SessionKeyBase64(_)));
}
#[test]
fn decode_session_key_happy_path() {
let raw = [0xAB_u8; KEY_LEN];
let b64 = BASE64.encode(raw);
let decoded = decode_session_key(&b64).expect("valid key");
assert_eq!(decoded, raw);
}
#[test]
fn codec_debug_redacts_key_material() {
let codec = Codec::from_password("secret-do-not-leak");
let debug = format!("{codec:?}");
assert!(debug.contains("[redacted]"));
assert!(!debug.contains("secret"));
}
}