#![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};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Policy {
pub default_alg: String,
pub allow_algs: Vec<String>,
#[serde(default)]
pub required_signatures: Option<RequiredSignatures>,
#[serde(default)]
pub offline_ok: bool,
#[serde(default = "Policy::default_require_fips")]
pub require_fips_only: bool,
#[serde(default = "Policy::default_require_level5")]
pub require_level5: bool,
#[serde(default = "Policy::default_digest_alg")]
pub digest_alg: String,
#[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()
}
#[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");
}
pub fn ensure_fips(&self, allow_nonfips: bool) -> Result<(), ValidationError> {
if self.require_fips_only && allow_nonfips {
return Err(ValidationError::FipsRequired);
}
Ok(())
}
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
))),
}
}
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(())
}
}
#[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
}
}
#[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)
}
}
#[derive(Debug)]
pub enum ValidationError {
FipsRequired,
InvalidQuorum {
m: u8,
n: u8,
},
QuorumUnsatisfied {
required_m: u8,
total_n: u8,
collected: usize,
},
Level5Requirement(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Json,
Yaml,
}
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"))
}
}
}
}
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);
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);
}
}