use chrono::{DateTime, Duration, FixedOffset};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::lease::MAX_LEASE_FUTURE_SKEW_MINUTES;
use super::{
parse_time, require_schema, signing_input_without_signature, validate_b64_len, validate_id,
validate_origin,
};
use crate::{UtcTimestamp, ValidationError};
pub const ROOT_CERT_SCHEMA: &str = "leanctx.hive.root-cert/v1";
pub const ROOT_CERT_SIGNATURE_DOMAIN: &str = "leanctx.hive.root-cert.v1\n";
pub const MAX_ROOT_CERT_DAYS: i64 = 400;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RootCertPlanV1 {
Enterprise,
Team,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RootCertV1 {
pub schema: String,
pub serial: String,
pub issuer_key_id: String,
pub subject: String,
pub hive_origin: String,
pub root_ed25519_pub: String,
pub org_ids: Vec<String>,
pub plans: Vec<RootCertPlanV1>,
pub max_seats: u32,
pub issued_at: UtcTimestamp,
pub not_before: UtcTimestamp,
pub not_after: UtcTimestamp,
pub signature: String,
}
impl RootCertV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, ROOT_CERT_SCHEMA)?;
validate_id(&self.serial, "serial")?;
let issuer = self
.issuer_key_id
.strip_prefix("key:ed25519-")
.ok_or_else(|| ValidationError::new("issuer_key_id must start with key:ed25519-"))?;
if issuer.len() != 32
|| !issuer
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Err(ValidationError::new(
"issuer_key_id carries 32 lowercase hex",
));
}
let subject_chars = self.subject.chars().count();
if subject_chars == 0
|| subject_chars > 200
|| self.subject.trim() != self.subject
|| self.subject.chars().any(char::is_control)
{
return Err(ValidationError::new(
"subject must be 1-200 characters on one line without surrounding spaces",
));
}
validate_origin(&self.hive_origin, "hive_origin")?;
validate_b64_len(&self.root_ed25519_pub, 32, "root_ed25519_pub")?;
if self.org_ids.is_empty() || self.org_ids.len() > 16 {
return Err(ValidationError::new("org_ids must list 1-16 ids"));
}
let mut seen = std::collections::BTreeSet::new();
for org in &self.org_ids {
validate_id(org, "org_ids")?;
if !seen.insert(org.as_str()) {
return Err(ValidationError::new("org_ids repeat"));
}
}
if self.plans.is_empty() || !self.plans.windows(2).all(|pair| pair[0] < pair[1]) {
return Err(ValidationError::new(
"plans must be a sorted, non-empty set",
));
}
if self.max_seats == 0 {
return Err(ValidationError::new("max_seats must be positive"));
}
let from = parse_time(&self.not_before)?;
let until = parse_time(&self.not_after)?;
parse_time(&self.issued_at)?;
if from >= until || until - from > Duration::days(MAX_ROOT_CERT_DAYS) {
return Err(ValidationError::new(
"not_after must follow not_before by at most 400 days",
));
}
validate_b64_len(&self.signature, 64, "signature")
}
pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
signing_input_without_signature(ROOT_CERT_SIGNATURE_DOMAIN, raw)
}
pub fn valid_at(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
let skew = Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES);
Ok(
parse_time(&self.not_before)? - skew <= now
&& now < parse_time(&self.not_after)? + skew,
)
}
pub fn covers_org(&self, org_id: &str) -> bool {
self.org_ids.iter().any(|org| org == org_id)
}
}