From f923257af65210b3a48ca9931bdd3341320b4a96 Mon Sep 17 00:00:00 2001 From: dela Date: Fri, 27 Feb 2026 08:56:04 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=AF=86=E5=99=A8=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/feistel.rs | 274 ++++++++++++++ src/gift256/interleave.rs | 6 +- src/lib.rs | 1 + src/main.rs | 101 +++-- src/solver.rs | 84 ++--- tests/integration.rs | 769 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1142 insertions(+), 94 deletions(-) create mode 100644 src/feistel.rs create mode 100644 tests/integration.rs diff --git a/.gitignore b/.gitignore index e1f6ecc..9aa2124 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target CLAUDE.md /docs +test_live.sh diff --git a/src/feistel.rs b/src/feistel.rs new file mode 100644 index 0000000..da4377f --- /dev/null +++ b/src/feistel.rs @@ -0,0 +1,274 @@ +//! Feistel byte decryptor +//! Corresponds to feistel_decrypt_next_byte @ 0x8004d4c1 +//! +//! Each byte is decrypted through: +//! 1. Feistel state update (ROL-based mixing) +//! 2. S-Box A lookup on byte_counter (4 nibbles → inv → fwd) +//! 3. S-Box B key derivation from rotated 0x8262a2877387e56c +//! 4. Combined XOR with rotated 0xc4e52cd2e80e33b7 +//! 5. Anti-tamper polynomial hash → output byte + +const KEY_CONST: u64 = 0x8262a2877387e56c; +const MIX_CONST: u64 = 0xc4e52cd2e80e33b7; +const FINAL_XOR: u32 = 0x0e80eca9; + +// ── S-Box table extraction ────────────────────────────────────────── + +const fn u64s_to_bytes(vals: [u64; 32]) -> [u8; 256] { + let mut out = [0u8; 256]; + let mut i = 0; + while i < 32 { + let v = vals[i]; + out[i * 8 ] = (v & 0xFF) as u8; + out[i * 8 + 1] = ((v >> 8) & 0xFF) as u8; + out[i * 8 + 2] = ((v >> 16) & 0xFF) as u8; + out[i * 8 + 3] = ((v >> 24) & 0xFF) as u8; + out[i * 8 + 4] = ((v >> 32) & 0xFF) as u8; + out[i * 8 + 5] = ((v >> 40) & 0xFF) as u8; + out[i * 8 + 6] = ((v >> 48) & 0xFF) as u8; + out[i * 8 + 7] = ((v >> 56) & 0xFF) as u8; + i += 1; + } + out +} + +// Table A forward (offset 0x00..0xFF, first load in decompilation) +static SBOX_A_FWD: [u8; 256] = u64s_to_bytes([ + 0x9ed15953c4cca4b6, 0x7aa6141967d5c621, 0xdc9783ea6c0e3fe7, 0x2bd2e6b13728c7b9, + 0xc55a41435e40bbeb, 0x4e6039525b008151, 0x1094755c8890642a, 0x8f8d0aae1723707c, + 0xfddf042465e5c08a, 0xbf1ecaf3275fd376, 0xf085f9302f46d8b7, 0xc820456d7fff22b4, + 0x34e05529f1963d1b, 0xde018c871de9b2f6, 0x6bcf7405ad4ccbfe, 0x79ac151af2d7c19f, + 0xe3f466913a07a561, 0xa033fc036818809d, 0x35c2b5d9fb317d9b, 0xf5ede247efa2b3e8, + 0x3bba9358be5dc9d4, 0x1f631202849289a3, 0x0678ce82ec1c0dee, 0x11b0a72d6a38db2c, + 0x9c6efa624a1669d6, 0x509ac3420f6fab95, 0xf799cd57f8567726, 0xafda4fdda84b0c3c, + 0x2ee473b825327244, 0x0b864971e18e3ea9, 0x09bc4d9813bd8ba1, 0xaa7b54d036487e08, +]); + +// Table A inverse (offset 0x100..0x1FF) +static SBOX_A_INV: [u8; 256] = u64s_to_bytes([ + 0x38565c35fe1b267e, 0x5f1123e743599f52, 0x0e087f546582a3e6, 0x37c10fc2fb7cbc45, + 0x3b16d7d1e1001e24, 0xaff9978a493a86a0, 0x5aeb9068acfdea95, 0xf410426eda04dfa8, + 0xb61283dc84755b53, 0xceefff6009c5150a, 0x92eea4fa31b44ff0, 0x48aea157bb622719, + 0x702f9e170cc388f6, 0xba4146816a7bb08b, 0x3e5d638da7d5dee3, 0x7d1a07b9b501f122, + 0xd01f7844e46d403c, 0xc76461766cbf6b5e, 0x7a96aa4ded8c21be, 0x73f8b703ec6f8e74, + 0xcb55982db1d3a966, 0x718f91ddd402add8, 0x673f139a72b89d50, 0x93d920140dd2e539, + 0xcfc04e2a79283369, 0xcaa29b2ea5f32505, 0x4afc99c451184bf2, 0x2c941d3658a6e034, + 0xcce2f7b30bbde8e9, 0x8777db8085292bc6, 0xd632c989cd474c9c, 0xb206f5c8301cab3d, +]); + +// Table B forward (second load, overwrites stack) +static SBOX_B_FWD: [u8; 256] = u64s_to_bytes([ + 0x5e63857d691096e3, 0xf5b29f30c1bce088, 0x6ca3117a3571ff48, 0x9abe3a1273b08ee6, + 0xdcdbf2e88a26900f, 0x588d282e554f9ddd, 0x566e221899549b84, 0xcab786d861cb47e7, + 0x193677c5f7dead8b, 0x31d4bafd333b9823, 0xe40a08392af36221, 0x5d42721afbe25f0e, + 0x75ecbd44ce529ee5, 0xa5ea4e04383ef4d7, 0xc60c166acc1fd540, 0xd6150bae7fd3b951, + 0x651e7cfe9703c4f6, 0x00141dee607be979, 0x6409c91ca927f1a6, 0x8f252c9ca0c86d59, + 0x6f508c02f8d217af, 0xa71b95a24d68132d, 0xc2bf4a4b5c6b6780, 0x43d057a487c7492f, + 0xed5beffacf058134, 0x3c7076660193b5ac, 0xc00d2b5aab24b8e1, 0xdf3fa832bb5391aa, + 0x37b3c307d106b1f9, 0x45f082fc89da8392, 0xa19478b4294c20eb, 0x3d7e74b6cd4146d9, +]); + +// Table B inverse (offset 0x100..0x1FF, second load) +static SBOX_B_INV: [u8; 256] = u64s_to_bytes([ + 0x10253c0ac9a9ccfa, 0xca0dd5e4675bcf42, 0x4fcb2ca86b979a49, 0x55a3b2991803929e, + 0x4b3aea77df22e90b, 0x3b1f83a77f580628, 0xfb204001da0c4182, 0x9d433e95ddb16991, + 0xd48d768af82ea600, 0x686ec25609a41d87, 0x441eb3887565cead, 0xcdf17eaae1e6af79, + 0x8027987c3108fcb6, 0x3872ded2156c52e2, 0xf7ab32b914e7ff17, 0xbd2b8b3d59a0c463, + 0xbc9f816f7196b7d0, 0xbb748f89ebe0d605, 0xdcd178378ec59bc6, 0x53a1dbbf7b84364a, + 0x12b470a2c1665e73, 0x1a13ed19c750352d, 0x93547a8557eef29c, 0x8cfe2aa5c0e3625c, + 0x3411f094aed9b84c, 0xfd48ec6d265a07ac, 0x47b5ba30d3f53360, 0x5d4586efc824164d, + 0x2f02646af4c304d8, 0x9029611bf9d7510f, 0xf346390e1c7dbee8, 0xf64ee5b03f235f21, +]); + +// ── Feistel state ─────────────────────────────────────────────────── + +struct FeistelState { + state: u32, + counter: u32, +} + +impl FeistelState { + fn new() -> Self { + Self { state: 0, counter: 0 } + } + + /// Advance Feistel state. Verified against disasm @ 0x8004d4c1 State '\0'. + /// + /// tmp = state ^ counter + /// new_state = tmp + (ROL32(counter + state, state & 0x1F) ^ ROL32(tmp, counter & 0x1F)) + /// counter += 1 + fn advance(&mut self) { + let counter = self.counter; + let state = self.state; + self.counter = counter.wrapping_add(1); + + let tmp = state ^ counter; + let rol_a = counter.wrapping_add(state).rotate_left(state & 0x1F); + let rol_b = tmp.rotate_left(counter & 0x1F); + self.state = tmp.wrapping_add(rol_a ^ rol_b); + } +} + +// ── Single-byte decrypt ───────────────────────────────────────────── + +/// Decrypt one byte from encrypted payload. +/// +/// raw_byte: the encrypted input byte +/// byte_counter: pre-increment counter (0 for first byte) +/// feistel: mutable Feistel state (advanced each call) +/// +/// Returns the decrypted byte. +fn decrypt_byte(raw_byte: u8, byte_counter: u32, feistel: &mut FeistelState) -> u8 { + // 1. Advance Feistel state + feistel.advance(); + let st = feistel.state; // new state after advance + + // 2. S-Box A lookups on byte_counter (4 byte lanes) + let b3 = SBOX_A_FWD[SBOX_A_INV[(byte_counter >> 24) as usize] as usize]; + let b2 = SBOX_A_FWD[SBOX_A_INV[((byte_counter >> 16) & 0xFF) as usize] as usize]; + let b0 = SBOX_A_FWD[SBOX_A_INV[(byte_counter & 0xFF) as usize] as usize]; + let b1 = SBOX_A_FWD[SBOX_A_INV[((byte_counter >> 8) & 0xFF) as usize] as usize]; + + // 3. S-Box B key byte from rotated KEY_CONST + let key_idx = (KEY_CONST.rotate_right(st & 0x1F) >> 56) as u8; + let key_byte = SBOX_B_FWD[SBOX_B_INV[key_idx as usize] as usize]; + + // 4. Combined shift derivation + let combined: u64 = (b0 as u64) + | ((b1 as u64) << 8) + | ((b2 as u64) << 16) + | ((b3 as u64) << 24) + | 0xacacacac_00000000u64; + // Arithmetic right shift (sign bit is 1 due to 0xac... in high bytes) + let shift = (((combined as i64) >> (raw_byte as u32 & 0x1f)) & 0x1f) as u32; + + // 5. Mix: key_byte ^ ROL64(MIX_CONST, shift)[31:0] ^ FINAL_XOR + let rotated = MIX_CONST.rotate_left(shift) as u32; + let mixed = (key_byte as u32) ^ rotated ^ FINAL_XOR; + + // 6. Polynomial hash → output byte + polynomial_mix(raw_byte as u32, byte_counter, st, mixed) +} + +// ── Polynomial anti-tamper hash ───────────────────────────────────── +// Faithfully translated from decompilation. All arithmetic is wrapping u32. + +fn polynomial_mix(raw: u32, n: u32, st: u32, mix: u32) -> u8 { + let w = |a: u32, b: u32| -> u32 { a.wrapping_mul(b) }; + let wa = |a: u32, b: u32| -> u32 { a.wrapping_add(b) }; + + // Derived values + let shl12 = n << 12; + let nshl12 = !shl12; + let g = raw | !st; // uVar16 + let h = mix | shl12; // uVar22 (reused) + let nn = !n; // uVar31 + let j = n ^ nshl12; // uVar25 (reused) + let k = (n | shl12) ^ nshl12; // uVar26 (reused) + let l = st & !raw; // uVar21 (reused) + + // Linear factors + let f7 = w(j, 0x6b00ec19_u32); + let f6 = w(k, 0x7ce3eb82_u32); + let f17 = w(raw, 0xb19d3cea_u32); // raw * (-0x4e62c316) as u32 + + let f23 = wa(wa( + w(j, 0x1a82aada_u32), + w(nshl12, 0xe6b8ed78_u32)), // nshl12 * (-0x19471288) + w(nn, 0x013b9852_u32)); + + let f15 = wa(f17, f23); + let f12 = wa(f15, w(k, 0xe441bcd4_u32)); // k * (-0x1bbe432c) + let f11 = wa(w(st, 0x1a2900ca_u32), f12); + let f13 = w(g, 0x612fbba4_u32); + // f14 == f12 (algebraically identical, verified in report §5) + let f10 = wa(wa(wa( + w(mix, 0xfec467ae_u32), // mix * (-0x13b9852) + f13), f12), + w(st, 0x4e62c316_u32)); + + let f8 = w(nshl12, 0xad1a3c4c_u32); // nshl12 * (-0x52e5c3b4) + let f9 = w(nn, 0x181b2865_u32); + let f5 = w(st, 0x2bb9fc31_u32); + let f4 = w(st, 0xd44603ce_u32); // st * (-0x2bb9fc32) + let f3 = w(g, 0x0dc3d04a_u32); + let f2 = w(mix, 0x38473458_u32); + let f1 = w(mix, 0xaf9da343_u32); // mix * (-0x50625cbd) + let fh = w(h, 0x181b2865_u32); + + // Sum of squares * 0xEA + let sq_sum = wa(wa(wa(wa(wa(wa(wa(wa(wa(wa( + w(f8, f8), w(f9, f9)), w(raw, raw)), w(f7, f7)), w(f6, f6)), + w(f5, f5)), w(f4, f4)), w(f3, f3)), w(f2, f2)), w(f1, f1)), + w(fh, fh)); + let sq_term = w(sq_sum, 0xea_u32); + + // Cross terms + let cross1 = w(w(f8, w(j, 0x1a82aada_u32)), 2); // iVar8 * j * 0x1a82aada * 2 + let cross2 = w(w(f7, f17), 2); // iVar7 * iVar17 * 2 + let cross3 = w(wa(w(wa(wa(raw, f8), f7), f9), w(raw, f8)), 0xd4); // ((raw+f8+f7)*f9 + raw*f8)*0xd4 + let cross4 = w(w(f6, f15), 2); // iVar6 * iVar15 * 2 + let cross5 = w(w(f12, f5), 2); // iVar12 * iVar5 * 2 + let cross6 = w(w(f11, f4), 2); // iVar11 * iVar4 * 2 + let cross7 = w(w(wa(w(st, 0x4e62c316_u32), f12), f3), 2); // (st*0x4e62c316 + f14) * f3 * 2 + let cross8 = w(w(f2, wa(wa(w(st, 0x4e62c316_u32), f12), f13)), 2); // f2*(st*0x4e62c316+f12+f13)*2 + let cross9 = w(w(wa(wa(wa(w(st, 0x3439c24c_u32), f11), f13), w(mix, 0x13527870_u32)), f1), 2); + let cross10 = w(w(f10, fh), 2); // iVar10 * iVar23 * 2 + let cross11 = w(w(l, wa(f10, w(h, 0x013b9852_u32))), 0x98); // l*(iVar10+h*0x13b9852)*0x98 + + // Quadratic self-term: (l * (-0x50386060) + 0x7c) * l + let l_quad = w(wa(w(l, 0xafc79fa0_u32), 0x7c), l); + + // Final assembly + let mut result: u32 = 0x455ae97d_u32; // constant base + result = wa(result, w(j, 0xa211f9f5_u32)); // j * (-0x5dee060b) + result = wa(result, w(st, 0x84a187c3_u32)); // st * (-0x7b5e783d) + result = wa(result, l_quad); + result = wa(result, w(mix, 0xa1a9cfef_u32)); // mix * (-0x5e563011) + result = wa(result, w(wa(h, nn), 0x5e563111_u32)); // (h + nn) * 0x5e563111 + result = wa(result, sq_term); + result = wa(result, w(nshl12, 0x336bfe1c_u32)); + result = wa(result, w(raw, 0x8fd1523d_u32)); // raw * (-0x702eadc3) + result = wa(result, w(k, 0x88700dfa_u32)); // k * (-0x778ff206) + result = wa(result, cross1); + result = wa(result, cross2); + result = wa(result, cross3); + result = wa(result, cross4); + result = wa(result, w(g, 0x1a7751a2_u32)); + result = wa(result, w(l, 0x3d99e8a0_u32)); + result = wa(result, cross5); + result = wa(result, cross6); + result = wa(result, cross7); + result = wa(result, cross8); + result = wa(result, cross9); + result = wa(result, cross10); + result = wa(result, cross11); + + result as u8 // low byte = decrypted output +} + +// ── Public API ────────────────────────────────────────────────────── + +/// Decrypt 33 bytes from the encrypted payload in `d`. +/// +/// Returns (key_material[32], control_byte). +/// +/// `data`: the full decoded d-field bytes (part 1, typically 203 bytes) +/// `skip`: number of bytes to skip before the 33-byte encrypted window +pub fn feistel_decrypt(data: &[u8], skip: usize) -> ([u8; 32], u8) { + let mut feistel = FeistelState::new(); + let mut key_material = [0u8; 32]; + let mut control = 0u8; + + for i in 0..33 { + let raw = data[skip + i]; + let decrypted = decrypt_byte(raw, i as u32, &mut feistel); + if i < 32 { + key_material[i] = decrypted; + } else { + control = decrypted; + } + } + + (key_material, control) +} diff --git a/src/gift256/interleave.rs b/src/gift256/interleave.rs index 8191108..dea073c 100644 --- a/src/gift256/interleave.rs +++ b/src/gift256/interleave.rs @@ -134,11 +134,13 @@ pub fn interleave_key_half(input: &[u32], output: &mut [u32]) { /// Reverse of pack_input: apply masks in reverse order (0x0F -> 0x33 -> 0x55). pub fn unpack_output(s: &[u32; 8], final_rk: &[u32; 8]) -> [u32; 8] { // XOR with final round keys first + // Mapping from pack_input output indices: + // out7=a, out3=e, out5=c, out1=g, out6=b, out2=f, out4=d, out0=h let mut a = s[7] ^ final_rk[7]; let mut b = s[6] ^ final_rk[6]; let mut c = s[5] ^ final_rk[5]; - let mut d = s[3] ^ final_rk[3]; - let mut e = s[4] ^ final_rk[4]; + let mut d = s[4] ^ final_rk[4]; // out4 = d part + let mut e = s[3] ^ final_rk[3]; // out3 = e part let mut f = s[2] ^ final_rk[2]; let mut g = s[1] ^ final_rk[1]; let mut h = s[0] ^ final_rk[0]; diff --git a/src/lib.rs b/src/lib.rs index 6ceb6d0..052ce94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod sbox; pub mod gift256; pub mod hash; pub mod solver; +pub mod feistel; diff --git a/src/main.rs b/src/main.rs index 1d17823..71c3917 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ //! hCaptcha PoW solver CLI -//! Parses JWT challenge -> calls solver -> outputs base64 nonce +//! Parses JWT challenge -> calls solver -> outputs base64 proof use base64::{engine::general_purpose::STANDARD, Engine}; use serde::Deserialize; @@ -10,6 +10,7 @@ mod gift256; mod hash; mod solver; mod util; +mod feistel; #[derive(Deserialize)] #[allow(dead_code)] @@ -54,56 +55,78 @@ fn main() { println!("Expiration: {}", payload.e); // 3. Decode challenge data from `d` field - let challenge_data = STANDARD.decode(&payload.d).unwrap_or_else(|_| { - // Try with padding adjustment - let padded = format!("{}==", payload.d.trim_end_matches('=')); - STANDARD.decode(&padded).expect("Invalid base64 in challenge data") - }); + // The `d` field is two concatenated base64 strings: + // encrypted_payload (with = padding) + seed (no padding) + // Split at the padding boundary and decode each part separately. + let challenge_data = if let Some(eq_pos) = payload.d.find('=') { + let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count(); + let part1 = &payload.d[..split_pos]; + let part2 = &payload.d[split_pos..]; - println!("Challenge data: {} bytes", challenge_data.len()); + let mut data = STANDARD.decode(part1).expect("Invalid base64 in challenge part1"); + if !part2.is_empty() { + let p2 = STANDARD.decode(part2).unwrap_or_else(|_| { + // part2 may lack padding — add it + let padded = format!("{}{}", part2, "=".repeat((4 - part2.len() % 4) % 4)); + STANDARD.decode(&padded).expect("Invalid base64 in challenge part2") + }); + data.extend_from_slice(&p2); + } + data + } else { + STANDARD.decode(&payload.d).expect("Invalid base64 in challenge data") + }; - // 4. Parse challenge (needs at least 49 bytes: 32 key + 16 target + 1 extra) - if challenge_data.len() < 49 { - eprintln!("Challenge data too short: {} bytes (need >= 49)", challenge_data.len()); + // Split challenge_data into encrypted_payload (part1) and seed_bytes (part2) + // Part1 = first decode (203 bytes), Part2 = second decode (12 bytes) + let (encrypted_payload, seed_bytes) = if let Some(eq_pos) = payload.d.find('=') { + let split_pos = eq_pos + payload.d[eq_pos..].chars().take_while(|&c| c == '=').count(); + let part1_len = STANDARD.decode(&payload.d[..split_pos]) + .expect("Invalid base64 in part1").len(); + (&challenge_data[..part1_len], &challenge_data[part1_len..]) + } else { + (challenge_data.as_slice(), &[] as &[u8]) + }; + + println!("Encrypted payload: {} bytes, Seed: {} bytes", encrypted_payload.len(), seed_bytes.len()); + + // 4. Feistel decrypt 33 bytes → 32 byte key + 1 byte control + if encrypted_payload.len() < 33 { + eprintln!("Encrypted payload too short: {} bytes (need >= 33)", encrypted_payload.len()); std::process::exit(1); } - let mut key_material = [0u8; 32]; - key_material.copy_from_slice(&challenge_data[0..32]); + let (key_material, control) = feistel::feistel_decrypt(encrypted_payload, 0); + println!("Feistel decrypted: key[0..4]={:02x}{:02x}{:02x}{:02x}, control=0x{:02x}", + key_material[0], key_material[1], key_material[2], key_material[3], control); - let mut target_hash = [0u8; 16]; - target_hash.copy_from_slice(&challenge_data[32..48]); - - let extra_byte = challenge_data[48]; + let difficulty = if payload.c > 0 { payload.c } else { 1000 }; let challenge = solver::Challenge { key_material, - target_hash, - extra_byte, + difficulty, }; - // 5. Solve - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() as u64; + // 5. Derive PCG seed from seed_bytes (part2 of d) + let seed = if seed_bytes.len() >= 8 { + u64::from_le_bytes([ + seed_bytes[0], seed_bytes[1], seed_bytes[2], seed_bytes[3], + seed_bytes[4], seed_bytes[5], seed_bytes[6], seed_bytes[7], + ]) + } else { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + }; - let max_iter = if payload.c > 0 { payload.c } else { 1_000_000 }; + println!("PCG seed: 0x{:016x}", seed); + println!("Running {} iterations...", difficulty); - println!("Solving with max {} iterations...", max_iter); + let proof = solver::solve(&challenge, seed); - let solution = solver::solve(&challenge, max_iter, seed); - - // 6. Output - match solution { - Some(sol) => { - let nonce_b64 = STANDARD.encode(sol.nonce); - println!("Found solution in {} iterations", sol.iterations); - println!("n={}", nonce_b64); - } - None => { - eprintln!("No solution found within {} iterations", max_iter); - std::process::exit(1); - } - } + // 6. Output base64-encoded proof + let proof_b64 = STANDARD.encode(&proof); + println!("Proof size: {} bytes ({} digests)", proof.len(), proof.len() / 16); + println!("n={}", proof_b64); } diff --git a/src/solver.rs b/src/solver.rs index a1bf0ae..c231b3a 100644 --- a/src/solver.rs +++ b/src/solver.rs @@ -1,97 +1,75 @@ //! Top-level PoW solver //! Corresponds to pow_main_dispatch (Yb) solve path (0xABAB270C) +//! +//! The `n` value is NOT a single nonce — it's an accumulated Vec of +//! 16-byte hash digests, one per iteration. The full output is base64-encoded +//! and typically 20-40KB (1250-2500 iterations × 16 bytes). use crate::pcg::PcgRng; use crate::sbox::apply_polynomial_sbox; use crate::gift256; use crate::hash; -/// PoW solution result -pub struct PowSolution { - pub nonce: [u8; 12], - pub iterations: u32, -} - /// Challenge data parsed from JWT `d` field pub struct Challenge { pub key_material: [u8; 32], // 32-byte key material (before S-Box) - pub target_hash: [u8; 16], // 16-byte target hash - pub extra_byte: u8, // 33rd byte + pub difficulty: u32, // iteration count (c field from JWT) } -/// Main solve function. +/// Solve the PoW challenge by accumulating digests. /// -/// Full flow: +/// Returns a Vec of concatenated 16-byte digests (one per iteration). +/// This is base64-encoded to produce the `n` value sent back to hCaptcha. +/// +/// Full per-iteration flow (0xABAB270C path): /// 1. PCG generates 12-byte nonce -/// 2. S-Box polynomial substitution (32-byte key material) -/// 3. GIFT-256 key schedule -> 480 bytes round keys -/// 4. MMO compression -/// 5. Assemble hash input: [nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000] -/// 6. GIFT-256 encrypt + hash_finalize -> 128-bit digest -/// 7. Constant-time 16-byte comparison -/// 8. Match -> return nonce; no match -> regenerate nonce -pub fn solve(challenge: &Challenge, max_iterations: u32, seed: u64) -> Option { +/// 2. S-Box polynomial substitution on key material +/// 3. GIFT-256 key schedule → 120 u32 round keys +/// 4. MMO compression → chaining value +/// 5. GIFT-256 encrypt (nonce as plaintext input) +/// 6. hash_finalize → 16-byte digest +/// 7. Append digest to output accumulator +/// 8. Loop back to step 1 with next PCG nonce +pub fn solve(challenge: &Challenge, seed: u64) -> Vec { let mut rng = PcgRng::new(seed); + let iterations = challenge.difficulty; + let mut output = Vec::with_capacity(iterations as usize * 16); - // Pre-compute key schedule and MMO (these don't depend on the nonce) + // Pre-compute key schedule and MMO (invariant across iterations) let mut key_data = challenge.key_material; apply_polynomial_sbox(&mut key_data); let round_keys = gift256::key_schedule::key_schedule(&key_data); let mmo_state = hash::mmo::mmo_compress(&round_keys); - // Initialize hash state from MMO output - // The hash state is derived from the round keys + chaining value - let base_state = [0u32; 8]; - // State initialized to zeros, will be populated by the hash process - - for iter in 0..max_iterations { + for _iter in 0..iterations { // 1. Generate 12-byte nonce let nonce = rng.generate_nonce(); - // 2. Assemble hash input block + // 2. Assemble hash input block from nonce let nonce_u32_0 = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]); let nonce_u32_1 = u32::from_le_bytes([nonce[4], nonce[5], nonce[6], nonce[7]]); let nonce_u32_2 = u32::from_le_bytes([nonce[8], nonce[9], nonce[10], nonce[11]]); - let hash_input = [nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000u32]; - - // 3. GIFT-256 encrypt + // 3. GIFT-256 encrypt (nonce words + padding as plaintext) let encrypted = gift256::encrypt::encrypt( - &[hash_input[0], hash_input[1], hash_input[2], hash_input[3], 0, 0, 0, 0], + &[nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000, 0, 0, 0, 0], &round_keys, ); - // 4. Finalize hash - // Build state from encrypted output - let mut hash_state = base_state; - for i in 0..8 { - hash_state[i] = encrypted[i]; - } + // 4. Build hash state from encrypted output + let hash_state = encrypted; + // 5. Finalize hash → 16-byte digest let digest = hash::finalize::finalize( &hash_state, &mmo_state.chaining, &nonce, ); - // 5. Compare with target - if constant_time_eq(&digest, &challenge.target_hash) { - return Some(PowSolution { - nonce, - iterations: iter + 1, - }); - } + // 6. Append 16-byte digest to output accumulator + output.extend_from_slice(&digest); } - None -} - -/// Constant-time comparison (16 bytes) -fn constant_time_eq(a: &[u8; 16], b: &[u8; 16]) -> bool { - let mut result = 0u8; - for i in 0..16 { - result |= a[i] ^ b[i]; - } - result == 0 + output } diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..020085a --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,769 @@ +//! Comprehensive test suite for the hCaptcha PoW solver +//! +//! Tests cover: +//! 1. Polynomial S-Box (all 256 entries) +//! 2. PCG-XSH-RR PRNG (known seed outputs) +//! 3. Util bit-manipulation functions +//! 4. GIFT-256 bitsliced S-Box properties +//! 5. GIFT-256 interleave round-trip +//! 6. GIFT-256 linear layer properties +//! 7. GIFT-256 key schedule structure +//! 8. GIFT-256 encrypt determinism +//! 9. Hash subsystem (GF multiply, inner_compress, finalize) +//! 10. End-to-end solver wiring + +use hcaptcha_pow::pcg::PcgRng; +use hcaptcha_pow::sbox::apply_polynomial_sbox; +use hcaptcha_pow::util::*; +use hcaptcha_pow::gift256::sbox::sbox_bitsliced; +use hcaptcha_pow::gift256::interleave::*; +use hcaptcha_pow::gift256::linear::*; +use hcaptcha_pow::gift256::key_schedule::key_schedule; +use hcaptcha_pow::gift256::encrypt::encrypt; +use hcaptcha_pow::hash::mmo::mmo_compress; +use hcaptcha_pow::hash::inner_compress::inner_compress; +use hcaptcha_pow::hash::message::process_message; +use hcaptcha_pow::hash::finalize::finalize; + +// ═══════════════════════════════════════════════════════════ +// 1. POLYNOMIAL S-BOX TESTS +// ═══════════════════════════════════════════════════════════ + +/// Full 256-entry reference table computed via Python: +/// S(x) = (192*x^6 + 224*x^5 + 120*x^4 + 200*x^3 + 150*x^2 + 65*x + 147) % 256 +const EXPECTED_SBOX: [u8; 256] = [ + 0x93, 0x4A, 0x2D, 0x0C, 0xF7, 0x3E, 0x71, 0x60, 0x1B, 0xF2, 0x75, 0x74, 0xFF, 0x66, 0x39, 0x48, + 0xA3, 0x9A, 0xBD, 0xDC, 0x07, 0x8E, 0x01, 0x30, 0x2B, 0x42, 0x05, 0x44, 0x0F, 0xB6, 0xC9, 0x18, + 0xB3, 0xEA, 0x4D, 0xAC, 0x17, 0xDE, 0x91, 0x00, 0x3B, 0x92, 0x95, 0x14, 0x1F, 0x06, 0x59, 0xE8, + 0xC3, 0x3A, 0xDD, 0x7C, 0x27, 0x2E, 0x21, 0xD0, 0x4B, 0xE2, 0x25, 0xE4, 0x2F, 0x56, 0xE9, 0xB8, + 0xD3, 0x8A, 0x6D, 0x4C, 0x37, 0x7E, 0xB1, 0xA0, 0x5B, 0x32, 0xB5, 0xB4, 0x3F, 0xA6, 0x79, 0x88, + 0xE3, 0xDA, 0xFD, 0x1C, 0x47, 0xCE, 0x41, 0x70, 0x6B, 0x82, 0x45, 0x84, 0x4F, 0xF6, 0x09, 0x58, + 0xF3, 0x2A, 0x8D, 0xEC, 0x57, 0x1E, 0xD1, 0x40, 0x7B, 0xD2, 0xD5, 0x54, 0x5F, 0x46, 0x99, 0x28, + 0x03, 0x7A, 0x1D, 0xBC, 0x67, 0x6E, 0x61, 0x10, 0x8B, 0x22, 0x65, 0x24, 0x6F, 0x96, 0x29, 0xF8, + 0x13, 0xCA, 0xAD, 0x8C, 0x77, 0xBE, 0xF1, 0xE0, 0x9B, 0x72, 0xF5, 0xF4, 0x7F, 0xE6, 0xB9, 0xC8, + 0x23, 0x1A, 0x3D, 0x5C, 0x87, 0x0E, 0x81, 0xB0, 0xAB, 0xC2, 0x85, 0xC4, 0x8F, 0x36, 0x49, 0x98, + 0x33, 0x6A, 0xCD, 0x2C, 0x97, 0x5E, 0x11, 0x80, 0xBB, 0x12, 0x15, 0x94, 0x9F, 0x86, 0xD9, 0x68, + 0x43, 0xBA, 0x5D, 0xFC, 0xA7, 0xAE, 0xA1, 0x50, 0xCB, 0x62, 0xA5, 0x64, 0xAF, 0xD6, 0x69, 0x38, + 0x53, 0x0A, 0xED, 0xCC, 0xB7, 0xFE, 0x31, 0x20, 0xDB, 0xB2, 0x35, 0x34, 0xBF, 0x26, 0xF9, 0x08, + 0x63, 0x5A, 0x7D, 0x9C, 0xC7, 0x4E, 0xC1, 0xF0, 0xEB, 0x02, 0xC5, 0x04, 0xCF, 0x76, 0x89, 0xD8, + 0x73, 0xAA, 0x0D, 0x6C, 0xD7, 0x9E, 0x51, 0xC0, 0xFB, 0x52, 0x55, 0xD4, 0xDF, 0xC6, 0x19, 0xA8, + 0x83, 0xFA, 0x9D, 0x3C, 0xE7, 0xEE, 0xE1, 0x90, 0x0B, 0xA2, 0xE5, 0xA4, 0xEF, 0x16, 0xA9, 0x78, +]; + +#[test] +fn test_sbox_all_256_entries() { + for x in 0u16..256 { + let mut buf = [x as u8; 32]; + apply_polynomial_sbox(&mut buf); + assert_eq!( + buf[0], EXPECTED_SBOX[x as usize], + "S-Box mismatch at x={}: got 0x{:02X}, expected 0x{:02X}", + x, buf[0], EXPECTED_SBOX[x as usize] + ); + // All 32 bytes should be identical since input was uniform + for i in 1..32 { + assert_eq!(buf[i], buf[0], "S-Box byte {} differs from byte 0 for input {}", i, x); + } + } +} + +#[test] +fn test_sbox_specific_values() { + // S(0) = 147 = 0x93 (constant term) + let mut buf = [0u8; 32]; + apply_polynomial_sbox(&mut buf); + assert_eq!(buf[0], 0x93); + + // S(1) = sum of all coefficients mod 256 = (192+224+120+200+150+65+147) % 256 = 74 + let mut buf = [1u8; 32]; + apply_polynomial_sbox(&mut buf); + assert_eq!(buf[0], 0x4A); + + // S(39) = 0x00 (a zero in the S-Box, found from lookup table) + let mut buf = [39u8; 32]; + apply_polynomial_sbox(&mut buf); + assert_eq!(buf[0], 0x00); +} + +#[test] +fn test_sbox_mixed_input() { + let mut buf = [0u8; 32]; + for i in 0..32 { + buf[i] = i as u8; + } + apply_polynomial_sbox(&mut buf); + for i in 0..32 { + assert_eq!(buf[i], EXPECTED_SBOX[i], "Mixed input mismatch at index {}", i); + } +} + +// ═══════════════════════════════════════════════════════════ +// 2. PCG-XSH-RR PRNG TESTS +// ═══════════════════════════════════════════════════════════ + +/// Reference outputs for seed=12345, computed from Python reference implementation +const PCG_EXPECTED_U32: [u32; 20] = [ + 0x68677495, 0xDB38677A, 0x01B8EF75, 0x0C0B2EEE, + 0xDBFB70E6, 0x92DDB8F5, 0xF84CD5BF, 0xA8C5D0DB, + 0xAE1E7AF5, 0x5CD5DB6A, 0x65971E61, 0x630A3794, + 0xF03DB558, 0xEBC6D353, 0x7C856CD9, 0x2FFCC414, + 0x27170096, 0x0044E9A0, 0xF69DE00C, 0x64A78FFD, +]; + +const PCG_EXPECTED_NONCE: [u8; 12] = [149, 122, 117, 238, 230, 245, 191, 219, 245, 106, 97, 148]; + +#[test] +fn test_pcg_nonce_seed_12345() { + let mut rng = PcgRng::new(12345); + let nonce = rng.generate_nonce(); + assert_eq!( + nonce, PCG_EXPECTED_NONCE, + "PCG nonce mismatch for seed=12345\ngot: {:?}\nexpected: {:?}", + nonce, PCG_EXPECTED_NONCE + ); +} + +#[test] +fn test_pcg_deterministic() { + // Same seed must produce same sequence + let mut rng1 = PcgRng::new(42); + let mut rng2 = PcgRng::new(42); + let n1 = rng1.generate_nonce(); + let n2 = rng2.generate_nonce(); + assert_eq!(n1, n2, "PCG is not deterministic for same seed"); +} + +#[test] +fn test_pcg_different_seeds_differ() { + let mut rng1 = PcgRng::new(0); + let mut rng2 = PcgRng::new(1); + let n1 = rng1.generate_nonce(); + let n2 = rng2.generate_nonce(); + assert_ne!(n1, n2, "Different seeds should produce different nonces"); +} + +#[test] +fn test_pcg_consecutive_nonces_differ() { + let mut rng = PcgRng::new(999); + let n1 = rng.generate_nonce(); + let n2 = rng.generate_nonce(); + assert_ne!(n1, n2, "Consecutive nonces should differ"); +} + +// ═══════════════════════════════════════════════════════════ +// 3. UTIL BIT-MANIPULATION TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_ror32() { + assert_eq!(ror32(0x80000000, 1), 0x40000000); + assert_eq!(ror32(0x00000001, 1), 0x80000000); + assert_eq!(ror32(0x12345678, 0), 0x12345678); + assert_eq!(ror32(0x12345678, 32), 0x12345678); + assert_eq!(ror32(0x12345678, 8), 0x78123456); +} + +#[test] +fn test_rol32() { + assert_eq!(rol32(0x80000000, 1), 0x00000001); + assert_eq!(rol32(0x00000001, 1), 0x00000002); + assert_eq!(rol32(0x12345678, 0), 0x12345678); + assert_eq!(rol32(0x12345678, 8), 0x34567812); +} + +#[test] +fn test_bswap32() { + assert_eq!(bswap32(0x12345678), 0x78563412); + assert_eq!(bswap32(0x00000000), 0x00000000); + assert_eq!(bswap32(0xFFFFFFFF), 0xFFFFFFFF); + assert_eq!(bswap32(0x000000FF), 0xFF000000); + // Double swap is identity + assert_eq!(bswap32(bswap32(0xDEADBEEF)), 0xDEADBEEF); +} + +#[test] +fn test_bitrev32() { + assert_eq!(bitrev32(0x00000001), 0x80000000); + assert_eq!(bitrev32(0x80000000), 0x00000001); + assert_eq!(bitrev32(0x00000000), 0x00000000); + assert_eq!(bitrev32(0xFFFFFFFF), 0xFFFFFFFF); + // Double reversal is identity + assert_eq!(bitrev32(bitrev32(0xDEADBEEF)), 0xDEADBEEF); + assert_eq!(bitrev32(bitrev32(0x12345678)), 0x12345678); +} + +#[test] +fn test_partial_bitrev_shr1_differs_from_bitrev() { + // partial_bitrev_shr1 uses mask 0x55555554 instead of 0x55555555 then >>1 + // It should NOT be equal to bitrev32(x) >> 1 for most inputs + let x = 0xDEADBEEF; + let pbr = partial_bitrev_shr1(x); + let br_shr = bitrev32(x) >> 1; + // They may or may not match depending on the input, but the operation itself is valid + // Just verify it's deterministic + assert_eq!(partial_bitrev_shr1(x), pbr); + // And not always zero + assert_ne!(partial_bitrev_shr1(0x12345678), 0); +} + +#[test] +fn test_nibble_half_swap() { + // Test identity: nibble_half_swap is an involution? Let's check. + let x = 0x12345678u32; + let swapped = nibble_half_swap(x); + // Not an identity + assert_ne!(swapped, x); + // Verify the formula: (x.rol(12) & 0x0F0F0F0F) | (x.rol(20) & 0xF0F0F0F0) + let expected = (x.rotate_left(12) & 0x0F0F0F0F) | (x.rotate_left(20) & 0xF0F0F0F0); + assert_eq!(swapped, expected); + + // Zero in, zero out + assert_eq!(nibble_half_swap(0), 0); +} + +// ═══════════════════════════════════════════════════════════ +// 4. GIFT-256 BITSLICED S-BOX TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_bitsliced_sbox_all_zeros() { + let mut s = [0u32; 8]; + sbox_bitsliced(&mut s); + // All-zero input should produce a deterministic non-zero output + // (the S-Box is not the identity) + // Just check it doesn't crash and produces something + let is_all_zero = s.iter().all(|&x| x == 0); + // S-Box of all zeros is unlikely to be all zeros + // (depends on the Boolean function, but let's at least test it runs) + let _ = is_all_zero; // may or may not be zero +} + +#[test] +fn test_bitsliced_sbox_all_ones() { + let mut s = [0xFFFFFFFFu32; 8]; + sbox_bitsliced(&mut s); + // Should produce some output without panicking + assert!(true, "S-Box on all-ones completed"); +} + +#[test] +fn test_bitsliced_sbox_deterministic() { + let input = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0, + 0xFEDCBA98, 0x76543210, 0xECA86420, 0xFDB97531]; + let mut s1 = input; + let mut s2 = input; + sbox_bitsliced(&mut s1); + sbox_bitsliced(&mut s2); + assert_eq!(s1, s2, "Bitsliced S-Box must be deterministic"); +} + +#[test] +fn test_bitsliced_sbox_not_identity() { + let input = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0, + 0xFEDCBA98, 0x76543210, 0xECA86420, 0xFDB97531]; + let mut s = input; + sbox_bitsliced(&mut s); + assert_ne!(s, input, "S-Box should not be the identity function"); +} + +#[test] +fn test_bitsliced_sbox_not_involution() { + // Applying S-Box twice should NOT return the original (it's not an involution) + let input = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let mut s = input; + sbox_bitsliced(&mut s); + let after_one = s; + sbox_bitsliced(&mut s); + // Double application likely doesn't return to original + // (this is a property test, not a guarantee for all inputs) + assert_ne!(s, input, "S-Box should not be an involution for this input"); + assert_ne!(s, after_one, "Double S-Box should differ from single"); +} + +// ═══════════════════════════════════════════════════════════ +// 5. GIFT-256 INTERLEAVE TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_nibble_deinterleave_zero() { + assert_eq!(nibble_deinterleave(0), 0); +} + +#[test] +fn test_nibble_deinterleave_all_ones() { + let result = nibble_deinterleave(0xFFFFFFFF); + // Should produce a valid result + let _ = result; +} + +#[test] +fn test_key_deinterleave_functions_zero() { + assert_eq!(key_deinterleave_a(0), 0); + assert_eq!(key_deinterleave_b(0), 0); + assert_eq!(key_deinterleave_c(0), 0); +} + +#[test] +fn test_pack_input_all_zeros() { + let input = [0u32; 8]; + let rk = [0u32; 8]; + let result = pack_input(&input, &rk); + assert_eq!(result, [0u32; 8], "Pack of zeros with zero keys should be zero"); +} + +#[test] +fn test_pack_unpack_roundtrip() { + // pack then unpack with zero keys should be a round-trip + let input = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let zero_rk = [0u32; 8]; + let packed = pack_input(&input, &zero_rk); + let unpacked = unpack_output(&packed, &zero_rk); + assert_eq!( + unpacked, input, + "Pack/unpack round-trip failed\noriginal: {:08X?}\nunpacked: {:08X?}", + input, unpacked + ); +} + +#[test] +fn test_pack_unpack_roundtrip_random() { + let input = [0xDEADBEEF, 0xCAFEBABE, 0x12345678, 0x9ABCDEF0, + 0xFEDCBA98, 0x76543210, 0xAAAAAAAA, 0x55555555]; + let zero_rk = [0u32; 8]; + let packed = pack_input(&input, &zero_rk); + let unpacked = unpack_output(&packed, &zero_rk); + assert_eq!( + unpacked, input, + "Pack/unpack round-trip with random data failed" + ); +} + +#[test] +fn test_pack_unpack_with_keys() { + let input = [0x11223344, 0x55667788, 0x99AABBCC, 0xDDEEFF00, + 0x01020304, 0x05060708, 0x090A0B0C, 0x0D0E0F10]; + let rk = [0xAAAAAAAA, 0xBBBBBBBB, 0xCCCCCCCC, 0xDDDDDDDD, + 0xEEEEEEEE, 0xFFFFFFFF, 0x11111111, 0x22222222]; + let packed = pack_input(&input, &rk); + let unpacked = unpack_output(&packed, &rk); + assert_eq!( + unpacked, input, + "Pack/unpack with non-zero keys round-trip failed" + ); +} + +// ═══════════════════════════════════════════════════════════ +// 6. GIFT-256 LINEAR LAYER TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_linear_p1_all_zeros() { + let mut s = [0u32; 8]; + linear_p1(&mut s); + assert_eq!(s, [0u32; 8], "P1 of all zeros should be all zeros"); +} + +#[test] +fn test_linear_p2_all_zeros() { + let mut s = [0u32; 8]; + linear_p2(&mut s); + assert_eq!(s, [0u32; 8], "P2 of all zeros should be all zeros"); +} + +#[test] +fn test_diffusion_a_all_zeros() { + let mut s = [0u32; 8]; + let rk = [0u32; 8]; + diffusion_a(&mut s, &rk); + assert_eq!(s, [0u32; 8], "DA of all zeros with zero keys should be all zeros"); +} + +#[test] +fn test_diffusion_b_all_zeros() { + let mut s = [0u32; 8]; + let rk = [0u32; 8]; + diffusion_b(&mut s, &rk); + assert_eq!(s, [0u32; 8], "DB of all zeros with zero keys should be all zeros"); +} + +#[test] +fn test_linear_p1_deterministic() { + let input = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0, + 0xFEDCBA98, 0x76543210, 0xECA86420, 0xFDB97531]; + let mut s1 = input; + let mut s2 = input; + linear_p1(&mut s1); + linear_p1(&mut s2); + assert_eq!(s1, s2, "P1 must be deterministic"); +} + +#[test] +fn test_linear_p2_deterministic() { + let input = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0, + 0xFEDCBA98, 0x76543210, 0xECA86420, 0xFDB97531]; + let mut s1 = input; + let mut s2 = input; + linear_p2(&mut s1); + linear_p2(&mut s2); + assert_eq!(s1, s2, "P2 must be deterministic"); +} + +#[test] +fn test_linear_p1_not_identity() { + let input = [0x12345678, 0x9ABCDEF0, 0x13579BDF, 0x2468ACE0, + 0xFEDCBA98, 0x76543210, 0xECA86420, 0xFDB97531]; + let mut s = input; + linear_p1(&mut s); + assert_ne!(s, input, "P1 should not be the identity"); +} + +// ═══════════════════════════════════════════════════════════ +// 7. GIFT-256 KEY SCHEDULE TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_key_schedule_zero_key() { + let key = [0u8; 32]; + let ks = key_schedule(&key); + // Should produce 120 u32 values without panicking + assert_eq!(ks.len(), 120); + // Zero key should still produce non-zero round keys (due to NOT compensation) + let has_nonzero = ks.iter().any(|&x| x != 0); + assert!(has_nonzero, "Zero key should produce non-zero round keys (NOT compensation)"); +} + +#[test] +fn test_key_schedule_deterministic() { + let key = [0x42u8; 32]; + let ks1 = key_schedule(&key); + let ks2 = key_schedule(&key); + assert_eq!(ks1, ks2, "Key schedule must be deterministic"); +} + +#[test] +fn test_key_schedule_different_keys_differ() { + let key1 = [0x00u8; 32]; + let key2 = [0x01u8; 32]; + let ks1 = key_schedule(&key1); + let ks2 = key_schedule(&key2); + assert_ne!(ks1, ks2, "Different keys should produce different round keys"); +} + +#[test] +fn test_key_schedule_avalanche() { + // Flipping one bit in the key should change many round key words + let mut key1 = [0u8; 32]; + let mut key2 = [0u8; 32]; + key2[0] = 0x01; // flip one bit + + let ks1 = key_schedule(&key1); + let ks2 = key_schedule(&key2); + + let diff_count = ks1.iter().zip(ks2.iter()).filter(|(&a, &b)| a != b).count(); + assert!( + diff_count > 10, + "Single bit flip should cause avalanche effect, only {} of 120 words differ", + diff_count + ); +} + +// ═══════════════════════════════════════════════════════════ +// 8. GIFT-256 ENCRYPT TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_encrypt_zero_plaintext_zero_key() { + let key = [0u8; 32]; + let rk = key_schedule(&key); + let plaintext = [0u32; 8]; + let ciphertext = encrypt(&plaintext, &rk); + // Should produce non-zero ciphertext + let is_all_zero = ciphertext.iter().all(|&x| x == 0); + assert!(!is_all_zero, "Encryption of zeros with zero key should not be all zeros"); +} + +#[test] +fn test_encrypt_deterministic() { + let key = [0xABu8; 32]; + let rk = key_schedule(&key); + let plaintext = [0x12345678, 0x9ABCDEF0, 0, 0, 0, 0, 0, 0]; + let ct1 = encrypt(&plaintext, &rk); + let ct2 = encrypt(&plaintext, &rk); + assert_eq!(ct1, ct2, "Encryption must be deterministic"); +} + +#[test] +fn test_encrypt_different_plaintexts_differ() { + let key = [0u8; 32]; + let rk = key_schedule(&key); + let pt1 = [0u32; 8]; + let mut pt2 = [0u32; 8]; + pt2[0] = 1; + let ct1 = encrypt(&pt1, &rk); + let ct2 = encrypt(&pt2, &rk); + assert_ne!(ct1, ct2, "Different plaintexts should produce different ciphertexts"); +} + +#[test] +fn test_encrypt_different_keys_differ() { + let key1 = [0u8; 32]; + let key2 = [0x01u8; 32]; + let rk1 = key_schedule(&key1); + let rk2 = key_schedule(&key2); + let pt = [0u32; 8]; + let ct1 = encrypt(&pt, &rk1); + let ct2 = encrypt(&pt, &rk2); + assert_ne!(ct1, ct2, "Different keys should produce different ciphertexts"); +} + +#[test] +fn test_encrypt_plaintext_avalanche() { + let key = [0u8; 32]; + let rk = key_schedule(&key); + let pt1 = [0u32; 8]; + let mut pt2 = [0u32; 8]; + pt2[0] = 1; // single bit flip + + let ct1 = encrypt(&pt1, &rk); + let ct2 = encrypt(&pt2, &rk); + + // Count differing bits across all 8 words + let diff_bits: u32 = ct1.iter().zip(ct2.iter()) + .map(|(&a, &b)| (a ^ b).count_ones()) + .sum(); + + // Good diffusion should flip roughly half the bits (128 out of 256) + // Allow wide range for a non-standard cipher + assert!( + diff_bits > 30, + "Single bit change should cause significant diffusion, only {} bits differ", + diff_bits + ); +} + +// ═══════════════════════════════════════════════════════════ +// 9. HASH SUBSYSTEM TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_mmo_compress_zero_key() { + let key = [0u8; 32]; + let rk = key_schedule(&key); + let state = mmo_compress(&rk); + // Chaining value should be non-zero (it's the GF(2^128) double of ciphertext extract) + let is_all_zero = state.chaining.iter().all(|&x| x == 0); + assert!(!is_all_zero, "MMO chaining of zero key should be non-zero"); +} + +#[test] +fn test_mmo_compress_deterministic() { + let key = [0xCDu8; 32]; + let rk = key_schedule(&key); + let s1 = mmo_compress(&rk); + let s2 = mmo_compress(&rk); + assert_eq!(s1.chaining, s2.chaining, "MMO compress must be deterministic"); +} + +#[test] +fn test_inner_compress_all_zeros() { + let mut state = [0u32; 8]; + let block = [0u32; 4]; + inner_compress(&mut state, &block); + // XOR of zeros into zeros, then GF multiply with zeros = zeros + // So state should remain all zeros + assert_eq!(state, [0u32; 8], "Inner compress of all zeros should be all zeros"); +} + +#[test] +fn test_inner_compress_deterministic() { + let mut s1 = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let mut s2 = s1; + let block = [0xAAAAAAAA, 0xBBBBBBBB, 0xCCCCCCCC, 0xDDDDDDDD]; + inner_compress(&mut s1, &block); + inner_compress(&mut s2, &block); + assert_eq!(s1, s2, "Inner compress must be deterministic"); +} + +#[test] +fn test_inner_compress_feistel_structure() { + // After one round, the old left half should become the new right half + let state_init = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let mut state = state_init; + let block = [0xAAAAAAAA, 0xBBBBBBBB, 0xCCCCCCCC, 0xDDDDDDDD]; + inner_compress(&mut state, &block); + + // Right half (state[4..7]) should be the old left half (state[0..3]) + assert_eq!(state[4], state_init[0], "Feistel: state[4] should be old state[0]"); + assert_eq!(state[5], state_init[1], "Feistel: state[5] should be old state[1]"); + assert_eq!(state[6], state_init[2], "Feistel: state[6] should be old state[2]"); + assert_eq!(state[7], state_init[3], "Feistel: state[7] should be old state[3]"); +} + +#[test] +fn test_process_message_empty() { + let mut state = [0u32; 8]; + process_message(&mut state, &[]); + // Empty message should not change state + assert_eq!(state, [0u32; 8], "Empty message should not change state"); +} + +#[test] +fn test_process_message_deterministic() { + let mut s1 = [0x11111111u32; 8]; + let mut s2 = [0x11111111u32; 8]; + let msg = b"Hello, hCaptcha!"; + process_message(&mut s1, msg); + process_message(&mut s2, msg); + assert_eq!(s1, s2, "Message processing must be deterministic"); +} + +#[test] +fn test_finalize_deterministic() { + let state = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let chaining = [0xAAu8; 16]; + let msg = b"test message"; + + let d1 = finalize(&state, &chaining, msg); + let d2 = finalize(&state, &chaining, msg); + assert_eq!(d1, d2, "Finalize must be deterministic"); +} + +#[test] +fn test_finalize_different_messages_differ() { + let state = [0x11111111, 0x22222222, 0x33333333, 0x44444444, + 0x55555555, 0x66666666, 0x77777777, 0x88888888]; + let chaining = [0xAAu8; 16]; + + let d1 = finalize(&state, &chaining, b"message A"); + let d2 = finalize(&state, &chaining, b"message B"); + assert_ne!(d1, d2, "Different messages should produce different digests"); +} + +#[test] +fn test_finalize_different_chaining_differ() { + let state = [0u32; 8]; + let chaining1 = [0x00u8; 16]; + let chaining2 = [0xFFu8; 16]; + let msg = b"test"; + + let d1 = finalize(&state, &chaining1, msg); + let d2 = finalize(&state, &chaining2, msg); + assert_ne!(d1, d2, "Different chaining values should produce different digests"); +} + +// ═══════════════════════════════════════════════════════════ +// 10. END-TO-END INTEGRATION TESTS +// ═══════════════════════════════════════════════════════════ + +#[test] +fn test_full_pipeline_no_panic() { + // Verify the entire pipeline runs without panicking + let key = [0x42u8; 32]; + let mut key_data = key; + apply_polynomial_sbox(&mut key_data); + + let rk = key_schedule(&key_data); + let mmo = mmo_compress(&rk); + + let mut rng = PcgRng::new(12345); + let nonce = rng.generate_nonce(); + + let nonce_u32_0 = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]); + let nonce_u32_1 = u32::from_le_bytes([nonce[4], nonce[5], nonce[6], nonce[7]]); + let nonce_u32_2 = u32::from_le_bytes([nonce[8], nonce[9], nonce[10], nonce[11]]); + + let encrypted = encrypt( + &[nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000, 0, 0, 0, 0], + &rk, + ); + + let mut hash_state = [0u32; 8]; + for i in 0..8 { + hash_state[i] = encrypted[i]; + } + + let digest = finalize(&hash_state, &mmo.chaining, &nonce); + // Digest should be 16 bytes, non-zero + assert_eq!(digest.len(), 16); + let is_all_zero = digest.iter().all(|&x| x == 0); + assert!(!is_all_zero, "Full pipeline digest should not be all zeros"); +} + +#[test] +fn test_full_pipeline_deterministic() { + // Same inputs must produce same digest + let compute = || { + let key = [0xABu8; 32]; + let mut key_data = key; + apply_polynomial_sbox(&mut key_data); + + let rk = key_schedule(&key_data); + let mmo = mmo_compress(&rk); + + let mut rng = PcgRng::new(42); + let nonce = rng.generate_nonce(); + + let nonce_u32_0 = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]); + let nonce_u32_1 = u32::from_le_bytes([nonce[4], nonce[5], nonce[6], nonce[7]]); + let nonce_u32_2 = u32::from_le_bytes([nonce[8], nonce[9], nonce[10], nonce[11]]); + + let encrypted = encrypt( + &[nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000, 0, 0, 0, 0], + &rk, + ); + + let mut hash_state = [0u32; 8]; + for i in 0..8 { + hash_state[i] = encrypted[i]; + } + + finalize(&hash_state, &mmo.chaining, &nonce) + }; + + let d1 = compute(); + let d2 = compute(); + assert_eq!(d1, d2, "Full pipeline must be deterministic"); +} + +#[test] +fn test_different_seeds_produce_different_digests() { + let key = [0xCDu8; 32]; + let mut key_data = key; + apply_polynomial_sbox(&mut key_data); + let rk = key_schedule(&key_data); + let mmo = mmo_compress(&rk); + + let compute_digest = |seed: u64| -> [u8; 16] { + let mut rng = PcgRng::new(seed); + let nonce = rng.generate_nonce(); + + let nonce_u32_0 = u32::from_le_bytes([nonce[0], nonce[1], nonce[2], nonce[3]]); + let nonce_u32_1 = u32::from_le_bytes([nonce[4], nonce[5], nonce[6], nonce[7]]); + let nonce_u32_2 = u32::from_le_bytes([nonce[8], nonce[9], nonce[10], nonce[11]]); + + let encrypted = encrypt( + &[nonce_u32_0, nonce_u32_1, nonce_u32_2, 0x01000000, 0, 0, 0, 0], + &rk, + ); + + let mut hash_state = [0u32; 8]; + for i in 0..8 { + hash_state[i] = encrypted[i]; + } + + finalize(&hash_state, &mmo.chaining, &nonce) + }; + + let d1 = compute_digest(111); + let d2 = compute_digest(222); + let d3 = compute_digest(333); + + assert_ne!(d1, d2, "Different seeds should produce different digests"); + assert_ne!(d2, d3, "Different seeds should produce different digests"); + assert_ne!(d1, d3, "Different seeds should produce different digests"); +}