roughenough-client 2.2.0

Command-line Roughtime client for querying time servers
Documentation
//! Observations providing proof that a server sent incorrect time.

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;

/// Media type registered for malfeasance reports (RFC 8.4.1 / 12.4.2)
pub const REPORT_MEDIA_TYPE: &str = "application/roughtime-malfeasance+json";

/// Errors that can occur during malfeasance reporting
#[derive(thiserror::Error, Debug)]
pub enum ReportingError {
    #[error("{0}")]
    SerializationError(#[from] roughenough_protocol::error::Error),

    #[error("remote server: {0}")]
    HttpError(String),
}

/// A [`Request`](roughenough_protocol::request::Request)/[`Response`](roughenough_protocol::response::Response) observation in a [`MalfeasanceReport`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportEntry {
    /// Base-64 encoded 32-byte random value used to generate the request nonce.
    #[serde(skip_serializing_if = "Option::is_none")]
    rand: Option<String>,

    /// Base64-encoded request bytes (including framing magic value and length)
    request: String,

    /// Base64-encoded response bytes (including framing magic value and length)
    response: String,

    /// Base64-encoded long-term public key expected from the server
    #[serde(rename = "publicKey")]
    public_key: String,
}

impl ReportEntry {
    /// the rand value if present
    pub fn rand(&self) -> Option<&str> {
        self.rand.as_deref()
    }

    /// base64-encoded request
    pub fn request(&self) -> &str {
        &self.request
    }

    /// base64-encoded response
    pub fn response(&self) -> &str {
        &self.response
    }

    /// base64-encoded public key
    pub fn public_key(&self) -> &str {
        &self.public_key
    }

    /// Creates a report entry from a measurement
    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");

        // Encode everything as base64. The response is reported exactly as it
        // was received from the server.
        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()),
        }
    }
}

/// Ordered [`Request`](roughenough_protocol::request::Request)/[`Response`](roughenough_protocol::response::Response) observations that demonstrate a violation of causality.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MalfeasanceReport {
    responses: Vec<ReportEntry>,
}

impl MalfeasanceReport {
    pub fn responses(&self) -> &[ReportEntry] {
        &self.responses
    }

    /// Extracts the chain of measurements that demonstrates the violation and formats
    /// them according to the RFC specification. Use `submit()` to send them to a server.
    ///
    /// The report carries the violation's full measurement chain, in the order
    /// performed (RFC 8.4.1).
    pub fn from_violation(violation: &CausalityViolation) -> Self {
        let responses: Vec<ReportEntry> = violation
            .chain()
            .iter()
            .map(ReportEntry::from_measurement)
            .collect();

        MalfeasanceReport { responses }
    }

    /// POSTs the malfeasance report to the specified URL
    #[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()
        };

        // Modify response for testing
        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);

        // Test entry with rand value
        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());

        // Verify base64 encoding
        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();

        // Verify JSON structure
        assert!(json.contains("\"responses\""));
        assert!(json.contains("\"request\""));
        assert!(json.contains("\"response\""));
        assert!(json.contains("\"publicKey\""));

        // Verify it's valid JSON
        let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    }

    /// Build a properly chained measurement sequence: measurement 0 uses a
    /// random nonce; each later measurement's nonce is
    /// H(prior_response || rand) exactly as MeasurementSequence derives it.
    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 {
            // one context per exchange; all contexts share a seed, so the
            // measurements present one server identity
            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;
        // measurement 0 claims a time far ahead of measurement 2
        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);

        // All three measurements appear, in the order performed
        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()));
        }

        // First entry's nonce was unchained: no rand. Later entries carry the
        // rand used to derive their nonce from the previous response.
        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();

        // Minimal HTTP endpoint: capture the request head, drain the body so
        // the client's write completes, then answer 200
        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}"
        );
    }
}