qs-policy 0.1.0

Policy engine for Quantum-Sign - The Digital Notary Stamp That Even Quantum Computers Can't Forge
Documentation
#![forbid(unsafe_code)]

use serde::{Deserialize, Serialize};
use std::{fs, path::Path};

#[cfg(feature = "json")]
use sha2::{Digest, Sha256};

#[cfg(feature = "json")]
use serde_json::{self, Map, Value};

/// Signing policy describing allowed algorithms and approval requirements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
    /// Default algorithm identifier (e.g., "mldsa-87").
    pub default_alg: String,
    /// Whitelist of allowed algorithms.
    pub allow_algs: Vec<String>,
    /// Optional multi-party signature quorum requirement.
    #[serde(default)]
    pub required_signatures: Option<RequiredSignatures>,
    /// Whether signing is permitted without network transparency.
    #[serde(default)]
    pub offline_ok: bool,
    /// Enforce FIPS-approved algorithms only (defaults to true).
    #[serde(default = "Policy::default_require_fips")]
    pub require_fips_only: bool,
    /// Enforce NIST PQC Level-5 defaults when true (default).
    #[serde(default = "Policy::default_require_level5")]
    pub require_level5: bool,
    /// Digest algorithm (sha512 or shake256-64 for Level-5).
    #[serde(default = "Policy::default_digest_alg")]
    pub digest_alg: String,
    /// Explicit escape hatch for non-Level-5 algorithms.
    #[serde(default)]
    pub allow_lower_levels: bool,
}

impl Policy {
    const fn default_require_fips() -> bool {
        true
    }

    const fn default_require_level5() -> bool {
        true
    }

    fn default_digest_alg() -> String {
        "sha512".into()
    }

    /// Compute a canonical SHA-256 hash of the policy for inclusion in intents.
    #[cfg(feature = "json")]
    pub fn canonical_hash(&self) -> [u8; 32] {
        let value = serde_json::to_value(self).expect("policy serializable");
        let sorted = Self::sort_json(value);
        let encoded = serde_json::to_vec(&sorted).expect("canonical JSON encode");
        let digest = Sha256::digest(&encoded);
        let mut out = [0u8; 32];
        out.copy_from_slice(&digest);
        out
    }

    #[cfg(feature = "json")]
    fn sort_json(value: Value) -> Value {
        match value {
            Value::Object(mut map) => {
                let mut keys: Vec<String> = map.keys().cloned().collect();
                keys.sort();
                let mut ordered = Map::new();
                for key in keys {
                    let v = map.remove(&key).expect("key removed");
                    ordered.insert(key, Self::sort_json(v));
                }
                Value::Object(ordered)
            }
            Value::Array(arr) => {
                let mut items: Vec<Value> = arr.into_iter().map(Self::sort_json).collect();
                items.sort_by(|a, b| {
                    serde_json::to_string(a)
                        .unwrap()
                        .cmp(&serde_json::to_string(b).unwrap())
                });
                Value::Array(items)
            }
            other => other,
        }
    }

    #[cfg(not(feature = "json"))]
    pub fn canonical_hash(&self) -> [u8; 32] {
        panic!("Policy::canonical_hash requires the `json` feature");
    }

    /// Enforce that non-FIPS algorithms are not used when policy forbids it.
    pub fn ensure_fips(&self, allow_nonfips: bool) -> Result<(), ValidationError> {
        if self.require_fips_only && allow_nonfips {
            return Err(ValidationError::FipsRequired);
        }
        Ok(())
    }

    /// Enforce Level-5 defaults when required.
    pub fn enforce_level5(&self) -> Result<(), ValidationError> {
        if !self.require_level5 || self.allow_lower_levels {
            return Ok(());
        }

        if !is_level5_sig_alg(&self.default_alg) {
            return Err(ValidationError::Level5Requirement(format!(
                "default_alg '{}' is not Level-5",
                self.default_alg
            )));
        }

        if !self.allow_algs.iter().all(|alg| is_level5_sig_alg(alg)) {
            return Err(ValidationError::Level5Requirement(
                "allow_algs must be Level-5 only".into(),
            ));
        }

        match self.digest_alg.as_str() {
            "sha512" | "shake256-64" => Ok(()),
            other => Err(ValidationError::Level5Requirement(format!(
                "digest_alg '{}' is not permitted for Level-5",
                other
            ))),
        }
    }

    /// Validate that the collected signatures satisfy the quorum constraint.
    pub fn ensure_quorum(&self, collected: usize) -> Result<(), ValidationError> {
        if let Some(req) = &self.required_signatures {
            req.validate()?;
            if !req.is_satisfied(collected) {
                return Err(ValidationError::QuorumUnsatisfied {
                    required_m: req.m,
                    total_n: req.n,
                    collected,
                });
            }
        }
        Ok(())
    }
}

/// M-of-N multi-signature requirement.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequiredSignatures {
    pub m: u8,
    pub n: u8,
}

impl RequiredSignatures {
    fn validate(&self) -> Result<(), ValidationError> {
        if self.m == 0 || self.n == 0 || self.m > self.n {
            return Err(ValidationError::InvalidQuorum {
                m: self.m,
                n: self.n,
            });
        }
        Ok(())
    }

    fn is_satisfied(&self, collected: usize) -> bool {
        let collected = collected as u8;
        collected >= self.m && collected <= self.n
    }
}

