roughtime 0.1.0

A no_std-capable Roughtime secure time-sync client with pluggable crypto backends
Documentation
//! Synthetic, signed Roughtime response fixtures shared by unit tests across the crate.
//!
//! Signing here uses `ed25519-dalek` directly (a dev-dependency) rather than the crate's own
//! `crypto` backend, so these fixtures exercise verification independently of whichever backend
//! feature is active. The crate itself never signs anything (see the crate-level security-
//! posture docs); this module exists only under `#[cfg(test)]`.
#![allow(clippy::unwrap_used, clippy::missing_panics_doc, clippy::redundant_pub_crate)]

use alloc::vec::Vec;

use base64ct::{Base64, Encoding};
use ed25519_dalek::{Signer as _, SigningKey};

use crate::request::Nonce;
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,
};

const DELEGATION_SIGNATURE_CONTEXT: &[u8] = b"RoughTime v1 delegation signature--\x00";
const RESPONSE_SIGNATURE_CONTEXT: &[u8] = b"RoughTime v1 response signature\x00";

const ROOT_SEED: [u8; 32] = [1u8; 32];
const ONLINE_SEED: [u8; 32] = [2u8; 32];

/// Encodes a Roughtime message from ordered `(tag, value)` pairs, per the wire format in
/// [`crate::wire`]. All values here have lengths that are multiples of 4.
fn encode_message(pairs: &[(u32, &[u8])]) -> Vec<u8> {
    let n = pairs.len();
    let mut out = Vec::new();
    #[allow(clippy::cast_possible_truncation)]
    out.extend_from_slice(&(n as u32).to_le_bytes());

    let mut cumulative = 0u32;
    for &(_, value) in &pairs[..n - 1] {
        #[allow(clippy::cast_possible_truncation)]
        {
            cumulative += value.len() as u32;
        }
        out.extend_from_slice(&cumulative.to_le_bytes());
    }
    for &(tag, _) in pairs {
        out.extend_from_slice(&tag.to_le_bytes());
    }
    for &(_, value) in pairs {
        out.extend_from_slice(value);
    }
    out
}

/// Computes SHA-512 truncated to 32 bytes, matching [`crate::verify`]'s Merkle-hash convention.
///
/// Deliberately reuses the crate's own active backend (rather than adding a fixture-only hash
/// dependency) so fixtures stay backend-agnostic to build.
fn truncated_sha512(parts: &[&[u8]]) -> [u8; 32] {
    let mut buf = Vec::new();
    for part in parts {
        buf.extend_from_slice(part);
    }
    let full = crate::crypto::sha512(&buf);
    let mut out = [0u8; 32];
    out.copy_from_slice(&full[0..32]);
    out
}

/// A fully-built, validly-signed Roughtime response and the root public key that verifies it.
pub(crate) struct SignedResponse {
    pub(crate) bytes: Vec<u8>,
    pub(crate) root_pubkey_base64: alloc::string::String,
}

/// Every knob a test might want to twist away from a "fully valid" response.
pub(crate) struct FixtureParams {
    pub(crate) nonce: Nonce,
    pub(crate) midpoint_micros: u64,
    pub(crate) mint_micros: u64,
    pub(crate) maxt_micros: u64,
    pub(crate) index: u32,
    /// Extra Merkle siblings beyond the single leaf; empty means index must be 0.
    pub(crate) path: Vec<[u8; 32]>,
    pub(crate) corrupt_cert_sig: bool,
    pub(crate) corrupt_response_sig: bool,
    pub(crate) omit_tag: Option<u32>,
}

impl FixtureParams {
    pub(crate) fn valid(nonce_byte: u8, midpoint_micros: u64) -> Self {
        Self {
            nonce: Nonce::new([nonce_byte; 64]),
            midpoint_micros,
            mint_micros: 0,
            maxt_micros: u64::MAX,
            index: 0,
            path: Vec::new(),
            corrupt_cert_sig: false,
            corrupt_response_sig: false,
            omit_tag: None,
        }
    }
}

