roughtime 0.1.0

A no_std-capable Roughtime secure time-sync client with pluggable crypto backends
Documentation
//! Verification of Roughtime responses: signature checks, Merkle inclusion proof, and the
//! anti-rollback floor.

use alloc::vec::Vec;

use base64ct::{Base64, Encoding};

use crate::crypto::{sha512, verify_ed25519};
use crate::error::Error;
use crate::floor;
use crate::tags::{
    TAG_CERT, TAG_DELE, TAG_INDX, TAG_MAXT, TAG_MIDP, TAG_MINT, TAG_PATH, TAG_PUBK, TAG_RADI,
    TAG_ROOT, TAG_SIG, TAG_SREP,
};
use crate::wire::RtMessage;

/// The prefix prepended to a `DELE` blob before verifying its delegation signature.
const DELEGATION_SIGNATURE_CONTEXT: &[u8] = b"RoughTime v1 delegation signature--\x00";
/// The prefix prepended to an `SREP` blob before verifying its response signature.
const RESPONSE_SIGNATURE_CONTEXT: &[u8] = b"RoughTime v1 response signature\x00";

/// A Roughtime response that has passed signature verification, Merkle inclusion proof
/// verification, and the crate's anti-rollback floor check.
#[derive(Debug, Clone, Copy)]
pub struct VerifiedTime {
    /// The server-reported midpoint, in microseconds since the Unix epoch.
    pub midpoint_micros: u64,
    /// The server-reported uncertainty radius, in microseconds.
    pub radius_micros: u32,
}

impl VerifiedTime {
    /// Returns the verified midpoint as whole Unix seconds.
    #[must_use]
    pub const fn as_unix_seconds(&self) -> u64 {
        self.midpoint_micros / 1_000_000
    }
}

