use serde::{Deserialize, Serialize};
use crate::error::ControlError;
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Resumption {
pub stek_file: String,
#[serde(default = "default_max_age_hours")]
pub max_age_hours: u32,
}
fn default_max_age_hours() -> u32 {
24
}
impl Resumption {
const MAX_AGE_HOURS_CAP: u32 = 168;
pub(crate) fn validate(&self) -> Result<(), ControlError> {
if self.max_age_hours == 0 || self.max_age_hours > Self::MAX_AGE_HOURS_CAP {
return Err(ControlError::Stek {
path: self.stek_file.clone(),
reason: format!(
"max_age_hours must be 1..={} (RFC 8446 caps ticket lifetime at 7 days), got {}",
Self::MAX_AGE_HOURS_CAP,
self.max_age_hours
),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::manifest::Manifest;
#[test]
fn resumption_parses_with_default_max_age() {
let m = Manifest::from_toml("[resumption]\nstek_file = \"tls/stek.key\"\n").unwrap();
let r = m.resumption.expect("[resumption] parses");
assert_eq!(r.stek_file, "tls/stek.key");
assert_eq!(r.max_age_hours, 24, "max_age_hours defaults to 24");
assert!(Manifest::from_toml("").unwrap().resumption.is_none());
assert!(
Manifest::from_toml("[resumption]\nstek_files = \"x\"\n").is_err(),
"a typo inside [resumption] is rejected"
);
}
#[test]
fn max_age_hours_is_range_checked() {
for (hours, ok) in [(0u32, false), (1, true), (168, true), (169, false)] {
let toml = format!("[resumption]\nstek_file = \"stek.key\"\nmax_age_hours = {hours}\n");
let r = Manifest::from_toml(&toml).unwrap().resumption.unwrap();
assert_eq!(
r.validate().is_ok(),
ok,
"max_age_hours = {hours} should be ok={ok}"
);
}
}
#[test]
fn resumption_rides_the_content_hash() {
let absent = Manifest::from_toml("").unwrap();
let shared = Manifest::from_toml("[resumption]\nstek_file = \"stek.key\"\n").unwrap();
assert_ne!(
absent.content_hash().unwrap(),
shared.content_hash().unwrap(),
"opting into shared STEK is a real config change — the hash must flip"
);
}
#[test]
fn client_auth_schema_fields_stay_sanctioned() {
fn property_names(value: &serde_json::Value, out: &mut Vec<String>) {
if let Some(object) = value.as_object() {
if let Some(properties) = object.get("properties").and_then(|p| p.as_object()) {
out.extend(properties.keys().cloned());
}
for nested in object.values() {
property_names(nested, out);
}
} else if let Some(array) = value.as_array() {
for nested in array {
property_names(nested, out);
}
}
}
let sanctioned = [
"client_auth", "client_cert_path", "client_key_path", ];
let schema: serde_json::Value =
serde_json::from_str(&crate::manifest_json_schema().unwrap()).unwrap();
let mut names = Vec::new();
property_names(&schema, &mut names);
for name in &names {
let lower = name.to_lowercase();
if ["client_ca", "client_auth", "client_cert", "mtls"]
.iter()
.any(|needle| lower.contains(needle))
{
assert!(
sanctioned.contains(&name.as_str()),
"manifest schema grew an unsanctioned client-auth field {name:?} — decide \
its shared-STEK crossing semantics (ADR 000062 (b) / 000078) and add it to \
the sanctioned list"
);
}
}
}
}