Skip to content

Commit 47bf9bd

Browse files
committed
akamai golden test + paper added
1 parent 793a3c8 commit 47bf9bd

File tree

8 files changed

+903
-1
lines changed

8 files changed

+903
-1
lines changed

huginn-net-http/src/akamai.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ impl AkamaiFingerprint {
207207
p.stream_id,
208208
u8::from(p.exclusive),
209209
p.depends_on,
210-
p.weight.saturating_add(1) // Weight is 1-256, not 0-255
210+
u16::from(p.weight).saturating_add(1) // Weight is 1-256, RFC 7540 says byte+1
211211
)
212212
})
213213
.collect::<Vec<_>>()

huginn-net-http/src/http2_parser.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,35 @@ pub struct Http2Frame {
5050
pub length: u32,
5151
}
5252

53+
impl Http2Frame {
54+
/// Creates a new HTTP/2 frame
55+
///
56+
/// # Parameters
57+
/// - `frame_type_byte`: Raw frame type byte (0x0-0x9 for standard types)
58+
/// - `flags`: Frame flags byte
59+
/// - `stream_id`: Stream identifier
60+
/// - `payload`: Frame payload data
61+
///
62+
/// # Example
63+
/// ```no_run
64+
/// use huginn_net_http::Http2Frame;
65+
///
66+
/// // Create a SETTINGS frame (type 0x4)
67+
/// let frame = Http2Frame::new(0x4, 0x0, 0, vec![0x00, 0x03, 0x00, 0x00, 0x00, 0x64]);
68+
/// ```
69+
#[must_use]
70+
pub fn new(frame_type_byte: u8, flags: u8, stream_id: u32, payload: Vec<u8>) -> Self {
71+
let length = payload.len() as u32;
72+
Self {
73+
frame_type: Http2FrameType::from(frame_type_byte),
74+
stream_id,
75+
flags,
76+
payload,
77+
length,
78+
}
79+
}
80+
}
81+
5382
#[derive(Debug, Clone, Default)]
5483
pub struct Http2Settings {
5584
pub header_table_size: Option<u32>,

huginn-net-http/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub mod signature_matcher;
2828
pub use akamai::{AkamaiFingerprint, Http2Priority, PseudoHeader, SettingId, SettingParameter};
2929
pub use akamai_extractor::extract_akamai_fingerprint;
3030
pub use error::*;
31+
pub use http2_parser::{Http2Frame, Http2FrameType};
3132
pub use http_process::*;
3233
pub use observable::*;
3334
pub use output::*;
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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

Comments
 (0)