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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Policy {
15 pub default_alg: String,
17 pub allow_algs: Vec<String>,
19 #[serde(default)]
21 pub required_signatures: Option<RequiredSignatures>,
22 #[serde(default)]
24 pub offline_ok: bool,
25 #[serde(default = "Policy::default_require_fips")]
27 pub require_fips_only: bool,
28 #[serde(default = "Policy::default_require_level5")]
30 pub require_level5: bool,
31 #[serde(default = "Policy::default_digest_alg")]
33 pub digest_alg: String,
34 #[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 #[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 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 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 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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum Format {
204 Json,
205 Yaml,
206}
207
208pub 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
234pub 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 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}