Skip to main content

lean_ctx_protocol/hive/
root_cert.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Self-host root certificates (1.7): LeanCTX vouches for the root of a
4//! self-hosted Hive.
5//!
6//! ```text
7//! vendor root (offline, compiled into engine and runtime)
8//!   └─ RootCertV1        GET /v1/root-cert (404 on the hosted Hive)
9//!        └─ self-host root → key set (GET /v1/keys) → lease
10//! ```
11//!
12//! A certificate never vouches for another certificate. It is signed by a
13//! vendor root over `"leanctx.hive.root-cert.v1\n" || canonical_json(cert
14//! without "signature")`, verified over the JSON as received, and valid
15//! while `not_before - 15 min <= now < not_after + 15 min` (no grace after
16//! `not_after`, no revocation in v1: validity is at most 400 days). The
17//! engine checks the origin, the window and that the lease's org is listed;
18//! the runtime also caps the lease's plan and seats.
19
20use 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";
33/// Longest validity of one certificate.
34pub const MAX_ROOT_CERT_DAYS: i64 = 400;
35
36/// What a self-hosted Hive may be certified for.
37#[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/// `GET /v1/root-cert`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RootCertV1 {
48    pub schema: String,
49    pub serial: String,
50    /// `key:ed25519-` + first 32 hex of sha256(vendor key); diagnostics
51    /// only, verifiers try every compiled vendor root.
52    pub issuer_key_id: String,
53    /// Display name, 1–200 characters on one line.
54    pub subject: String,
55    /// `https://host[:port]` the certificate is for.
56    pub hive_origin: String,
57    /// The self-host root that signs the key set, 32 bytes base64.
58    pub root_ed25519_pub: String,
59    /// Lease `org_id`s this root may sign for (1–16).
60    pub org_ids: Vec<String>,
61    /// Sorted, non-empty; caps `lease.plan` in the runtime.
62    pub plans: Vec<RootCertPlanV1>,
63    /// Caps `lease.seats_limit` in the runtime.
64    pub max_seats: u32,
65    pub issued_at: UtcTimestamp,
66    pub not_before: UtcTimestamp,
67    pub not_after: UtcTimestamp,
68    /// Ed25519 by a vendor root, base64.
69    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    /// Signing input of a certificate as received.
131    pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
132        signing_input_without_signature(ROOT_CERT_SIGNATURE_DOMAIN, raw)
133    }
134
135    /// Whether `now` is inside the validity window (15 min skew, no grace).
136    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    /// Whether a lease of `org_id` may be signed by the certified root.
145    pub fn covers_org(&self, org_id: &str) -> bool {
146        self.org_ids.iter().any(|org| org == org_id)
147    }
148}