quantum-sign 0.1.7

Quantum-Sign: post-quantum signatures, format, policy, and CLI in one crate
Documentation
#![forbid(unsafe_code)]

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

/// Policy definition for signing operations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
    /// Default signature algorithm (e.g., "mldsa-87").
    pub default_alg: String,
    /// Artifact digest algorithm (e.g., "sha512").
    pub digest_alg: String,
    /// Allowed signature algorithm identifiers.
    pub allow_algs: Vec<String>,
    /// Required signature structure (m-of-n or required kids).
    pub required_signatures: RequiredSignatures,
    /// Whether offline verification is acceptable.
    pub offline_ok: bool,
    /// Enforce FIPS-only digest algorithms.
    pub require_fips_only: bool,
    /// Optional comments (ignored by canonical hash).
    pub comments: Option<String>,
}

/// Canonical hash of a policy (for binding to signatures).
pub fn canonical_hash(policy: &Policy) -> [u8; 32] {
    // Serialize a reduced, order-stable view. Sort allow_algs and RequiredKids for order-invariant hashing.
    #[derive(Serialize)]
    enum CanonRequiredSignatures {
        Quorum { m: u8, n: u8 },
        RequiredKids { required: Vec<String> },
    }

    #[derive(Serialize)]
    struct Canon {
        default_alg: String,
        digest_alg: String,
        allow_algs: Vec<String>,
        required_signatures: CanonRequiredSignatures,
        offline_ok: bool,
        require_fips_only: bool,
    }
    let mut allow = policy.allow_algs.clone();
    allow.sort();
    let canon_rs = match &policy.required_signatures {
        RequiredSignatures::Quorum { m, n } => CanonRequiredSignatures::Quorum { m: *m, n: *n },
        RequiredSignatures::RequiredKids { required } => {
            let mut req = required.clone();
            req.sort();
            req.dedup();
            CanonRequiredSignatures::RequiredKids { required: req }
        }
    };
    let canon = Canon {
        default_alg: policy.default_alg.clone(),
        digest_alg: policy.digest_alg.clone(),
        allow_algs: allow,
        required_signatures: canon_rs,
        offline_ok: policy.offline_ok,
        require_fips_only: policy.require_fips_only,
    };
    let json = serde_json::to_vec(&canon).expect("serialize policy");
    let mut h = Sha256::new();
    h.update(json);
    let mut out = [0u8; 32];
    out.copy_from_slice(&h.finalize());
    out
}

/// Quorum policy (m-of-n) or explicit required signers.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RequiredSignatures {
    Quorum { m: u8, n: u8 },
    RequiredKids { required: Vec<String> },
}


/// 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)
}

// reserved for potential future policy-level algorithm gating