/// Error while loading or parsing a policy file.
#[derive(Debug)]
pub enum Error {
    Io(std::io::Error),
    Parse(String),
    Unsupported(&'static str),
}

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::Io(err)
    }
}

/// Policy validation failures (enforced at runtime).
#[derive(Debug)]
pub enum ValidationError {
    FipsRequired,
    InvalidQuorum {
        m: u8,
        n: u8,
    },
    QuorumUnsatisfied {
        required_m: u8,
        total_n: u8,
        collected: usize,
    },
    Level5Requirement(String),
}

/// Explicit serialization formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    Json,
    Yaml,
}

/// Deserialize policy from a string using the provided (or inferred) format.
pub fn load_policy_str(contents: &str, fmt: Option<Format>) -> Result<Policy, Error> {
    match fmt.unwrap_or(Format::Json) {
        Format::Json => {
            #[cfg(feature = "json")]
            {
                serde_json::from_str::<Policy>(contents).map_err(|e| Error::Parse(e.to_string()))
            }
            #[cfg(not(feature = "json"))]
            {
                Err(Error::Unsupported("json feature disabled"))
            }
        }
        Format::Yaml => {
            #[cfg(feature = "yaml")]
            {
                serde_yaml::from_str::<Policy>(contents).map_err(|e| Error::Parse(e.to_string()))
            }
            #[cfg(not(feature = "yaml"))]
            {
                Err(Error::Unsupported("yaml feature disabled"))
            }
        }
    }
}

/// Load policy from disk, inferring format by extension (`.json`, `.yaml`, `.yml`).
pub fn load_policy_file(path: &Path) -> Result<Policy, Error> {
    let data = fs::read_to_string(path)?;
    let fmt = match path.extension().and_then(|s| s.to_str()) {
        Some("yaml") | Some("yml") => Some(Format::Yaml),
        _ => Some(Format::Json),
    };
    load_policy_str(&data, fmt)
}

fn is_level5_sig_alg(alg: &str) -> bool {
    matches!(
        alg,
        "mldsa-87"
            | "slh-dsa-sha2-256s"
            | "slh-dsa-sha2-256f"
            | "slh-dsa-shake-256s"
            | "slh-dsa-shake-256f"
    )
}

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

    const SAMPLE: &str = r#"{
        "default_alg": "mldsa-87",
        "allow_algs": ["mldsa-87", "slh-dsa-sha2-256s"],
        "required_signatures": {"m": 2, "n": 3},
        "offline_ok": false,
        "require_fips_only": true,
        "require_level5": true,
        "digest_alg": "sha512"
    }"#;

    #[test]
    fn parse_json_policy() {
        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        assert_eq!(pol.default_alg, "mldsa-87");
        assert_eq!(pol.allow_algs.len(), 2);
        assert_eq!(pol.required_signatures.unwrap().m, 2);
        assert!(pol.require_fips_only);
        assert!(pol.require_level5);
        assert_eq!(pol.digest_alg, "sha512");
        assert!(!pol.offline_ok);
    }

    #[test]
    fn enforce_fips_requirement() {
        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        assert!(pol.ensure_fips(false).is_ok());
        assert!(matches!(
            pol.ensure_fips(true),
            Err(ValidationError::FipsRequired)
        ));
    }

    #[test]
    fn enforce_level5_requirement() {
        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        assert!(pol.enforce_level5().is_ok());
    }

    #[test]
    fn quorum_validation() {
        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        assert!(matches!(
            pol.ensure_quorum(1),
            Err(ValidationError::QuorumUnsatisfied { .. })
        ));
        assert!(pol.ensure_quorum(2).is_ok());
        assert!(pol.ensure_quorum(3).is_ok());
        assert!(matches!(
            pol.ensure_quorum(4),
            Err(ValidationError::QuorumUnsatisfied { .. })
        ));
    }

    #[test]
    #[cfg(feature = "json")]
    fn canonical_hash_stable() {
        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        let digest = pol.canonical_hash();
        assert_eq!(digest.len(), 32);
        // Hash should be deterministic across calls
        assert_eq!(digest, pol.canonical_hash());
    }

    #[test]
    #[cfg(feature = "json")]
    fn canonical_hash_ignores_key_order() {
        let pol_a = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
        let alt = r#"{
            "allow_lower_levels": false,
            "digest_alg": "sha512",
            "require_fips_only": true,
            "offline_ok": false,
            "required_signatures": {"n": 3, "m": 2},
            "allow_algs": ["slh-dsa-sha2-256s", "mldsa-87"],
            "require_level5": true,
            "default_alg": "mldsa-87"
        }"#;
        let pol_b = load_policy_str(alt, Some(Format::Json)).expect("policy");
        assert_eq!(pol_a.canonical_hash(), pol_b.canonical_hash());
    }

    #[cfg(feature = "yaml")]
    #[test]
    fn parse_yaml_policy() {
        let pol = load_policy_str(
            r#"default_alg: mldsa-87
allow_algs: [mldsa-87, slh-dsa-sha2-256s]
required_signatures: {m: 2, n: 3}
offline_ok: true
require_level5: true
digest_alg: sha512
"#,
            Some(Format::Yaml),
        )
        .expect("policy");
        assert!(pol.offline_ok);
    }
}