|
| 1 | +//! Golden tests for Akamai HTTP/2 fingerprinting |
| 2 | +use huginn_net_http::{extract_akamai_fingerprint, Http2Frame}; |
| 3 | +use serde::{Deserialize, Serialize}; |
| 4 | +use std::fs; |
| 5 | + |
| 6 | +#[derive(Serialize, Deserialize, Debug, Clone)] |
| 7 | +struct AkamaiTestCase { |
| 8 | + name: String, |
| 9 | + description: String, |
| 10 | + frames: Vec<FrameSnapshot>, |
| 11 | + expected_fingerprint: Option<ExpectedFingerprint>, |
| 12 | +} |
| 13 | + |
| 14 | +#[derive(Serialize, Deserialize, Debug, Clone)] |
| 15 | +struct FrameSnapshot { |
| 16 | + frame_type: u8, |
| 17 | + flags: u8, |
| 18 | + stream_id: u32, |
| 19 | + payload: Vec<u8>, |
| 20 | +} |
| 21 | + |
| 22 | +#[derive(Serialize, Deserialize, Debug, Clone)] |
| 23 | +struct ExpectedFingerprint { |
| 24 | + signature: String, |
| 25 | + hash: String, |
| 26 | + settings_count: usize, |
| 27 | + window_update: u32, |
| 28 | + priority_frames_count: usize, |
| 29 | + pseudo_headers_count: usize, |
| 30 | +} |
| 31 | + |
| 32 | +impl From<FrameSnapshot> for Http2Frame { |
| 33 | + fn from(snapshot: FrameSnapshot) -> Self { |
| 34 | + Http2Frame::new(snapshot.frame_type, snapshot.flags, snapshot.stream_id, snapshot.payload) |
| 35 | + } |
| 36 | +} |
| 37 | + |
| 38 | +fn load_test_cases() -> Vec<AkamaiTestCase> { |
| 39 | + let test_data = match fs::read_to_string("tests/snapshots/akamai_test_cases.json") { |
| 40 | + Ok(data) => data, |
| 41 | + Err(e) => panic!("Failed to read akamai_test_cases.json: {e}"), |
| 42 | + }; |
| 43 | + |
| 44 | + match serde_json::from_str(&test_data) { |
| 45 | + Ok(cases) => cases, |
| 46 | + Err(e) => panic!("Failed to parse test cases JSON: {e}"), |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +struct ActualFingerprint<'a> { |
| 51 | + signature: &'a str, |
| 52 | + hash: &'a str, |
| 53 | + settings_count: usize, |
| 54 | + window_update: u32, |
| 55 | + priority_count: usize, |
| 56 | + pseudo_headers_count: usize, |
| 57 | +} |
| 58 | + |
| 59 | +fn assert_fingerprint_matches( |
| 60 | + actual: &ActualFingerprint, |
| 61 | + expected: &ExpectedFingerprint, |
| 62 | + test_name: &str, |
| 63 | +) { |
| 64 | + assert_eq!(actual.signature, expected.signature, "[{test_name}] Signature mismatch"); |
| 65 | + assert_eq!(actual.hash, expected.hash, "[{test_name}] Hash mismatch"); |
| 66 | + assert_eq!( |
| 67 | + actual.settings_count, expected.settings_count, |
| 68 | + "[{test_name}] Settings count mismatch" |
| 69 | + ); |
| 70 | + assert_eq!( |
| 71 | + actual.window_update, expected.window_update, |
| 72 | + "[{test_name}] Window update mismatch" |
| 73 | + ); |
| 74 | + assert_eq!( |
| 75 | + actual.priority_count, expected.priority_frames_count, |
| 76 | + "[{test_name}] Priority frames count mismatch" |
| 77 | + ); |
| 78 | + assert_eq!( |
| 79 | + actual.pseudo_headers_count, expected.pseudo_headers_count, |
| 80 | + "[{test_name}] Pseudo-headers count mismatch" |
| 81 | + ); |
| 82 | +} |
| 83 | + |
| 84 | +#[test] |
| 85 | +fn test_akamai_golden_snapshots() { |
| 86 | + let test_cases = load_test_cases(); |
| 87 | + |
| 88 | + for test_case in test_cases { |
| 89 | + println!("Running Akamai golden test: {}", test_case.name); |
| 90 | + println!(" Description: {}", test_case.description); |
| 91 | + |
| 92 | + let frames: Vec<Http2Frame> = test_case.frames.into_iter().map(Http2Frame::from).collect(); |
| 93 | + |
| 94 | + let fingerprint = extract_akamai_fingerprint(&frames); |
| 95 | + |
| 96 | + match (&fingerprint, &test_case.expected_fingerprint) { |
| 97 | + (Some(actual_fp), Some(expected)) => { |
| 98 | + let actual = ActualFingerprint { |
| 99 | + signature: &actual_fp.fingerprint, |
| 100 | + hash: &actual_fp.hash, |
| 101 | + settings_count: actual_fp.settings.len(), |
| 102 | + window_update: actual_fp.window_update, |
| 103 | + priority_count: actual_fp.priority_frames.len(), |
| 104 | + pseudo_headers_count: actual_fp.pseudo_header_order.len(), |
| 105 | + }; |
| 106 | + assert_fingerprint_matches(&actual, expected, &test_case.name); |
| 107 | + } |
| 108 | + (None, None) => { /* expected */ } |
| 109 | + (Some(actual), None) => { |
| 110 | + panic!( |
| 111 | + "[{}] Expected no fingerprint, but got: {}", |
| 112 | + test_case.name, actual.fingerprint |
| 113 | + ); |
| 114 | + } |
| 115 | + (None, Some(_)) => { |
| 116 | + panic!("[{}] Expected fingerprint, but none was generated", test_case.name); |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +#[test] |
| 123 | +fn test_chrome_fingerprint() { |
| 124 | + let chrome_frames = vec![ |
| 125 | + Http2Frame::new( |
| 126 | + 0x4, // SETTINGS |
| 127 | + 0x0, |
| 128 | + 0, |
| 129 | + vec![ |
| 130 | + 0x00, 0x03, 0x00, 0x00, 0x00, 0x64, // HEADER_TABLE_SIZE = 100 |
| 131 | + 0x00, 0x04, 0x00, 0x60, 0x00, 0x00, // INITIAL_WINDOW_SIZE = 6291456 |
| 132 | + ], |
| 133 | + ), |
| 134 | + Http2Frame::new( |
| 135 | + 0x8, // WINDOW_UPDATE |
| 136 | + 0x0, |
| 137 | + 0, |
| 138 | + vec![0x00, 0xEE, 0xFF, 0x01], // increment = 15663105 |
| 139 | + ), |
| 140 | + Http2Frame::new( |
| 141 | + 0x2, // PRIORITY |
| 142 | + 0x0, |
| 143 | + 3, |
| 144 | + vec![ |
| 145 | + 0x00, 0x00, 0x00, 0x00, // stream dependency = 0 |
| 146 | + 0xC8, // weight = 200 |
| 147 | + ], |
| 148 | + ), |
| 149 | + ]; |
| 150 | + |
| 151 | + let fingerprint = if let Some(fp) = extract_akamai_fingerprint(&chrome_frames) { |
| 152 | + fp |
| 153 | + } else { |
| 154 | + panic!("Failed to extract Chrome fingerprint"); |
| 155 | + }; |
| 156 | + |
| 157 | + assert_eq!(fingerprint.settings.len(), 2); |
| 158 | + assert_eq!(fingerprint.window_update, 15662849); |
| 159 | + assert_eq!(fingerprint.priority_frames.len(), 1); |
| 160 | + |
| 161 | + // Verify signature format |
| 162 | + assert!(fingerprint.fingerprint.contains('|')); |
| 163 | + assert!(!fingerprint.hash.is_empty()); |
| 164 | + assert_eq!(fingerprint.hash.len(), 32); // SHA-256 truncated to 32 hex chars (like JA3) |
| 165 | +} |
| 166 | + |
| 167 | +#[test] |
| 168 | +fn test_firefox_fingerprint() { |
| 169 | + let firefox_frames = vec![ |
| 170 | + Http2Frame::new( |
| 171 | + 0x4, // SETTINGS |
| 172 | + 0x0, |
| 173 | + 0, |
| 174 | + vec![ |
| 175 | + 0x00, 0x03, 0x00, 0x00, 0x10, 0x00, // HEADER_TABLE_SIZE = 4096 |
| 176 | + 0x00, 0x04, 0x00, 0x00, 0x00, 0x64, // INITIAL_WINDOW_SIZE = 100 |
| 177 | + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, // ENABLE_PUSH = 0 |
| 178 | + ], |
| 179 | + ), |
| 180 | + Http2Frame::new( |
| 181 | + 0x8, // WINDOW_UPDATE |
| 182 | + 0x0, |
| 183 | + 0, |
| 184 | + vec![0x00, 0xBE, 0xFF, 0x01], // increment = 12517377 |
| 185 | + ), |
| 186 | + ]; |
| 187 | + |
| 188 | + let fingerprint = if let Some(fp) = extract_akamai_fingerprint(&firefox_frames) { |
| 189 | + fp |
| 190 | + } else { |
| 191 | + panic!("Failed to extract Firefox fingerprint"); |
| 192 | + }; |
| 193 | + |
| 194 | + assert_eq!(fingerprint.settings.len(), 3); |
| 195 | + assert_eq!(fingerprint.window_update, 12517121); |
| 196 | + assert_eq!(fingerprint.priority_frames.len(), 0); |
| 197 | + |
| 198 | + // Verify signature format |
| 199 | + assert!(fingerprint.fingerprint.contains('|')); |
| 200 | + assert!(!fingerprint.hash.is_empty()); |
| 201 | + assert_eq!(fingerprint.hash.len(), 32); // SHA-256 truncated to 32 hex chars |
| 202 | +} |
| 203 | + |
| 204 | +#[test] |
| 205 | +fn test_fingerprint_deterministic() { |
| 206 | + let frames = vec![ |
| 207 | + Http2Frame::new(0x4, 0x0, 0, vec![0x00, 0x03, 0x00, 0x00, 0x00, 0x64]), |
| 208 | + Http2Frame::new(0x8, 0x0, 0, vec![0x00, 0xEE, 0xFF, 0x01]), |
| 209 | + ]; |
| 210 | + |
| 211 | + let fp1 = if let Some(fp) = extract_akamai_fingerprint(&frames) { |
| 212 | + fp |
| 213 | + } else { |
| 214 | + panic!("First fingerprint extraction failed"); |
| 215 | + }; |
| 216 | + |
| 217 | + let fp2 = if let Some(fp) = extract_akamai_fingerprint(&frames) { |
| 218 | + fp |
| 219 | + } else { |
| 220 | + panic!("Second fingerprint extraction failed"); |
| 221 | + }; |
| 222 | + |
| 223 | + assert_eq!(fp1.fingerprint, fp2.fingerprint, "Signatures must be deterministic"); |
| 224 | + assert_eq!(fp1.hash, fp2.hash, "Hashes must be deterministic"); |
| 225 | +} |
| 226 | + |
| 227 | +#[test] |
| 228 | +fn test_different_browsers_different_fingerprints() { |
| 229 | + let chrome_frames = vec![ |
| 230 | + Http2Frame::new(0x4, 0x0, 0, vec![0x00, 0x03, 0x00, 0x00, 0x00, 0x64]), |
| 231 | + Http2Frame::new(0x8, 0x0, 0, vec![0x00, 0xEE, 0xFF, 0x01]), |
| 232 | + ]; |
| 233 | + |
| 234 | + let firefox_frames = vec![ |
| 235 | + Http2Frame::new(0x4, 0x0, 0, vec![0x00, 0x03, 0x00, 0x00, 0x10, 0x00]), |
| 236 | + Http2Frame::new(0x8, 0x0, 0, vec![0x00, 0xBE, 0xFF, 0x01]), |
| 237 | + ]; |
| 238 | + |
| 239 | + let chrome_fp = if let Some(fp) = extract_akamai_fingerprint(&chrome_frames) { |
| 240 | + fp |
| 241 | + } else { |
| 242 | + panic!("Chrome fingerprint failed"); |
| 243 | + }; |
| 244 | + |
| 245 | + let firefox_fp = if let Some(fp) = extract_akamai_fingerprint(&firefox_frames) { |
| 246 | + fp |
| 247 | + } else { |
| 248 | + panic!("Firefox fingerprint failed"); |
| 249 | + }; |
| 250 | + |
| 251 | + assert_ne!( |
| 252 | + chrome_fp.fingerprint, firefox_fp.fingerprint, |
| 253 | + "Different browsers must produce different signatures" |
| 254 | + ); |
| 255 | + assert_ne!( |
| 256 | + chrome_fp.hash, firefox_fp.hash, |
| 257 | + "Different browsers must produce different hashes" |
| 258 | + ); |
| 259 | +} |
| 260 | + |
| 261 | +#[test] |
| 262 | +fn test_minimal_frames() { |
| 263 | + // Only SETTINGS frame (minimum required) |
| 264 | + let minimal_frames = |
| 265 | + vec![Http2Frame::new(0x4, 0x0, 0, vec![0x00, 0x03, 0x00, 0x00, 0x00, 0x64])]; |
| 266 | + |
| 267 | + let fingerprint = if let Some(fp) = extract_akamai_fingerprint(&minimal_frames) { |
| 268 | + fp |
| 269 | + } else { |
| 270 | + panic!("Should generate fingerprint with minimal frames"); |
| 271 | + }; |
| 272 | + |
| 273 | + assert_eq!(fingerprint.settings.len(), 1); |
| 274 | + assert_eq!(fingerprint.window_update, 0); |
| 275 | + assert_eq!(fingerprint.priority_frames.len(), 0); |
| 276 | +} |
| 277 | + |
| 278 | +#[test] |
| 279 | +fn test_no_settings_frame_returns_none() { |
| 280 | + let no_settings_frames = vec![ |
| 281 | + Http2Frame::new(0x8, 0x0, 0, vec![0x00, 0xEE, 0xFF, 0x01]), |
| 282 | + Http2Frame::new(0x2, 0x0, 3, vec![0x00, 0x00, 0x00, 0x00, 0xC8]), |
| 283 | + ]; |
| 284 | + |
| 285 | + let fingerprint = extract_akamai_fingerprint(&no_settings_frames); |
| 286 | + assert!(fingerprint.is_none(), "Should return None without SETTINGS frame"); |
| 287 | +} |
0 commit comments