roughtime 0.1.0

A no_std-capable Roughtime secure time-sync client with pluggable crypto backends
Documentation
//! An async, concurrent multi-server Roughtime client built on tokio.

use alloc::vec::Vec;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;

use tokio::net::UdpSocket;
use tokio::time::timeout;

use crate::request::{Nonce, build_request};
use crate::servers::RoughtimeServer;
use crate::verify::verify_response;

use super::{ConsensusResult, RoughtimeClientConfig, consensus};

/// An async Roughtime client that queries multiple servers concurrently and takes the
/// median-midpoint consensus.
#[derive(Debug, Clone, Copy)]
pub struct TokioClient {
    config: RoughtimeClientConfig,
}

impl TokioClient {
    /// Creates a new client with the given configuration.
    #[must_use]
    pub const fn new(config: RoughtimeClientConfig) -> Self {
        Self { config }
    }

    /// Queries `resolved_servers` concurrently and returns the consensus verified time.
    ///
    /// # Errors
    ///
    /// Returns [`crate::Error::NoValidResponses`] if every server query failed.
    pub async fn query(
        &self,
        resolved_servers: &[(RoughtimeServer, IpAddr)],
    ) -> Result<ConsensusResult, crate::Error> {
        let mut handles = Vec::with_capacity(resolved_servers.len());
        for &(srv, ip) in resolved_servers {
            let config = self.config;
            handles.push(tokio::spawn(async move {
                query_single(ip, srv.port, srv.pubkey_base64, &config).await
            }));
        }

        let mut times = Vec::with_capacity(handles.len());
        for handle in handles {
            if let Ok(Ok(verified)) = handle.await {
                times.push(verified);
            }
        }

        consensus(times, self.config.max_spread)
    }

    /// Resolves the crate's well-known [`ROUGHTIME_SERVERS`](crate::ROUGHTIME_SERVERS) over
    /// `DNSCrypt` and queries them, returning the consensus verified time.
    ///
    /// This is the most user-friendly entry point: no manual IP resolution or server list
    /// required. Requires the `dnscrypt` feature.
    ///
    /// # Errors
    ///
    /// Returns [`crate::Error::NoValidResponses`] if no server both resolved and answered.
    #[cfg(feature = "dnscrypt")]
    pub async fn query_well_known(&self) -> Result<ConsensusResult, crate::Error> {
        let resolved = crate::resolve::resolve_servers_async(crate::servers::ROUGHTIME_SERVERS).await;
        self.query(&resolved).await
    }
}

async fn query_single(
    ip: IpAddr,
    port: u16,
    pubkey_base64: &str,
    config: &RoughtimeClientConfig,
) -> Result<crate::VerifiedTime, crate::Error> {
    let socket = UdpSocket::bind("0.0.0.0:0")
        .await
        .map_err(|_err| crate::Error::NoValidResponses)?;
    let server_addr = SocketAddr::new(ip, port);

    let nonce = Nonce::random()?;
    let req = build_request(&nonce);

    let mut buf = [0u8; 2048];
    let mut received_len = None;

    for _attempt in 0..config.retries.max(1) {
        if socket.send_to(&req, server_addr).await.is_err() {
            tokio::time::sleep(Duration::from_millis(300)).await;
            continue;
        }

        match timeout(config.timeout, socket.recv_from(&mut buf)).await {
            Ok(Ok((n, _src))) => {
                received_len = Some(n);
                break;
            }
            Ok(Err(_)) | Err(_) => {
                tokio::time::sleep(Duration::from_millis(300)).await;
            }
        }
    }

    let n = received_len.ok_or(crate::Error::NoValidResponses)?;
    verify_response(&buf[..n], &nonce, pubkey_base64)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]

    use std::net::IpAddr;

    use super::{RoughtimeClientConfig, RoughtimeServer, TokioClient};
    use crate::floor::EFFECTIVE_FLOOR_SECS;
    use crate::request::Nonce;
    use crate::tags::TAG_NONC;
    use crate::test_fixtures::{FixtureParams, build_signed_response, root_pubkey_base64_static};
    use crate::wire::RtMessage;

    #[tokio::test]
    async fn queries_a_loopback_server_successfully() {
        let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
        let addr = socket.local_addr().unwrap();

        let server_task = tokio::spawn(async move {
            let mut buf = [0u8; 2048];
            let (n, src) = socket.recv_from(&mut buf).await.unwrap();
            let nonce_bytes: [u8; 64] = RtMessage::parse(&buf[..n])
                .unwrap()
                .get(TAG_NONC)
                .unwrap()
                .try_into()
                .unwrap();

            let mut params = FixtureParams::valid(0, (EFFECTIVE_FLOOR_SECS + 10) * 1_000_000);
            params.nonce = Nonce::new(nonce_bytes);
            let signed = build_signed_response(&params);
            socket.send_to(&signed.bytes, src).await.unwrap();
        });

        let server = RoughtimeServer {
            name: "test",
            host: "localhost",
            port: addr.port(),
            pubkey_base64: root_pubkey_base64_static(),
        };
        let client = TokioClient::new(RoughtimeClientConfig::default());
        let result = client.query(&[(server, addr.ip())]).await.unwrap();
        assert!(result.time.midpoint_micros > 0);

        server_task.await.unwrap();
    }

    #[tokio::test]
    async fn an_unreachable_server_yields_no_valid_responses() {
        let config = RoughtimeClientConfig {
            timeout: std::time::Duration::from_millis(100),
            retries: 1,
            ..RoughtimeClientConfig::default()
        };

        let server = RoughtimeServer {
            name: "test",
            host: "localhost",
            port: 1, // reserved, nothing listens here
            pubkey_base64: root_pubkey_base64_static(),
        };
        let client = TokioClient::new(config);
        let ip: IpAddr = "127.0.0.1".parse().unwrap();
        let err = client.query(&[(server, ip)]).await.unwrap_err();
        assert_eq!(err, crate::Error::NoValidResponses);
    }
}