hop-actions 2.0.0

Action primitives and centralizer utilities for hop-core streams
Documentation
#![forbid(unsafe_code)]
#![deny(missing_docs)]
//! Canonically encoded polynomial actions for HOP channels.
//!
//! An action describes the linear transform `g(C)` for a channel companion
//! matrix `C`. Actions over the same matrix commute, because they are
//! polynomials in the same matrix.

use core::fmt;

use hop_core::{poly_matrix_eval_checked, CompanionMatrix, Field, Mat};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

const ACTION_HASH_DOMAIN: &[u8] = b"HOP-ACTION\0\x02";

/// A channel action represented by a polynomial in the channel matrix.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Action {
    /// Polynomial coefficients in ascending degree order.
    pub g_coeffs: Vec<u64>,
    /// Caller-controlled value used by replay and ordering policies.
    pub nonce: u64,
    /// Optional canonical projection-mask row.
    #[serde(default)]
    pub mask_row0: Option<Vec<u64>>,
}

impl Action {
    /// Constructs and validates an action for a channel dimension.
    pub fn try_new(
        g_coeffs: Vec<u64>,
        nonce: u64,
        mask_row0: Option<Vec<u64>>,
        dimension: usize,
    ) -> Result<Self, ActionError> {
        let action = Self {
            g_coeffs,
            nonce,
            mask_row0,
        };
        action.validate_for_dimension(dimension)?;
        Ok(action)
    }

    /// Validates the polynomial and optional mask for a channel dimension.
    pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), ActionError> {
        if dimension == 0 {
            return Err(ActionError::ZeroDimension);
        }
        self.validate()?;
        if let Some(mask) = &self.mask_row0 {
            if mask.len() != dimension {
                return Err(ActionError::MaskDimensionMismatch {
                    expected: dimension,
                    actual: mask.len(),
                });
            }
        }
        Ok(())
    }

    /// Validates dimension-independent canonical action invariants.
    pub fn validate(&self) -> Result<(), ActionError> {
        if self.g_coeffs.is_empty() {
            return Err(ActionError::EmptyPolynomial);
        }
        validate_canonical_values(&self.g_coeffs, "polynomial")?;
        if let Some(mask) = &self.mask_row0 {
            if mask.is_empty() {
                return Err(ActionError::EmptyMask);
            }
            validate_canonical_values(mask, "mask")?;
        }
        Ok(())
    }

    /// Returns the domain-separated canonical hash of this action.
    pub fn hash(&self) -> [u8; 32] {
        hash_action(self)
    }
}

/// An ordered sequence of actions.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActionSeq(pub Vec<Action>);

/// Limits an action polynomial to a topic and maximum degree.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capability {
    /// Maximum permitted polynomial degree.
    pub max_degree: usize,
    /// Topic identifier covered by the capability.
    pub topic: [u8; 32],
}

/// An opaque signature over a subject and capability.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
    /// Subject identifier receiving the capability.
    pub subject: [u8; 32],
    /// Capability granted to the subject.
    pub scope: Capability,
    /// Signature bytes interpreted by the protocol layer.
    pub sig: Vec<u8>,
}

/// Errors produced while validating or evaluating an action.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ActionError {
    /// A channel transform cannot target a zero-dimensional state.
    ZeroDimension,
    /// The action omitted its transform polynomial.
    EmptyPolynomial,
    /// A present mask must contain at least one field value.
    EmptyMask,
    /// A wire value was not a canonical field representative.
    NonCanonicalFieldValue {
        /// Logical field containing the value.
        field: &'static str,
        /// Index of the invalid value.
        index: usize,
        /// Invalid value.
        value: u64,
    },
    /// The optional projection mask did not match the channel dimension.
    MaskDimensionMismatch {
        /// Required mask length.
        expected: usize,
        /// Observed mask length.
        actual: usize,
    },
    /// The state did not match the channel dimension.
    StateDimensionMismatch {
        /// Required state length.
        expected: usize,
        /// Observed state length.
        actual: usize,
    },
    /// The checked HOP Core transform evaluator rejected the matrix.
    InvalidTransform(hop_core::CoreError),
}

impl fmt::Display for ActionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ZeroDimension => f.write_str("an action dimension must be nonzero"),
            Self::EmptyPolynomial => f.write_str("an action polynomial must not be empty"),
            Self::EmptyMask => f.write_str("a present action mask must not be empty"),
            Self::NonCanonicalFieldValue {
                field,
                index,
                value,
            } => write!(
                f,
                "{field} value at index {index} is not canonical: {value} >= {}",
                Field::MOD
            ),
            Self::MaskDimensionMismatch { expected, actual } => write!(
                f,
                "mask dimension mismatch: expected {expected}, got {actual}"
            ),
            Self::StateDimensionMismatch { expected, actual } => write!(
                f,
                "state dimension mismatch: expected {expected}, got {actual}"
            ),
            Self::InvalidTransform(error) => write!(f, "invalid action transform: {error}"),
        }
    }
}

impl std::error::Error for ActionError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::InvalidTransform(error) => Some(error),
            _ => None,
        }
    }
}

