qs_policy/
lib.rs

1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4use std::{fs, path::Path};
5
6#[cfg(feature = "json")]
7use sha2::{Digest, Sha256};
8
9#[cfg(feature = "json")]
10use serde_json::{self, Map, Value};
11
12/// Signing policy describing allowed algorithms and approval requirements.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Policy {
15    /// Default algorithm identifier (e.g., "mldsa-87").
16    pub default_alg: String,
17    /// Whitelist of allowed algorithms.
18    pub allow_algs: Vec<String>,
19    /// Optional multi-party signature quorum requirement.
20    #[serde(default)]
21    pub required_signatures: Option<RequiredSignatures>,
22    /// Whether signing is permitted without network transparency.
23    #[serde(default)]
24    pub offline_ok: bool,
25    /// Enforce FIPS-approved algorithms only (defaults to true).
26    #[serde(default = "Policy::default_require_fips")]
27    pub require_fips_only: bool,
28    /// Enforce NIST PQC Level-5 defaults when true (default).
29    #[serde(default = "Policy::default_require_level5")]
30    pub require_level5: bool,
31    /// Digest algorithm (sha512 or shake256-64 for Level-5).
32    #[serde(default = "Policy::default_digest_alg")]
33    pub digest_alg: String,
34    /// Explicit escape hatch for non-Level-5 algorithms.
35    #[serde(default)]
36    pub allow_lower_levels: bool,
37}
38
39impl Policy {
40    const fn default_require_fips() -> bool {
41        true
42    }
43
44    const fn default_require_level5() -> bool {
45        true
46    }
47
48    fn default_digest_alg() -> String {
49        "sha512".into()
50    }
51
52    /// Compute a canonical SHA-256 hash of the policy for inclusion in intents.
53    #[cfg(feature = "json")]
54    pub fn canonical_hash(&self) -> [u8; 32] {
55        let value = serde_json::to_value(self).expect("policy serializable");
56        let sorted = Self::sort_json(value);
57        let encoded = serde_json::to_vec(&sorted).expect("canonical JSON encode");
58        let digest = Sha256::digest(&encoded);
59        let mut out = [0u8; 32];
60        out.copy_from_slice(&digest);
61        out
62    }
63
64    #[cfg(feature = "json")]
65    fn sort_json(value: Value) -> Value {
66        match value {
67            Value::Object(mut map) => {
68                let mut keys: Vec<String> = map.keys().cloned().collect();
69                keys.sort();
70                let mut ordered = Map::new();
71                for key in keys {
72                    let v = map.remove(&key).expect("key removed");
73                    ordered.insert(key, Self::sort_json(v));
74                }
75                Value::Object(ordered)
76            }
77            Value::Array(arr) => {
78                let mut items: Vec<Value> = arr.into_iter().map(Self::sort_json).collect();
79                items.sort_by(|a, b| {
80                    serde_json::to_string(a)
81                        .unwrap()
82                        .cmp(&serde_json::to_string(b).unwrap())
83                });
84                Value::Array(items)
85            }
86            other => other,
87        }
88    }
89
90    #[cfg(not(feature = "json"))]
91    pub fn canonical_hash(&self) -> [u8; 32] {
92        panic!("Policy::canonical_hash requires the `json` feature");
93    }
94
95    /// Enforce that non-FIPS algorithms are not used when policy forbids it.
96    pub fn ensure_fips(&self, allow_nonfips: bool) -> Result<(), ValidationError> {
97        if self.require_fips_only && allow_nonfips {
98            return Err(ValidationError::FipsRequired);
99        }
100        Ok(())
101    }
102
103    /// Enforce Level-5 defaults when required.
104    pub fn enforce_level5(&self) -> Result<(), ValidationError> {
105        if !self.require_level5 || self.allow_lower_levels {
106            return Ok(());
107        }
108
109        if !is_level5_sig_alg(&self.default_alg) {
110            return Err(ValidationError::Level5Requirement(format!(
111                "default_alg '{}' is not Level-5",
112                self.default_alg
113            )));
114        }
115
116        if !self.allow_algs.iter().all(|alg| is_level5_sig_alg(alg)) {
117            return Err(ValidationError::Level5Requirement(
118                "allow_algs must be Level-5 only".into(),
119            ));
120        }
121
122        match self.digest_alg.as_str() {
123            "sha512" | "shake256-64" => Ok(()),
124            other => Err(ValidationError::Level5Requirement(format!(
125                "digest_alg '{}' is not permitted for Level-5",
126                other
127            ))),
128        }
129    }
130
131    /// Validate that the collected signatures satisfy the quorum constraint.
132    pub fn ensure_quorum(&self, collected: usize) -> Result<(), ValidationError> {
133        if let Some(req) = &self.required_signatures {
134            req.validate()?;
135            if !req.is_satisfied(collected) {
136                return Err(ValidationError::QuorumUnsatisfied {
137                    required_m: req.m,
138                    total_n: req.n,
139                    collected,
140                });
141            }
142        }
143        Ok(())
144    }
145}
146
147/// M-of-N multi-signature requirement.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub struct RequiredSignatures {
150    pub m: u8,
151    pub n: u8,
152}
153
154impl RequiredSignatures {
155    fn validate(&self) -> Result<(), ValidationError> {
156        if self.m == 0 || self.n == 0 || self.m > self.n {
157            return Err(ValidationError::InvalidQuorum {
158                m: self.m,
159                n: self.n,
160            });
161        }
162        Ok(())
163    }
164
165    fn is_satisfied(&self, collected: usize) -> bool {
166        let collected = collected as u8;
167        collected >= self.m && collected <= self.n
168    }
169}
170
171/// Error while loading or parsing a policy file.
172#[derive(Debug)]
173pub enum Error {
174    Io(std::io::Error),
175    Parse(String),
176    Unsupported(&'static str),
177}
178
179impl From<std::io::Error> for Error {
180    fn from(err: std::io::Error) -> Self {
181        Error::Io(err)
182    }
183}
184
185/// Policy validation failures (enforced at runtime).
186#[derive(Debug)]
187pub enum ValidationError {
188    FipsRequired,
189    InvalidQuorum {
190        m: u8,
191        n: u8,
192    },
193    QuorumUnsatisfied {
194        required_m: u8,
195        total_n: u8,
196        collected: usize,
197    },
198    Level5Requirement(String),
199}
200
201/// Explicit serialization formats.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Format {
204    Json,
205    Yaml,
206}
207
208/// Deserialize policy from a string using the provided (or inferred) format.
209pub fn load_policy_str(contents: &str, fmt: Option<Format>) -> Result<Policy, Error> {
210    match fmt.unwrap_or(Format::Json) {
211        Format::Json => {
212            #[cfg(feature = "json")]
213            {
214                serde_json::from_str::<Policy>(contents).map_err(|e| Error::Parse(e.to_string()))
215            }
216            #[cfg(not(feature = "json"))]
217            {
218                Err(Error::Unsupported("json feature disabled"))
219            }
220        }
221        Format::Yaml => {
222            #[cfg(feature = "yaml")]
223            {
224                serde_yaml::from_str::<Policy>(contents).map_err(|e| Error::Parse(e.to_string()))
225            }
226            #[cfg(not(feature = "yaml"))]
227            {
228                Err(Error::Unsupported("yaml feature disabled"))
229            }
230        }
231    }
232}
233
234/// Load policy from disk, inferring format by extension (`.json`, `.yaml`, `.yml`).
235pub fn load_policy_file(path: &Path) -> Result<Policy, Error> {
236    let data = fs::read_to_string(path)?;
237    let fmt = match path.extension().and_then(|s| s.to_str()) {
238        Some("yaml") | Some("yml") => Some(Format::Yaml),
239        _ => Some(Format::Json),
240    };
241    load_policy_str(&data, fmt)
242}
243
244fn is_level5_sig_alg(alg: &str) -> bool {
245    matches!(
246        alg,
247        "mldsa-87"
248            | "slh-dsa-sha2-256s"
249            | "slh-dsa-sha2-256f"
250            | "slh-dsa-shake-256s"
251            | "slh-dsa-shake-256f"
252    )
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    const SAMPLE: &str = r#"{
260        "default_alg": "mldsa-87",
261        "allow_algs": ["mldsa-87", "slh-dsa-sha2-256s"],
262        "required_signatures": {"m": 2, "n": 3},
263        "offline_ok": false,
264        "require_fips_only": true,
265        "require_level5": true,
266        "digest_alg": "sha512"
267    }"#;
268
269    #[test]
270    fn parse_json_policy() {
271        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
272        assert_eq!(pol.default_alg, "mldsa-87");
273        assert_eq!(pol.allow_algs.len(), 2);
274        assert_eq!(pol.required_signatures.unwrap().m, 2);
275        assert!(pol.require_fips_only);
276        assert!(pol.require_level5);
277        assert_eq!(pol.digest_alg, "sha512");
278        assert!(!pol.offline_ok);
279    }
280
281    #[test]
282    fn enforce_fips_requirement() {
283        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
284        assert!(pol.ensure_fips(false).is_ok());
285        assert!(matches!(
286            pol.ensure_fips(true),
287            Err(ValidationError::FipsRequired)
288        ));
289    }
290
291    #[test]
292    fn enforce_level5_requirement() {
293        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
294        assert!(pol.enforce_level5().is_ok());
295    }
296
297    #[test]
298    fn quorum_validation() {
299        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
300        assert!(matches!(
301            pol.ensure_quorum(1),
302            Err(ValidationError::QuorumUnsatisfied { .. })
303        ));
304        assert!(pol.ensure_quorum(2).is_ok());
305        assert!(pol.ensure_quorum(3).is_ok());
306        assert!(matches!(
307            pol.ensure_quorum(4),
308            Err(ValidationError::QuorumUnsatisfied { .. })
309        ));
310    }
311
312    #[test]
313    #[cfg(feature = "json")]
314    fn canonical_hash_stable() {
315        let pol = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
316        let digest = pol.canonical_hash();
317        assert_eq!(digest.len(), 32);
318        // Hash should be deterministic across calls
319        assert_eq!(digest, pol.canonical_hash());
320    }
321
322    #[test]
323    #[cfg(feature = "json")]
324    fn canonical_hash_ignores_key_order() {
325        let pol_a = load_policy_str(SAMPLE, Some(Format::Json)).expect("policy");
326        let alt = r#"{
327            "allow_lower_levels": false,
328            "digest_alg": "sha512",
329            "require_fips_only": true,
330            "offline_ok": false,
331            "required_signatures": {"n": 3, "m": 2},
332            "allow_algs": ["slh-dsa-sha2-256s", "mldsa-87"],
333            "require_level5": true,
334            "default_alg": "mldsa-87"
335        }"#;
336        let pol_b = load_policy_str(alt, Some(Format::Json)).expect("policy");
337        assert_eq!(pol_a.canonical_hash(), pol_b.canonical_hash());
338    }
339
340    #[cfg(feature = "yaml")]
341    #[test]
342    fn parse_yaml_policy() {
343        let pol = load_policy_str(
344            r#"default_alg: mldsa-87
345allow_algs: [mldsa-87, slh-dsa-sha2-256s]
346required_signatures: {m: 2, n: 3}
347offline_ok: true
348require_level5: true
349digest_alg: sha512
350"#,
351            Some(Format::Yaml),
352        )
353        .expect("policy");
354        assert!(pol.offline_ok);
355    }
356}