use serde::{Deserialize, Serialize};
use std::fmt;
use super::lease::{EnrollmentKeySet, HiveLeaseEnvelopeV1};
use super::{
HIVE_ENGINE_CLIENT_ID, TOKEN_ASSERTION_DOMAIN, require_schema, validate_b64_len, validate_id,
validate_text,
};
use crate::ValidationError;
pub const DEVICE_CODE_REQUEST_SCHEMA: &str = "leanctx.hive.device-code-request/v1";
pub const DEVICE_CODE_SCHEMA: &str = "leanctx.hive.device-code/v1";
pub const DEVICE_TOKEN_REQUEST_SCHEMA: &str = "leanctx.hive.device-token-request/v1";
pub const ENROLLMENT_SCHEMA: &str = "leanctx.hive.enrollment/v1";
pub const TOKEN_REQUEST_SCHEMA: &str = "leanctx.hive.token-request/v1";
pub const ACCESS_TOKEN_SCHEMA: &str = "leanctx.hive.access-token/v1";
pub const MAX_INSTANCE_LABEL_CHARS: usize = 64;
pub const MAX_ASSERTION_SKEW_SECONDS: i64 = 300;
pub const MAX_ACCESS_TOKEN_SECONDS: u32 = 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EngineOs {
Macos,
Linux,
Windows,
Other,
}
fn validate_engine_version(value: &str) -> Result<(), ValidationError> {
let ok = !value.is_empty()
&& value.len() <= 32
&& value.as_bytes()[0].is_ascii_digit()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'));
if ok {
Ok(())
} else {
Err(ValidationError::new(
"instance_version must be a semantic version of at most 32 characters",
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceCodeRequestV1 {
pub schema: String,
pub client_id: String,
pub instance_version: String,
pub os: EngineOs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_label: Option<String>,
}
impl DeviceCodeRequestV1 {
pub fn new(instance_version: impl Into<String>, os: EngineOs) -> Self {
Self {
schema: DEVICE_CODE_REQUEST_SCHEMA.into(),
client_id: HIVE_ENGINE_CLIENT_ID.into(),
instance_version: instance_version.into(),
os,
instance_label: None,
}
}
#[must_use]
pub fn with_label(mut self, label: Option<String>) -> Self {
self.instance_label = label;
self
}
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, DEVICE_CODE_REQUEST_SCHEMA)?;
if self.client_id != HIVE_ENGINE_CLIENT_ID {
return Err(ValidationError::new(format!(
"client_id must be {HIVE_ENGINE_CLIENT_ID}"
)));
}
if let Some(label) = &self.instance_label {
validate_text(label, MAX_INSTANCE_LABEL_CHARS, "instance_label", false)?;
}
validate_engine_version(&self.instance_version)
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceCodeV1 {
pub schema: String,
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub interval: u32,
pub expires_in: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
}
impl DeviceCodeV1 {
pub fn offers_device_setup(&self) -> bool {
self.features.iter().any(|f| f == FEATURE_DEVICE_SETUP)
}
}
pub const FEATURE_DEVICE_SETUP: &str = "device_setup";
pub const DEVICE_SETUP_REQUEST_SCHEMA: &str = "leanctx.hive.device-setup/v1";
pub const DEVICE_SETUP_RESULT_SCHEMA: &str = "leanctx.hive.device-setup-result/v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceSetupRequestV1 {
pub schema: String,
pub device_code: String,
pub project: Option<DeviceSetupProjectV1>,
pub shares: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceSetupProjectV1 {
pub name: String,
pub remote: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceSetupResultV1 {
pub schema: String,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault: Option<String>,
}
impl fmt::Debug for DeviceCodeV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DeviceCodeV1")
.field("device_code", &"<redacted>")
.field("user_code", &self.user_code)
.field("verification_uri", &self.verification_uri)
.field("interval", &self.interval)
.field("expires_in", &self.expires_in)
.finish_non_exhaustive()
}
}
fn is_loopback_http(uri: &str) -> bool {
uri.strip_prefix("http://")
.and_then(|rest| rest.split(['/', ':', '?']).next())
.is_some_and(|host| host == "localhost" || host == "127.0.0.1")
}
impl DeviceCodeV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, DEVICE_CODE_SCHEMA)?;
validate_text(&self.device_code, 256, "device_code", false)?;
validate_text(&self.user_code, 16, "user_code", false)?;
for uri in [&self.verification_uri, &self.verification_uri_complete] {
if !(uri.starts_with("https://") || is_loopback_http(uri)) || uri.len() > 512 {
return Err(ValidationError::new(
"verification uri must be https (http only on localhost/127.0.0.1)",
));
}
}
if !(1..=60).contains(&self.interval) || !(60..=3600).contains(&self.expires_in) {
return Err(ValidationError::new(
"interval must be 1-60 s and expires_in 60-3600 s",
));
}
Ok(())
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeviceTokenRequestV1 {
pub schema: String,
pub device_code: String,
pub instance_public_key: String,
pub instance_kx_public_key: String,
pub instance_label: String,
pub instance_version: String,
pub os: EngineOs,
}
impl fmt::Debug for DeviceTokenRequestV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DeviceTokenRequestV1")
.field("device_code", &"<redacted>")
.field("instance_public_key", &self.instance_public_key)
.field("instance_label", &self.instance_label)
.field("instance_version", &self.instance_version)
.field("os", &self.os)
.finish_non_exhaustive()
}
}
impl DeviceTokenRequestV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, DEVICE_TOKEN_REQUEST_SCHEMA)?;
validate_text(&self.device_code, 256, "device_code", false)?;
validate_b64_len(&self.instance_public_key, 32, "instance_public_key")?;
validate_b64_len(&self.instance_kx_public_key, 32, "instance_kx_public_key")?;
validate_text(
&self.instance_label,
MAX_INSTANCE_LABEL_CHARS,
"instance_label",
false,
)?;
validate_engine_version(&self.instance_version)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OrgRefV1 {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub company: Option<CompanyRefV1>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompanyRefV1 {
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrollmentV1 {
pub schema: String,
pub instance_id: String,
pub org: OrgRefV1,
pub lease: HiveLeaseEnvelopeV1,
pub keys: EnrollmentKeySet,
pub org_salt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
}
impl EnrollmentV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, ENROLLMENT_SCHEMA)?;
validate_id(&self.instance_id, "instance_id")?;
validate_id(&self.org.id, "org.id")?;
validate_text(&self.org.name, 128, "org.name", false)?;
validate_id(&self.org_salt_id, "org_salt_id")?;
if let Some(web_url) = &self.web_url {
super::validate_origin(web_url, "web_url")?;
}
self.keys.validate()?;
self.lease.validate_shape()?;
if !self.keys.signs_leases_as(&self.lease.kid) {
return Err(ValidationError::new(
"the lease kid names no lease key of the enrollment's key set",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstanceTokenRequestV1 {
pub schema: String,
pub iat: i64,
pub nonce: String,
pub assertion: String,
}
impl InstanceTokenRequestV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, TOKEN_REQUEST_SCHEMA)?;
if self.iat <= 0 {
return Err(ValidationError::new("iat must be positive unix seconds"));
}
validate_nonce(&self.nonce)?;
validate_b64_len(&self.assertion, 64, "assertion")
}
}
fn validate_nonce(nonce: &str) -> Result<(), ValidationError> {
if (16..=64).contains(&nonce.len())
&& nonce
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
Ok(())
} else {
Err(ValidationError::new(
"nonce must be 16-64 characters of [A-Za-z0-9_-]",
))
}
}
pub fn token_assertion_input(instance_id: &str, iat: i64, nonce: &str) -> Vec<u8> {
format!("{TOKEN_ASSERTION_DOMAIN}{instance_id}\n{iat}\n{nonce}").into_bytes()
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessTokenV1 {
pub schema: String,
pub access_token: String,
pub expires_in: u32,
}
impl fmt::Debug for AccessTokenV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AccessTokenV1")
.field("access_token", &"<redacted>")
.field("expires_in", &self.expires_in)
.finish_non_exhaustive()
}
}
impl AccessTokenV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, ACCESS_TOKEN_SCHEMA)?;
validate_text(&self.access_token, 4096, "access_token", false)?;
if !(1..=MAX_ACCESS_TOKEN_SECONDS).contains(&self.expires_in) {
return Err(ValidationError::new("expires_in must be 1-3600 s"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_device_code_label_follows_the_text_rule() {
let request = |label: Option<&str>| {
DeviceCodeRequestV1::new("3.12.0", EngineOs::Macos)
.with_label(label.map(str::to_string))
};
assert!(request(None).validate().is_ok());
assert!(request(Some("build-box")).validate().is_ok());
let long = "x".repeat(MAX_INSTANCE_LABEL_CHARS + 1);
for bad in [
"",
"line\nbreak",
"zero\u{200b}width",
"a\u{202e}b",
long.as_str(),
] {
assert!(request(Some(bad)).validate().is_err(), "{bad:?}");
}
let unlabelled = serde_json::to_value(request(None)).unwrap();
assert!(
unlabelled.get("instance_label").is_none(),
"older services refuse unknown fields"
);
}
#[test]
fn assertion_input_is_the_documented_text() {
assert_eq!(
token_assertion_input("inst_1", 1_792_500_000, "n0nce-n0nce-n0nce"),
b"leanctx.hive.token.v1\ninst_1\n1792500000\nn0nce-n0nce-n0nce".to_vec()
);
}
#[test]
fn secrets_stay_out_of_debug() {
let token = AccessTokenV1 {
schema: ACCESS_TOKEN_SCHEMA.into(),
access_token: "atl_secret_value".into(),
expires_in: 3600,
};
assert!(!format!("{token:?}").contains("atl_secret_value"));
let code = DeviceCodeV1 {
schema: DEVICE_CODE_SCHEMA.into(),
device_code: "dc_secret_value".into(),
user_code: "WDJB-MJHT".into(),
verification_uri: "https://hive.leanctx.com/device".into(),
verification_uri_complete: "https://hive.leanctx.com/device?code=WDJB-MJHT".into(),
interval: 5,
expires_in: 900,
features: Vec::new(),
};
assert!(!format!("{code:?}").contains("dc_secret_value"));
assert!(!code.offers_device_setup());
}
#[test]
fn device_setup_documents_are_the_contract_fixtures() {
fn round<T: serde::de::DeserializeOwned + Serialize>(raw: &str) {
let value: serde_json::Value = serde_json::from_str(raw).unwrap();
let typed: T = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(typed).unwrap(), value);
}
round::<DeviceSetupRequestV1>(include_str!(
"../../tests/fixtures/hive-v2/routes/device_setup.request.json"
));
round::<DeviceSetupResultV1>(include_str!(
"../../tests/fixtures/hive-v2/routes/device_setup_result.request.json"
));
let mut answer = serde_json::json!({
"schema": DEVICE_CODE_SCHEMA, "device_code": "dc_x", "user_code": "WDJB-MJHT",
"verification_uri": "https://hive.leanctx.com/device",
"verification_uri_complete": "https://hive.leanctx.com/device?user_code=WDJB-MJHT",
"interval": 5, "expires_in": 900,
});
let old: DeviceCodeV1 = serde_json::from_value(answer.clone()).unwrap();
assert!(!old.offers_device_setup(), "a C0 2.0 Hive lists nothing");
answer["features"] = serde_json::json!([FEATURE_DEVICE_SETUP, "later"]);
let new: DeviceCodeV1 = serde_json::from_value(answer).unwrap();
assert!(new.offers_device_setup());
}
#[test]
fn an_enrollment_with_key_set_v1_decodes_and_names_its_lease_key() {
let raw: serde_json::Value = serde_json::from_str(include_str!(
"../../tests/fixtures/hive/enrollment.response.json"
))
.unwrap();
let enrollment: EnrollmentV1 = serde_json::from_value(raw.clone()).unwrap();
enrollment.validate().unwrap();
assert!(matches!(enrollment.keys, EnrollmentKeySet::V1(_)));
assert_eq!(serde_json::to_value(&enrollment).unwrap(), raw);
let mut other_kid = enrollment;
other_kid.lease.kid = "k-unknown".into();
assert!(other_kid.validate().is_err(), "the kid follows the set");
}
#[test]
fn an_enrollment_with_key_set_v3_decodes_and_names_its_policy_key() {
let raw: serde_json::Value = serde_json::from_str(include_str!(
"../../tests/fixtures/hive-v2/routes/enrollment.for_2x.response.json"
))
.unwrap();
let enrollment: EnrollmentV1 = serde_json::from_value(raw.clone()).unwrap();
enrollment.validate().unwrap();
let EnrollmentKeySet::V3(set) = &enrollment.keys else {
panic!("a v3 set decodes as v3");
};
assert_eq!(
set.lease_key(&enrollment.lease.kid)
.map(|k| k.key_id.as_str()),
Some("552e43422b3589fa")
);
assert_eq!(serde_json::to_value(&enrollment).unwrap(), raw);
let mut event_kid = enrollment.clone();
event_kid.lease.kid = "4fc7bebc302357bd".into();
assert!(event_kid.validate().is_err());
let mut broken = raw;
broken["keys"]["keys"][0]
.as_object_mut()
.unwrap()
.remove("key_id");
assert!(serde_json::from_value::<EnrollmentV1>(broken).is_err());
}
#[test]
fn requests_refuse_unknown_fields() {
let json = r#"{"schema":"leanctx.hive.device-code-request/v1","client_id":"lean-ctx-engine","instance_version":"3.12.0","os":"macos","extra":true}"#;
assert!(serde_json::from_str::<DeviceCodeRequestV1>(json).is_err());
}
}