feat(analyzer): encrypted payload decoding in replay mode (#17)
When --key <64-char-hex> is provided with --replay, the analyzer decrypts each packet's ChaCha20-Poly1305 payload using the session key and logs plaintext frame sizes. Prints first 5 + every 100th decrypt result, and a summary at the end. This completes all 5 protocol analyzer tasks (#13-17): - #13: Observer mode (live passive listener) — was done - #14: TUI with Ratatui (per-participant panels) — was done - #15: Capture and replay (.wzp format) — was done - #16: HTML report (Chart.js loss/jitter graphs) — was done - #17: Encrypted decode (--key for replay) — done now Usage: wzp-analyzer --replay session.wzp --key <64-hex-chars> --html report.html Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -332,12 +332,53 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut timeline: Vec<TimelineEntry> = Vec::new();
|
let mut timeline: Vec<TimelineEntry> = Vec::new();
|
||||||
|
|
||||||
|
// Decrypt session from --key (optional)
|
||||||
|
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> = args.key.as_ref().and_then(|hex| {
|
||||||
|
if hex.len() != 64 { return None; }
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
|
||||||
|
let s = std::str::from_utf8(chunk).unwrap_or("00");
|
||||||
|
key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
|
||||||
|
}
|
||||||
|
Some(wzp_crypto::ChaChaSession::new(key))
|
||||||
|
});
|
||||||
|
let mut decrypt_ok: u64 = 0;
|
||||||
|
let mut decrypt_fail: u64 = 0;
|
||||||
|
|
||||||
while let Some((ts_us, pkt)) = reader.next_packet()? {
|
while let Some((ts_us, pkt)) = reader.next_packet()? {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let idx = find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
|
let idx = find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
|
||||||
participants[idx].ingest(&pkt, now);
|
participants[idx].ingest(&pkt, now);
|
||||||
total_packets += 1;
|
total_packets += 1;
|
||||||
|
|
||||||
|
// Attempt decryption if key provided
|
||||||
|
if let Some(ref mut session) = decrypt_session {
|
||||||
|
use wzp_proto::CryptoSession;
|
||||||
|
let header_bytes = pkt.header.to_bytes();
|
||||||
|
let mut plaintext = Vec::new();
|
||||||
|
match session.decrypt(&header_bytes, &pkt.payload, &mut plaintext) {
|
||||||
|
Ok(()) => {
|
||||||
|
decrypt_ok += 1;
|
||||||
|
if decrypt_ok <= 5 || decrypt_ok % 100 == 0 {
|
||||||
|
eprintln!(
|
||||||
|
" decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B",
|
||||||
|
pkt.header.seq, pkt.header.codec_id,
|
||||||
|
pkt.payload.len(), plaintext.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
decrypt_fail += 1;
|
||||||
|
if decrypt_fail <= 3 {
|
||||||
|
eprintln!(
|
||||||
|
" decrypt FAIL: seq={} (key mismatch, wrong direction, or rekey boundary)",
|
||||||
|
pkt.header.seq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record for HTML timeline
|
// Record for HTML timeline
|
||||||
timeline.push(TimelineEntry {
|
timeline.push(TimelineEntry {
|
||||||
timestamp_us: ts_us,
|
timestamp_us: ts_us,
|
||||||
@@ -350,6 +391,13 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if decrypt_session.is_some() {
|
||||||
|
eprintln!(
|
||||||
|
"Decrypt stats: {} ok, {} failed (total {})",
|
||||||
|
decrypt_ok, decrypt_fail, total_packets
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
print_summary(&participants, total_packets, start.elapsed());
|
print_summary(&participants, total_packets, start.elapsed());
|
||||||
|
|
||||||
// Generate HTML if requested
|
// Generate HTML if requested
|
||||||
@@ -781,13 +829,24 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref key) = args.key {
|
let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> =
|
||||||
eprintln!(
|
if let Some(ref key_hex) = args.key {
|
||||||
"Note: --key provided ({} chars) but audio decode is not yet implemented.",
|
if key_hex.len() != 64 {
|
||||||
key.len()
|
eprintln!("Error: --key must be 64 hex characters (32 bytes). Got {} chars.", key_hex.len());
|
||||||
);
|
std::process::exit(1);
|
||||||
eprintln!(" Header-only analysis (loss%, jitter, codec stats) will proceed.");
|
}
|
||||||
}
|
let mut key_bytes = [0u8; 32];
|
||||||
|
for (i, chunk) in key_hex.as_bytes().chunks(2).enumerate() {
|
||||||
|
let hex_str = std::str::from_utf8(chunk).unwrap_or("00");
|
||||||
|
key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0);
|
||||||
|
}
|
||||||
|
eprintln!("Encrypted payload decoding enabled (key loaded).");
|
||||||
|
Some(std::sync::Mutex::new(
|
||||||
|
wzp_crypto::ChaChaSession::new(key_bytes),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Replay mode: offline analysis of a .wzp capture file
|
// Replay mode: offline analysis of a .wzp capture file
|
||||||
if let Some(ref replay_path) = args.replay {
|
if let Some(ref replay_path) = args.replay {
|
||||||
|
|||||||
Reference in New Issue
Block a user