/// Verifies a Roughtime server response.
///
/// Checks the delegation and response Ed25519 signatures, the Merkle inclusion proof binding
/// `nonce` to the signed root, the delegated key's validity interval, and the crate's
/// anti-rollback floor.
///
/// # Errors
///
/// Returns an [`Error`] if any part of the response is malformed, any signature fails to
/// verify, the Merkle proof does not check out, or the reported time predates the anti-rollback
/// floor.
pub fn verify_response(
    data: &[u8],
    nonce: &crate::request::Nonce,
    server_pubkey_base64: &str,
) -> Result<VerifiedTime, Error> {
    let resp = RtMessage::parse(data)?;
    let cert_bytes = resp.get(TAG_CERT).ok_or(Error::MissingTag(TAG_CERT))?;
    let srep_bytes = resp.get(TAG_SREP).ok_or(Error::MissingTag(TAG_SREP))?;
    let sig_bytes = resp.get(TAG_SIG).ok_or(Error::MissingTag(TAG_SIG))?;

    let cert = RtMessage::parse(cert_bytes)?;
    let dele_bytes = cert.get(TAG_DELE).ok_or(Error::MissingTag(TAG_DELE))?;
    let cert_sig_bytes = cert.get(TAG_SIG).ok_or(Error::MissingTag(TAG_SIG))?;

    let root_pubkey_bytes =
        Base64::decode_vec(server_pubkey_base64).map_err(|_err| Error::InvalidPublicKeyEncoding)?;

    let mut dele_signed_data =
        Vec::with_capacity(DELEGATION_SIGNATURE_CONTEXT.len() + dele_bytes.len());
    dele_signed_data.extend_from_slice(DELEGATION_SIGNATURE_CONTEXT);
    dele_signed_data.extend_from_slice(dele_bytes);
    verify_ed25519(&root_pubkey_bytes, &dele_signed_data, cert_sig_bytes)
        .map_err(|_err| Error::SignatureInvalid)?;

    let dele = RtMessage::parse(dele_bytes)?;
    let online_pubkey = dele.get(TAG_PUBK).ok_or(Error::MissingTag(TAG_PUBK))?;
    let mint_bytes = dele.get(TAG_MINT).ok_or(Error::MissingTag(TAG_MINT))?;
    let maxt_bytes = dele.get(TAG_MAXT).ok_or(Error::MissingTag(TAG_MAXT))?;

    let mint = u64::from_le_bytes(
        mint_bytes
            .try_into()
            .map_err(|_err| Error::InvalidFieldSize(TAG_MINT))?,
    );
    let maxt = u64::from_le_bytes(
        maxt_bytes
            .try_into()
            .map_err(|_err| Error::InvalidFieldSize(TAG_MAXT))?,
    );

    let mut srep_signed_data =
        Vec::with_capacity(RESPONSE_SIGNATURE_CONTEXT.len() + srep_bytes.len());
    srep_signed_data.extend_from_slice(RESPONSE_SIGNATURE_CONTEXT);
    srep_signed_data.extend_from_slice(srep_bytes);
    verify_ed25519(online_pubkey, &srep_signed_data, sig_bytes)
        .map_err(|_err| Error::SignatureInvalid)?;

    let srep = RtMessage::parse(srep_bytes)?;
    let root_hash = srep.get(TAG_ROOT).ok_or(Error::MissingTag(TAG_ROOT))?;
    let midpoint_bytes = srep.get(TAG_MIDP).ok_or(Error::MissingTag(TAG_MIDP))?;
    let radius_bytes = srep.get(TAG_RADI).ok_or(Error::MissingTag(TAG_RADI))?;

    let midpoint = u64::from_le_bytes(
        midpoint_bytes
            .try_into()
            .map_err(|_err| Error::InvalidFieldSize(TAG_MIDP))?,
    );
    let radius = u32::from_le_bytes(
        radius_bytes
            .try_into()
            .map_err(|_err| Error::InvalidFieldSize(TAG_RADI))?,
    );

    if midpoint < mint || midpoint > maxt {
        return Err(Error::DelegationNotValidAtMidpoint);
    }

    let index_bytes = resp.get(TAG_INDX).ok_or(Error::MissingTag(TAG_INDX))?;
    let path_bytes = resp.get(TAG_PATH).ok_or(Error::MissingTag(TAG_PATH))?;

    let index = u32::from_le_bytes(
        index_bytes
            .try_into()
            .map_err(|_err| Error::InvalidFieldSize(TAG_INDX))?,
    );
    if path_bytes.len() % 32 != 0 {
        return Err(Error::InvalidMerklePathLength);
    }

    let mut current_hash = truncated_sha512(&[&[0x00], nonce.as_bytes().as_slice()]);

    let mut idx = index;
    for sibling in path_bytes.chunks_exact(32) {
        current_hash = if idx & 1 == 0 {
            truncated_sha512(&[&[0x01], &current_hash, sibling])
        } else {
            truncated_sha512(&[&[0x01], sibling, &current_hash])
        };
        idx >>= 1;
    }

    if root_hash.len() < 32 {
        return Err(Error::InvalidFieldSize(TAG_ROOT));
    }
    if current_hash != root_hash[0..32] {
        return Err(Error::MerkleRootMismatch);
    }
    if idx != 0 {
        return Err(Error::MerkleIndexOutOfBounds);
    }

    floor::check_floor(midpoint / 1_000_000)?;

    Ok(VerifiedTime {
        midpoint_micros: midpoint,
        radius_micros: radius,
    })
}

