use ReportingError::HttpError;
use data_encoding::BASE64;
use roughenough_protocol::ToFrame;
use serde::{Deserialize, Serialize};
#[cfg(feature = "reporting")]
use tracing::error;
use tracing::info;
use crate::CausalityViolation;
use crate::measurement::Measurement;
pub const REPORT_MEDIA_TYPE: &str = "application/roughtime-malfeasance+json";
#[derive(thiserror::Error, Debug)]
pub enum ReportingError {
#[error("{0}")]
SerializationError(#[from] roughenough_protocol::error::Error),
#[error("remote server: {0}")]
HttpError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportEntry {
#[serde(skip_serializing_if = "Option::is_none")]
rand: Option<String>,
request: String,
response: String,
#[serde(rename = "publicKey")]
public_key: String,
}
impl ReportEntry {
pub fn rand(&self) -> Option<&str> {
self.rand.as_deref()
}
pub fn request(&self) -> &str {
&self.request
}
pub fn response(&self) -> &str {
&self.response
}
pub fn public_key(&self) -> &str {
&self.public_key
}
fn from_measurement(measurement: &Measurement) -> Self {
let request_bytes = measurement.request().as_frame_bytes().unwrap();
let public_key = measurement
.public_key()
.expect("Measurements always have a public key by construction");
ReportEntry {
rand: measurement.rand_value().map(|r| BASE64.encode(r)),
request: BASE64.encode(&request_bytes),
response: BASE64.encode(measurement.response_bytes()),
public_key: BASE64.encode(public_key.as_ref()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MalfeasanceReport {
responses: Vec<ReportEntry>,
}
impl MalfeasanceReport {
pub fn responses(&self) -> &[ReportEntry] {
&self.responses
}
pub fn from_violation(violation: &CausalityViolation) -> Self {
let responses: Vec<ReportEntry> = violation
.chain()
.iter()
.map(ReportEntry::from_measurement)
.collect();
MalfeasanceReport { responses }
}
#[cfg(feature = "reporting")]
pub fn submit(&self, url: &str) -> Result<(), ReportingError> {
info!("Sending malfeasance report to {url}");
match ureq::post(url)
.content_type(REPORT_MEDIA_TYPE)
.send_json(self)
{
Ok(_response) => {
info!("Successfully sent malfeasance report");
Ok(())
}
Err(e) => {
error!("failed to submit report: {e}");
Err(HttpError(e.to_string()))
}
}
}
#[cfg(not(feature = "reporting"))]
pub fn submit(&self, _url: &str) -> Result<(), ReportingError> {
info!("Report submission is disabled. Recompile with the 'reporting' feature to enable.");
Err(HttpError("Reporting is disabled".to_string()))
}
}
#[cfg(test)]
mod tests {
const BASE_TIME: u64 = 1_700_000_000;
use roughenough_protocol::FromFrame;
use roughenough_protocol::cursor::ParseCursor;
use roughenough_protocol::request::Request;
use roughenough_protocol::response::Response;
use roughenough_protocol::tags::PublicKey;
use super::*;
fn create_test_measurement(midpoint: u64, radius: u32) -> Measurement {
let request = {
let mut raw =
include_bytes!("../../roughenough-protocol/testdata/rfc-request.071039e5").to_vec();
let mut cursor = ParseCursor::new(&mut raw);
Request::from_frame(&mut cursor).unwrap()
};
let raw_response =
include_bytes!("../../roughenough-protocol/testdata/rfc-response.071039e5").to_vec();
let mut response = {
let mut parse_buf = raw_response.clone();
let mut cursor = ParseCursor::new(&mut parse_buf);
Response::from_frame(&mut cursor).unwrap()
};
let mut srep = response.srep().clone();
srep.set_midp(midpoint);
srep.set_radi(radius);
response.set_srep(srep);
let public_key = PublicKey::from([0x22u8; 32]);
Measurement::builder()
.server("127.0.0.1:8000".parse().unwrap())
.hostname("test".to_string())
.public_key(Some(public_key))
.request(request)
.response(response)
.response_bytes(raw_response)
.rand_value(Some([0x42u8; 32]))
.build()
.unwrap()
}
#[test]
fn test_report_entry_creation() {
let measurement = create_test_measurement(1000000, 100);
let entry = ReportEntry::from_measurement(&measurement);
assert!(entry.rand.is_some());
assert!(!entry.request.is_empty());
assert!(!entry.response.is_empty());
assert!(!entry.public_key.is_empty());
let decoded_key = BASE64.decode(entry.public_key.as_bytes()).unwrap();
assert_eq!(decoded_key.len(), 32);
}
#[test]
fn test_json_serialization() {
let m1 = create_test_measurement(2000, 100);
let m2 = create_test_measurement(1000, 100);
let violation = CausalityViolation::new(&[m1, m2], 0, 1);
let report = MalfeasanceReport::from_violation(&violation);
let json = serde_json::to_string_pretty(&report).unwrap();
assert!(json.contains("\"responses\""));
assert!(json.contains("\"request\""));
assert!(json.contains("\"response\""));
assert!(json.contains("\"publicKey\""));
let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
}
fn create_chained_measurements(midpoints: &[u64]) -> Vec<Measurement> {
use roughenough_common::crypto::{calculate_chained_nonce, random_bytes};
use roughenough_protocol::tags::Nonce;
use roughenough_server::test_utils::TestContext;
let mut prior_response: Option<Vec<u8>> = None;
let mut measurements = Vec::new();
for &midpoint in midpoints {
let mut ctx = TestContext::new_at(1, BASE_TIME);
let (nonce, rand_value) = match prior_response.as_deref() {
Some(prior) => {
let rand = random_bytes::<32>();
(calculate_chained_nonce(prior, &rand), Some(rand))
}
None => (Nonce::from(random_bytes::<32>()), None),
};
let (request, response) = ctx.create_interaction_pair_with_nonce(midpoint, &nonce);
let response_bytes = response.as_frame_bytes().unwrap();
let measurement = Measurement::builder()
.server("127.0.0.1:2003".parse().unwrap())
.hostname("test".to_string())
.public_key(Some(ctx.key_source.public_key()))
.request(request)
.response(response)
.response_bytes(response_bytes.clone())
.rand_value(rand_value)
.build()
.unwrap();
prior_response = Some(response_bytes);
measurements.push(measurement);
}
measurements
}
#[test]
fn violation_between_first_and_third_reports_full_chain() {
let base = BASE_TIME;
let measurements = create_chained_measurements(&[base + 2_000_000, base + 1_000_000, base]);
let violation = CausalityViolation::new(&measurements, 0, 2);
let report = MalfeasanceReport::from_violation(&violation);
assert_eq!(report.responses().len(), 3);
for (entry, m) in report.responses().iter().zip(&measurements) {
let request_bytes = m.request().as_frame_bytes().unwrap();
assert_eq!(entry.request(), BASE64.encode(&request_bytes));
assert_eq!(entry.response(), BASE64.encode(m.response_bytes()));
}
assert!(report.responses()[0].rand().is_none());
for (entry, m) in report.responses().iter().zip(&measurements).skip(1) {
let expected = BASE64.encode(m.rand_value().unwrap());
assert_eq!(entry.rand().unwrap(), expected);
}
}
#[cfg(feature = "reporting")]
#[test]
fn submit_sends_registered_media_type() {
use std::io::{Read, Write};
use std::net::TcpListener;
use crate::reporting::REPORT_MEDIA_TYPE;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let head_end = loop {
let n = stream.read(&mut chunk).unwrap();
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break pos + 4;
}
assert!(n > 0, "connection closed before headers completed");
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_lowercase();
let content_length: usize = head
.lines()
.find_map(|line| line.strip_prefix("content-length:"))
.expect("request must declare content-length")
.trim()
.parse()
.unwrap();
while buf.len() < head_end + content_length {
let n = stream.read(&mut chunk).unwrap();
assert!(n > 0, "connection closed before body completed");
buf.extend_from_slice(&chunk[..n]);
}
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
.unwrap();
head
});
let m1 = create_test_measurement(2000, 100);
let m2 = create_test_measurement(1000, 100);
let violation = CausalityViolation::new(&[m1, m2], 0, 1);
let report = MalfeasanceReport::from_violation(&violation);
report.submit(&format!("http://{addr}/reports")).unwrap();
let head = handle.join().unwrap();
assert!(
head.contains(&format!("content-type: {REPORT_MEDIA_TYPE}")),
"request head lacks the registered media type: {head}"
);
}
}