/// Builds a signed Roughtime response for `params`, returning `None` only when `omit_tag`
/// removes a tag entirely (callers assert on the resulting parse/verify error instead).
pub(crate) fn build_signed_response(params: &FixtureParams) -> SignedResponse {
    let root_key = SigningKey::from_bytes(&ROOT_SEED);
    let online_key = SigningKey::from_bytes(&ONLINE_SEED);
    let online_pubkey = online_key.verifying_key().to_bytes();

    let mint_bytes = params.mint_micros.to_le_bytes();
    let maxt_bytes = params.maxt_micros.to_le_bytes();
    let dele_bytes = encode_message(&[
        (TAG_PUBK, &online_pubkey),
        (TAG_MINT, &mint_bytes),
        (TAG_MAXT, &maxt_bytes),
    ]);

    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);
    let mut cert_sig = root_key.sign(&dele_signed_data).to_bytes();
    if params.corrupt_cert_sig {
        cert_sig[0] ^= 0xFF;
    }

    let cert_bytes = encode_message(&[(TAG_DELE, &dele_bytes), (TAG_SIG, &cert_sig)]);

    // Merkle tree: leaf = truncated_sha512([0x00, nonce]); each level combines with a sibling
    // (ordered by the current index's parity) under a 0x01 prefix.
    let mut current_hash = truncated_sha512(&[&[0x00], params.nonce.as_bytes().as_slice()]);
    let mut idx = params.index;
    let mut path_bytes = Vec::with_capacity(params.path.len() * 32);
    for sibling in &params.path {
        current_hash = if idx & 1 == 0 {
            truncated_sha512(&[&[0x01], &current_hash, sibling])
        } else {
            truncated_sha512(&[&[0x01], sibling, &current_hash])
        };
        path_bytes.extend_from_slice(sibling);
        idx >>= 1;
    }
    let root_hash = current_hash;

    let midpoint_bytes = params.midpoint_micros.to_le_bytes();
    let radius_bytes = 1_000_000u32.to_le_bytes();
    let srep_bytes = encode_message(&[
        (TAG_ROOT, &root_hash),
        (TAG_MIDP, &midpoint_bytes),
        (TAG_RADI, &radius_bytes),
    ]);

    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);
    let mut sig = online_key.sign(&srep_signed_data).to_bytes();
    if params.corrupt_response_sig {
        sig[0] ^= 0xFF;
    }

    let index_bytes = params.index.to_le_bytes();
    let mut pairs: Vec<(u32, &[u8])> = alloc::vec![
        (TAG_SIG, &sig),
        (TAG_PATH, &path_bytes),
        (TAG_CERT, &cert_bytes),
        (TAG_INDX, &index_bytes),
        (TAG_SREP, &srep_bytes),
    ];
    if let Some(omit) = params.omit_tag {
        pairs.retain(|&(tag, _)| tag != omit);
    }
    let bytes = encode_message(&pairs);

    let root_pubkey_base64 = Base64::encode_string(&root_key.verifying_key().to_bytes());

    SignedResponse {
        bytes,
        root_pubkey_base64,
    }
}

/// The base64-encoded root public key that verifies every [`build_signed_response`] fixture
/// (independent of `nonce`/`midpoint`), for tests that need it before a nonce is known.
pub(crate) fn root_pubkey_base64() -> alloc::string::String {
    let root_key = SigningKey::from_bytes(&ROOT_SEED);
    Base64::encode_string(&root_key.verifying_key().to_bytes())
}

/// As [`root_pubkey_base64`], leaked to a `'static` lifetime for tests that need to populate a
/// [`crate::servers::RoughtimeServer`] (whose `pubkey_base64` field is `&'static str`).
pub(crate) fn root_pubkey_base64_static() -> &'static str {
    alloc::boxed::Box::leak(root_pubkey_base64().into_boxed_str())
}