|
| 1 | +//! Legacy 3DES-EDE2 transition — verify-during-migration, not a forward path. |
| 2 | +//! |
| 3 | +//! Some legacy corpora (classic .NET line-of-business apps) store secrets that |
| 4 | +//! `TripleDESCryptoServiceProvider` produced with `PasswordDeriveBytes` + |
| 5 | +//! `CryptDeriveKey`. To migrate such a corpus onto the forward suite |
| 6 | +//! ([`crate::password`] / [`crate::kdf`]) you must first *read* the old value |
| 7 | +//! once — decrypt it, then immediately re-protect it forward and discard the |
| 8 | +//! legacy path. This module is that one-way door. |
| 9 | +//! |
| 10 | +//! ## The algorithm (agnostic — carries no secrets) |
| 11 | +//! |
| 12 | +//! The .NET construction all such corpora share: |
| 13 | +//! |
| 14 | +//! - **Key derivation** — `PasswordDeriveBytes(password, emptySalt)` then |
| 15 | +//! `CryptDeriveKey("RC2","MD5",128,IV)`: `state = MD5(password)`, folded |
| 16 | +//! `iterations-1` times, one final `MD5(state)`; the first 16 bytes are the |
| 17 | +//! RC2-128 key, which `des.Key = <16 bytes>` interprets as TripleDES-EDE2 |
| 18 | +//! (K3 = K1). See [`derive_tdes_key_pbkdf1_md5`]. |
| 19 | +//! - **Cipher** — 3DES-EDE2, CBC, zero IV (`des.IV = new byte[8]`), PKCS#7. |
| 20 | +//! - **Storage** — `prefix ‖ base64(ciphertext)`, one prefix char naming a |
| 21 | +//! password-table index (base62 order: `0-9`, `a-z`, `A-Z`). |
| 22 | +//! |
| 23 | +//! **This crate bakes NO password table.** The 62-key (or however-many) |
| 24 | +//! table belongs to the consumer that owns the legacy corpus and is passed as |
| 25 | +//! a `&[&str]` argument. That is the whole reason this primitive is public and |
| 26 | +//! agnostic while the table stays private: the *algorithm* is a documented |
| 27 | +//! .NET convention; the *table* is a live secret. |
| 28 | +
|
| 29 | +use base64::Engine; |
| 30 | +use base64::engine::general_purpose::STANDARD as BASE64; |
| 31 | +use cipher::{BlockDecryptMut, BlockEncryptMut, KeyInit}; |
| 32 | +use md5::{Digest, Md5}; |
| 33 | + |
| 34 | +use crate::{AuthError, AuthResult}; |
| 35 | + |
| 36 | +/// .NET `PasswordDeriveBytes` default iteration count. |
| 37 | +pub const DEFAULT_PBKDF1_ITERATIONS: usize = 100; |
| 38 | + |
| 39 | +const MD5_LEN: usize = 16; |
| 40 | +const TDES_BLOCK: usize = 8; |
| 41 | + |
| 42 | +/// Map a single ciphertext prefix char to its password-table index using the |
| 43 | +/// base62 order .NET's helper emits: `'0'..='9'` → 0..9, `'a'..='z'` → 10..35, |
| 44 | +/// `'A'..='Z'` → 36..61. Returns `None` for any other byte. |
| 45 | +#[must_use] |
| 46 | +pub fn prefix_to_index(c: char) -> Option<usize> { |
| 47 | + match c { |
| 48 | + '0'..='9' => Some(c as usize - '0' as usize), |
| 49 | + 'a'..='z' => Some(10 + (c as usize - 'a' as usize)), |
| 50 | + 'A'..='Z' => Some(36 + (c as usize - 'A' as usize)), |
| 51 | + _ => None, |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +/// Inverse of [`prefix_to_index`] (0..=61 → the prefix char). |
| 56 | +#[must_use] |
| 57 | +pub fn index_to_prefix(idx: usize) -> Option<char> { |
| 58 | + match idx { |
| 59 | + 0..=9 => char::from_u32('0' as u32 + idx as u32), |
| 60 | + 10..=35 => char::from_u32('a' as u32 + (idx - 10) as u32), |
| 61 | + 36..=61 => char::from_u32('A' as u32 + (idx - 36) as u32), |
| 62 | + _ => None, |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +/// `.NET PasswordDeriveBytes` over MD5 with empty salt and `iterations` |
| 67 | +/// rounds, then the `CryptDeriveKey("RC2","MD5",128,…)` step, expanded to the |
| 68 | +/// 24-byte TripleDES-EDE2 key (K1‖K2‖K1). |
| 69 | +#[must_use] |
| 70 | +pub fn derive_tdes_key_pbkdf1_md5(password: &[u8], iterations: usize) -> [u8; 24] { |
| 71 | + // Step 1 — initial hash of password (empty salt appended unchanged). |
| 72 | + let mut hasher = Md5::new(); |
| 73 | + hasher.update(password); |
| 74 | + let mut state: [u8; MD5_LEN] = hasher.finalize().into(); |
| 75 | + |
| 76 | + // Steps 2..iterations-1 — fold through MD5. |
| 77 | + for _ in 1..iterations.saturating_sub(1) { |
| 78 | + let mut h = Md5::new(); |
| 79 | + h.update(state); |
| 80 | + state = h.finalize().into(); |
| 81 | + } |
| 82 | + |
| 83 | + // Output stream — first (only needed) chunk: MD5(state) → 16-byte RC2 key. |
| 84 | + let mut h = Md5::new(); |
| 85 | + h.update(state); |
| 86 | + let derived: [u8; MD5_LEN] = h.finalize().into(); |
| 87 | + |
| 88 | + let mut k = [0u8; 24]; |
| 89 | + k[..16].copy_from_slice(&derived); |
| 90 | + k[16..].copy_from_slice(&derived[..8]); // K3 = K1 (two-key EDE) |
| 91 | + k |
| 92 | +} |
| 93 | + |
| 94 | +/// Encrypt `plaintext` with a 24-byte EDE2 key: 3DES CBC, zero IV, PKCS#7. |
| 95 | +/// The inverse of [`decrypt_cbc_zero_iv`]; deterministic (zero IV), which is |
| 96 | +/// what makes round-trip self-consistency testable. |
| 97 | +#[must_use] |
| 98 | +pub fn encrypt_cbc_zero_iv(key: &[u8; 24], plaintext: &[u8]) -> Vec<u8> { |
| 99 | + let mut tdes = des::TdesEde3::new_from_slice(key).expect("TripleDES key length is 24"); |
| 100 | + let mut buf = plaintext.to_vec(); |
| 101 | + let pad = TDES_BLOCK - (buf.len() % TDES_BLOCK); |
| 102 | + buf.extend(std::iter::repeat_n(pad as u8, pad)); |
| 103 | + |
| 104 | + let mut prev_iv = [0u8; TDES_BLOCK]; |
| 105 | + for block in buf.chunks_mut(TDES_BLOCK) { |
| 106 | + for (b, iv) in block.iter_mut().zip(prev_iv.iter()) { |
| 107 | + *b ^= *iv; |
| 108 | + } |
| 109 | + let mut arr = [0u8; TDES_BLOCK]; |
| 110 | + arr.copy_from_slice(block); |
| 111 | + tdes.encrypt_block_mut((&mut arr).into()); |
| 112 | + block.copy_from_slice(&arr); |
| 113 | + prev_iv.copy_from_slice(block); |
| 114 | + } |
| 115 | + buf |
| 116 | +} |
| 117 | + |
| 118 | +/// Decrypt `ciphertext` (a multiple of the 8-byte block size) with a 24-byte |
| 119 | +/// EDE2 key: 3DES CBC, zero IV, PKCS#7 strip. Returns the raw plaintext bytes. |
| 120 | +/// |
| 121 | +/// # Errors |
| 122 | +/// [`AuthError::Legacy`] on block misalignment or PKCS#7 padding failure. |
| 123 | +pub fn decrypt_cbc_zero_iv(key: &[u8; 24], ciphertext: &[u8]) -> AuthResult<Vec<u8>> { |
| 124 | + if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(TDES_BLOCK) { |
| 125 | + return Err(AuthError::Legacy( |
| 126 | + "ciphertext is not a whole number of 3DES blocks", |
| 127 | + )); |
| 128 | + } |
| 129 | + let mut tdes = des::TdesEde3::new_from_slice(key).expect("TripleDES key length is 24"); |
| 130 | + let mut plain = Vec::with_capacity(ciphertext.len()); |
| 131 | + let mut prev_iv = [0u8; TDES_BLOCK]; |
| 132 | + for chunk in ciphertext.chunks(TDES_BLOCK) { |
| 133 | + let mut block = [0u8; TDES_BLOCK]; |
| 134 | + block.copy_from_slice(chunk); |
| 135 | + let mut decoded = block; |
| 136 | + tdes.decrypt_block_mut((&mut decoded).into()); |
| 137 | + for (b, iv) in decoded.iter_mut().zip(prev_iv.iter()) { |
| 138 | + *b ^= *iv; |
| 139 | + } |
| 140 | + plain.extend_from_slice(&decoded); |
| 141 | + prev_iv = block; |
| 142 | + } |
| 143 | + |
| 144 | + let pad = *plain |
| 145 | + .last() |
| 146 | + .ok_or(AuthError::Legacy("empty plaintext after decrypt"))? as usize; |
| 147 | + if pad == 0 || pad > TDES_BLOCK || pad > plain.len() { |
| 148 | + return Err(AuthError::Legacy("PKCS#7 pad byte out of range")); |
| 149 | + } |
| 150 | + if plain[plain.len() - pad..] |
| 151 | + .iter() |
| 152 | + .any(|&b| b as usize != pad) |
| 153 | + { |
| 154 | + return Err(AuthError::Legacy("PKCS#7 padding bytes are not uniform")); |
| 155 | + } |
| 156 | + plain.truncate(plain.len() - pad); |
| 157 | + Ok(plain) |
| 158 | +} |
| 159 | + |
| 160 | +/// Encrypt `plaintext` under `table[index]` and emit the stored form |
| 161 | +/// (`prefix ‖ base64(ciphertext)`). The inverse of [`decrypt_prefixed`]; |
| 162 | +/// exists for round-trip self-consistency and for generating vectors to diff |
| 163 | +/// against a private oracle. NOT a forward path. |
| 164 | +/// |
| 165 | +/// # Errors |
| 166 | +/// [`AuthError::Legacy`] if `index` is outside the prefix range or the table. |
| 167 | +pub fn encrypt_prefixed( |
| 168 | + plaintext: &str, |
| 169 | + index: usize, |
| 170 | + table: &[&str], |
| 171 | + iterations: usize, |
| 172 | +) -> AuthResult<String> { |
| 173 | + let prefix = index_to_prefix(index).ok_or(AuthError::Legacy("index has no prefix char"))?; |
| 174 | + let password = table |
| 175 | + .get(index) |
| 176 | + .ok_or(AuthError::Legacy("index outside password table"))?; |
| 177 | + let key = derive_tdes_key_pbkdf1_md5(password.as_bytes(), iterations); |
| 178 | + let ct = encrypt_cbc_zero_iv(&key, plaintext.as_bytes()); |
| 179 | + Ok(format!("{prefix}{}", BASE64.encode(&ct))) |
| 180 | +} |
| 181 | + |
| 182 | +/// Decrypt a stored legacy value (`prefix ‖ base64(ciphertext)`) using the |
| 183 | +/// caller-supplied password `table`. Returns `Ok(None)` for empty input. |
| 184 | +/// |
| 185 | +/// The `table` is the consumer's private secret; this crate never carries one. |
| 186 | +/// |
| 187 | +/// # Errors |
| 188 | +/// [`AuthError::Legacy`] on an unknown/out-of-range prefix, a truncated value, |
| 189 | +/// bad base64, block misalignment, or padding failure. [`AuthError::Encoding`] |
| 190 | +/// if the decrypted bytes are not valid UTF-8. |
| 191 | +pub fn decrypt_prefixed( |
| 192 | + input: &str, |
| 193 | + table: &[&str], |
| 194 | + iterations: usize, |
| 195 | +) -> AuthResult<Option<String>> { |
| 196 | + if input.is_empty() { |
| 197 | + return Ok(None); |
| 198 | + } |
| 199 | + let mut chars = input.chars(); |
| 200 | + let prefix = chars |
| 201 | + .next() |
| 202 | + .ok_or(AuthError::Legacy("empty legacy value"))?; |
| 203 | + let index = prefix_to_index(prefix).ok_or(AuthError::Legacy("unrecognised prefix char"))?; |
| 204 | + let password = table |
| 205 | + .get(index) |
| 206 | + .ok_or(AuthError::Legacy("prefix index outside password table"))?; |
| 207 | + let body: String = chars.collect(); |
| 208 | + if body.is_empty() { |
| 209 | + return Err(AuthError::Legacy("legacy value has a prefix but no body")); |
| 210 | + } |
| 211 | + |
| 212 | + let ciphertext = BASE64 |
| 213 | + .decode(body.as_bytes()) |
| 214 | + .map_err(|_| AuthError::Encoding("legacy body is not base64"))?; |
| 215 | + let key = derive_tdes_key_pbkdf1_md5(password.as_bytes(), iterations); |
| 216 | + let plain = decrypt_cbc_zero_iv(&key, &ciphertext)?; |
| 217 | + let s = String::from_utf8(plain) |
| 218 | + .map_err(|_| AuthError::Encoding("legacy plaintext is not UTF-8"))?; |
| 219 | + Ok(Some(s)) |
| 220 | +} |
| 221 | + |
| 222 | +#[cfg(test)] |
| 223 | +mod tests { |
| 224 | + use super::*; |
| 225 | + |
| 226 | + // A SYNTHETIC table — invented passwords, never a real corpus's secret. |
| 227 | + // It exists only to prove the machinery is internally coherent; byte-parity |
| 228 | + // against a real .NET oracle is a private consumer test, not a public one. |
| 229 | + const SYNTHETIC_TABLE: [&str; 62] = [ |
| 230 | + "pw00", "pw01", "pw02", "pw03", "pw04", "pw05", "pw06", "pw07", "pw08", "pw09", "pw10", |
| 231 | + "pw11", "pw12", "pw13", "pw14", "pw15", "pw16", "pw17", "pw18", "pw19", "pw20", "pw21", |
| 232 | + "pw22", "pw23", "pw24", "pw25", "pw26", "pw27", "pw28", "pw29", "pw30", "pw31", "pw32", |
| 233 | + "pw33", "pw34", "pw35", "pw36", "pw37", "pw38", "pw39", "pw40", "pw41", "pw42", "pw43", |
| 234 | + "pw44", "pw45", "pw46", "pw47", "pw48", "pw49", "pw50", "pw51", "pw52", "pw53", "pw54", |
| 235 | + "pw55", "pw56", "pw57", "pw58", "pw59", "pw60", "pw61", |
| 236 | + ]; |
| 237 | + |
| 238 | + #[test] |
| 239 | + fn prefix_index_round_trips() { |
| 240 | + for (c, i) in [ |
| 241 | + ('0', 0), |
| 242 | + ('9', 9), |
| 243 | + ('a', 10), |
| 244 | + ('z', 35), |
| 245 | + ('A', 36), |
| 246 | + ('Z', 61), |
| 247 | + ] { |
| 248 | + assert_eq!(prefix_to_index(c), Some(i)); |
| 249 | + assert_eq!(index_to_prefix(i), Some(c)); |
| 250 | + } |
| 251 | + assert_eq!(prefix_to_index('!'), None); |
| 252 | + assert_eq!(index_to_prefix(62), None); |
| 253 | + } |
| 254 | + |
| 255 | + #[test] |
| 256 | + fn encrypt_decrypt_round_trips_for_every_index() { |
| 257 | + // Proves the plumbing (PBKDF1/MD5 → 3DES-EDE2 → CBC zero-IV → PKCS#7 → |
| 258 | + // prefix) is internally coherent for a caller-supplied table. It does |
| 259 | + // NOT prove parity with any real .NET oracle — that is a private test. |
| 260 | + let samples = ["", "a", "hunter2", "café — münchen — 12345", "block-align8"]; |
| 261 | + for idx in [0usize, 1, 9, 10, 35, 36, 61] { |
| 262 | + for pt in samples { |
| 263 | + let ct = |
| 264 | + encrypt_prefixed(pt, idx, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS).unwrap(); |
| 265 | + assert_eq!(prefix_to_index(ct.chars().next().unwrap()), Some(idx)); |
| 266 | + let back = |
| 267 | + decrypt_prefixed(&ct, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS).unwrap(); |
| 268 | + assert_eq!(back.as_deref(), Some(pt), "round-trip idx={idx} pt={pt:?}"); |
| 269 | + } |
| 270 | + } |
| 271 | + } |
| 272 | + |
| 273 | + #[test] |
| 274 | + fn decrypt_empty_is_none() { |
| 275 | + assert!(matches!( |
| 276 | + decrypt_prefixed("", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS), |
| 277 | + Ok(None) |
| 278 | + )); |
| 279 | + } |
| 280 | + |
| 281 | + #[test] |
| 282 | + fn decrypt_unknown_prefix_errors() { |
| 283 | + assert!(matches!( |
| 284 | + decrypt_prefixed("!body", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS), |
| 285 | + Err(AuthError::Legacy(_)) |
| 286 | + )); |
| 287 | + } |
| 288 | + |
| 289 | + #[test] |
| 290 | + fn decrypt_prefix_only_errors() { |
| 291 | + assert!(matches!( |
| 292 | + decrypt_prefixed("0", &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS), |
| 293 | + Err(AuthError::Legacy(_)) |
| 294 | + )); |
| 295 | + } |
| 296 | + |
| 297 | + #[test] |
| 298 | + fn encrypt_out_of_range_index_errors() { |
| 299 | + assert!(matches!( |
| 300 | + encrypt_prefixed("x", 62, &SYNTHETIC_TABLE, DEFAULT_PBKDF1_ITERATIONS), |
| 301 | + Err(AuthError::Legacy(_)) |
| 302 | + )); |
| 303 | + } |
| 304 | +} |
0 commit comments