/// Computes SHA-512 over the concatenation of `parts`, truncated to the first 32 bytes, per the
/// Roughtime Merkle-hash convention.
fn truncated_sha512(parts: &[&[u8]]) -> [u8; 32] {
    let mut buf = Vec::new();
    for part in parts {
        buf.extend_from_slice(part);
    }
    let full = sha512(&buf);
    let mut out = [0u8; 32];
    out.copy_from_slice(&full[0..32]);
    out
}

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

    use super::verify_response;
    use crate::Error;
    use crate::floor::EFFECTIVE_FLOOR_SECS;
    use crate::request::Nonce;
    use crate::test_fixtures::{FixtureParams, build_signed_response};

    const VALID_MIDPOINT_MICROS: u64 = (EFFECTIVE_FLOOR_SECS + 3600) * 1_000_000;

    #[test]
    fn accepts_a_valid_response() {
        let params = FixtureParams::valid(0x11, VALID_MIDPOINT_MICROS);
        let signed = build_signed_response(&params);

        let verified =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap();

        assert_eq!(verified.midpoint_micros, VALID_MIDPOINT_MICROS);
        assert_eq!(verified.as_unix_seconds(), VALID_MIDPOINT_MICROS / 1_000_000);
    }

    #[test]
    fn accepts_a_response_with_a_multi_level_merkle_path() {
        let mut params = FixtureParams::valid(0x22, VALID_MIDPOINT_MICROS);
        params.index = 2;
        params.path = alloc::vec![[0xAA; 32], [0xBB; 32]];
        let signed = build_signed_response(&params);

        let verified =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap();
        assert_eq!(verified.midpoint_micros, VALID_MIDPOINT_MICROS);
    }

    #[test]
    fn rejects_wrong_nonce_via_merkle_root_mismatch() {
        let params = FixtureParams::valid(0x33, VALID_MIDPOINT_MICROS);
        let signed = build_signed_response(&params);

        let wrong_nonce = Nonce::new([0x99; 64]);
        let err = verify_response(&signed.bytes, &wrong_nonce, &signed.root_pubkey_base64)
            .unwrap_err();
        assert_eq!(err, Error::MerkleRootMismatch);
    }

    #[test]
    fn rejects_index_not_fully_consumed_by_path() {
        let mut params = FixtureParams::valid(0x44, VALID_MIDPOINT_MICROS);
        params.index = 1; // no siblings to consume the index bit
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::MerkleIndexOutOfBounds);
    }

    #[test]
    fn rejects_corrupt_delegation_signature() {
        let mut params = FixtureParams::valid(0x55, VALID_MIDPOINT_MICROS);
        params.corrupt_cert_sig = true;
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::SignatureInvalid);
    }

    #[test]
    fn rejects_corrupt_response_signature() {
        let mut params = FixtureParams::valid(0x66, VALID_MIDPOINT_MICROS);
        params.corrupt_response_sig = true;
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::SignatureInvalid);
    }

    #[test]
    fn rejects_midpoint_outside_delegation_validity() {
        let mut params = FixtureParams::valid(0x77, VALID_MIDPOINT_MICROS);
        params.maxt_micros = VALID_MIDPOINT_MICROS - 1;
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::DelegationNotValidAtMidpoint);
    }

    #[test]
    fn rejects_midpoint_before_the_anti_rollback_floor() {
        let params = FixtureParams::valid(0x88, (EFFECTIVE_FLOOR_SECS - 10) * 1_000_000);
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert!(matches!(err, Error::BeforeFloor { .. }));
    }

    #[test]
    fn rejects_invalid_public_key_encoding() {
        let params = FixtureParams::valid(0x99, VALID_MIDPOINT_MICROS);
        let signed = build_signed_response(&params);

        let err = verify_response(&signed.bytes, &params.nonce, "not valid base64!!").unwrap_err();
        assert_eq!(err, Error::InvalidPublicKeyEncoding);
    }

    #[test]
    fn rejects_response_missing_cert_tag() {
        let mut params = FixtureParams::valid(0xAA, VALID_MIDPOINT_MICROS);
        params.omit_tag = Some(crate::tags::TAG_CERT);
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::MissingTag(crate::tags::TAG_CERT));
    }

    #[test]
    fn rejects_response_missing_srep_tag() {
        let mut params = FixtureParams::valid(0xBB, VALID_MIDPOINT_MICROS);
        params.omit_tag = Some(crate::tags::TAG_SREP);
        let signed = build_signed_response(&params);

        let err =
            verify_response(&signed.bytes, &params.nonce, &signed.root_pubkey_base64).unwrap_err();
        assert_eq!(err, Error::MissingTag(crate::tags::TAG_SREP));
    }

    #[test]
    fn rejects_truncated_garbage() {
        let err = verify_response(&[0x01, 0x02], &Nonce::new([0; 64]), "AAAA").unwrap_err();
        assert_eq!(err, Error::MessageTooShort);
    }
}