lean_ctx_protocol/hive/
root_cert.rs1use chrono::{DateTime, Duration, FixedOffset};
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23
24use super::lease::MAX_LEASE_FUTURE_SKEW_MINUTES;
25use super::{
26 parse_time, require_schema, signing_input_without_signature, validate_b64_len, validate_id,
27 validate_origin,
28};
29use crate::{UtcTimestamp, ValidationError};
30
31pub const ROOT_CERT_SCHEMA: &str = "leanctx.hive.root-cert/v1";
32pub const ROOT_CERT_SIGNATURE_DOMAIN: &str = "leanctx.hive.root-cert.v1\n";
33pub const MAX_ROOT_CERT_DAYS: i64 = 400;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum RootCertPlanV1 {
40 Enterprise,
41 Team,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RootCertV1 {
48 pub schema: String,
49 pub serial: String,
50 pub issuer_key_id: String,
53 pub subject: String,
55 pub hive_origin: String,
57 pub root_ed25519_pub: String,
59 pub org_ids: Vec<String>,
61 pub plans: Vec<RootCertPlanV1>,
63 pub max_seats: u32,
65 pub issued_at: UtcTimestamp,
66 pub not_before: UtcTimestamp,
67 pub not_after: UtcTimestamp,
68 pub signature: String,
70}
71
72impl RootCertV1 {
73 pub fn validate(&self) -> Result<(), ValidationError> {
74 require_schema(&self.schema, ROOT_CERT_SCHEMA)?;
75 validate_id(&self.serial, "serial")?;
76 let issuer = self
77 .issuer_key_id
78 .strip_prefix("key:ed25519-")
79 .ok_or_else(|| ValidationError::new("issuer_key_id must start with key:ed25519-"))?;
80 if issuer.len() != 32
81 || !issuer
82 .bytes()
83 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
84 {
85 return Err(ValidationError::new(
86 "issuer_key_id carries 32 lowercase hex",
87 ));
88 }
89 let subject_chars = self.subject.chars().count();
90 if subject_chars == 0
91 || subject_chars > 200
92 || self.subject.trim() != self.subject
93 || self.subject.chars().any(char::is_control)
94 {
95 return Err(ValidationError::new(
96 "subject must be 1-200 characters on one line without surrounding spaces",
97 ));
98 }
99 validate_origin(&self.hive_origin, "hive_origin")?;
100 validate_b64_len(&self.root_ed25519_pub, 32, "root_ed25519_pub")?;
101 if self.org_ids.is_empty() || self.org_ids.len() > 16 {
102 return Err(ValidationError::new("org_ids must list 1-16 ids"));
103 }
104 let mut seen = std::collections::BTreeSet::new();
105 for org in &self.org_ids {
106 validate_id(org, "org_ids")?;
107 if !seen.insert(org.as_str()) {
108 return Err(ValidationError::new("org_ids repeat"));
109 }
110 }
111 if self.plans.is_empty() || !self.plans.windows(2).all(|pair| pair[0] < pair[1]) {
112 return Err(ValidationError::new(
113 "plans must be a sorted, non-empty set",
114 ));
115 }
116 if self.max_seats == 0 {
117 return Err(ValidationError::new("max_seats must be positive"));
118 }
119 let from = parse_time(&self.not_before)?;
120 let until = parse_time(&self.not_after)?;
121 parse_time(&self.issued_at)?;
122 if from >= until || until - from > Duration::days(MAX_ROOT_CERT_DAYS) {
123 return Err(ValidationError::new(
124 "not_after must follow not_before by at most 400 days",
125 ));
126 }
127 validate_b64_len(&self.signature, 64, "signature")
128 }
129
130 pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
132 signing_input_without_signature(ROOT_CERT_SIGNATURE_DOMAIN, raw)
133 }
134
135 pub fn valid_at(&self, now: DateTime<FixedOffset>) -> Result<bool, ValidationError> {
137 let skew = Duration::minutes(MAX_LEASE_FUTURE_SKEW_MINUTES);
138 Ok(
139 parse_time(&self.not_before)? - skew <= now
140 && now < parse_time(&self.not_after)? + skew,
141 )
142 }
143
144 pub fn covers_org(&self, org_id: &str) -> bool {
146 self.org_ids.iter().any(|org| org == org_id)
147 }
148}