/// Returns the domain-separated canonical SHA-256 hash of an action.
///
/// Every variable-length field is length-prefixed, and the optional mask has
/// an explicit presence byte. This prevents structural collisions between
/// coefficient and mask encodings.
pub fn hash_action(action: &Action) -> [u8; 32] {
    let mut hasher = Sha256::new();
    hasher.update(ACTION_HASH_DOMAIN);
    hasher.update(action.nonce.to_le_bytes());
    update_values(&mut hasher, &action.g_coeffs);
    match &action.mask_row0 {
        None => hasher.update([0]),
        Some(mask) => {
            hasher.update([1]);
            update_values(&mut hasher, mask);
        }
    }
    hasher.finalize().into()
}

/// Evaluates an action polynomial as `g(C)` using checked HOP Core operations.
pub fn action_transform(companion: &CompanionMatrix, action: &Action) -> Result<Mat, ActionError> {
    action.validate_for_dimension(companion.dimension())?;
    poly_matrix_eval_checked(&action.g_coeffs, companion.matrix())
        .map_err(ActionError::InvalidTransform)
}

/// Applies the action transform `g(C)` to a channel state.
pub fn apply_action(
    companion: &CompanionMatrix,
    state: &[Field],
    action: &Action,
) -> Result<Vec<Field>, ActionError> {
    let dimension = companion.dimension();
    if state.len() != dimension {
        return Err(ActionError::StateDimensionMismatch {
            expected: dimension,
            actual: state.len(),
        });
    }
    action.validate_for_dimension(dimension)?;
    let mut result = vec![Field::zero(); dimension];
    for coefficient in action.g_coeffs.iter().rev() {
        result = companion
            .try_apply(&result)
            .map_err(ActionError::InvalidTransform)?;
        let scalar = Field::from(*coefficient);
        for (output, input) in result.iter_mut().zip(state) {
            *output += scalar * *input;
        }
    }
    Ok(result)
}

fn validate_canonical_values(values: &[u64], field: &'static str) -> Result<(), ActionError> {
    if let Some((index, &value)) = values
        .iter()
        .enumerate()
        .find(|(_, value)| **value >= Field::MOD)
    {
        return Err(ActionError::NonCanonicalFieldValue {
            field,
            index,
            value,
        });
    }
    Ok(())
}

fn update_values(hasher: &mut Sha256, values: &[u64]) {
    hasher.update((values.len() as u128).to_le_bytes());
    for value in values {
        hasher.update(value.to_le_bytes());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn companion() -> CompanionMatrix {
        CompanionMatrix::try_from_coeffs(&[4, 3, 2]).unwrap()
    }

    #[test]
    fn hash_framing_distinguishes_coefficients_from_mask() {
        let coefficients_then_mask = Action {
            g_coeffs: vec![1],
            nonce: 7,
            mask_row0: Some(vec![2, 0, 0]),
        };
        let coefficients_only = Action {
            g_coeffs: vec![1, 2, 0, 0],
            nonce: 7,
            mask_row0: None,
        };
        assert_ne!(
            hash_action(&coefficients_then_mask),
            hash_action(&coefficients_only)
        );
    }

    #[test]
    fn action_applies_polynomial_transform() {
        let c = companion();
        let state = vec![Field::one(), Field::zero(), Field::zero()];
        let identity = Action::try_new(vec![1], 0, None, 3).unwrap();
        let transition = Action::try_new(vec![0, 1], 1, None, 3).unwrap();

        assert_eq!(apply_action(&c, &state, &identity).unwrap(), state);
        assert_eq!(
            apply_action(&c, &state, &transition).unwrap(),
            c.try_apply(&state).unwrap()
        );
        let polynomial = Action::try_new(vec![7, 11, 13, 17], 2, None, 3).unwrap();
        let dense = action_transform(&c, &polynomial).unwrap();
        let expected: Vec<Field> = dense
            .iter()
            .map(|row| {
                row.iter()
                    .zip(&state)
                    .fold(Field::zero(), |sum, (left, right)| sum + (*left * *right))
            })
            .collect();
        assert_eq!(apply_action(&c, &state, &polynomial).unwrap(), expected);
    }

    #[test]
    fn rejects_ambiguous_or_noncanonical_wire_values() {
        assert_eq!(
            Action::try_new(Vec::new(), 0, None, 3),
            Err(ActionError::EmptyPolynomial)
        );
        assert_eq!(
            Action::try_new(vec![1], 0, None, 0),
            Err(ActionError::ZeroDimension)
        );
        assert_eq!(
            Action::try_new(vec![1], 0, Some(Vec::new()), 3),
            Err(ActionError::EmptyMask)
        );
        assert!(matches!(
            Action::try_new(vec![Field::MOD], 0, None, 3),
            Err(ActionError::NonCanonicalFieldValue { .. })
        ));
        assert!(matches!(
            Action::try_new(vec![1], 0, Some(vec![1]), 3),
            Err(ActionError::MaskDimensionMismatch { .. })
        ));
    }
}