1use serde::{Deserialize, Serialize};
17use std::fmt;
18
19use super::lease::{EnrollmentKeySet, HiveLeaseEnvelopeV1};
20use super::{
21 HIVE_ENGINE_CLIENT_ID, TOKEN_ASSERTION_DOMAIN, require_schema, validate_b64_len, validate_id,
22 validate_text,
23};
24use crate::ValidationError;
25
26pub const DEVICE_CODE_REQUEST_SCHEMA: &str = "leanctx.hive.device-code-request/v1";
27pub const DEVICE_CODE_SCHEMA: &str = "leanctx.hive.device-code/v1";
28pub const DEVICE_TOKEN_REQUEST_SCHEMA: &str = "leanctx.hive.device-token-request/v1";
29pub const ENROLLMENT_SCHEMA: &str = "leanctx.hive.enrollment/v1";
30pub const TOKEN_REQUEST_SCHEMA: &str = "leanctx.hive.token-request/v1";
31pub const ACCESS_TOKEN_SCHEMA: &str = "leanctx.hive.access-token/v1";
32
33pub const MAX_INSTANCE_LABEL_CHARS: usize = 64;
35pub const MAX_ASSERTION_SKEW_SECONDS: i64 = 300;
37pub const MAX_ACCESS_TOKEN_SECONDS: u32 = 3600;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum EngineOs {
44 Macos,
45 Linux,
46 Windows,
47 Other,
48}
49
50fn validate_engine_version(value: &str) -> Result<(), ValidationError> {
51 let ok = !value.is_empty()
52 && value.len() <= 32
53 && value.as_bytes()[0].is_ascii_digit()
54 && value
55 .bytes()
56 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+'));
57 if ok {
58 Ok(())
59 } else {
60 Err(ValidationError::new(
61 "instance_version must be a semantic version of at most 32 characters",
62 ))
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct DeviceCodeRequestV1 {
70 pub schema: String,
71 pub client_id: String,
73 pub instance_version: String,
74 pub os: EngineOs,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub instance_label: Option<String>,
81}
82
83impl DeviceCodeRequestV1 {
84 pub fn new(instance_version: impl Into<String>, os: EngineOs) -> Self {
85 Self {
86 schema: DEVICE_CODE_REQUEST_SCHEMA.into(),
87 client_id: HIVE_ENGINE_CLIENT_ID.into(),
88 instance_version: instance_version.into(),
89 os,
90 instance_label: None,
91 }
92 }
93
94 #[must_use]
96 pub fn with_label(mut self, label: Option<String>) -> Self {
97 self.instance_label = label;
98 self
99 }
100
101 pub fn validate(&self) -> Result<(), ValidationError> {
102 require_schema(&self.schema, DEVICE_CODE_REQUEST_SCHEMA)?;
103 if self.client_id != HIVE_ENGINE_CLIENT_ID {
104 return Err(ValidationError::new(format!(
105 "client_id must be {HIVE_ENGINE_CLIENT_ID}"
106 )));
107 }
108 if let Some(label) = &self.instance_label {
109 validate_text(label, MAX_INSTANCE_LABEL_CHARS, "instance_label", false)?;
110 }
111 validate_engine_version(&self.instance_version)
112 }
113}
114
115#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DeviceCodeV1 {
118 pub schema: String,
119 pub device_code: String,
121 pub user_code: String,
123 pub verification_uri: String,
124 pub verification_uri_complete: String,
125 pub interval: u32,
127 pub expires_in: u32,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub features: Vec<String>,
133}
134
135impl DeviceCodeV1 {
136 pub fn offers_device_setup(&self) -> bool {
138 self.features.iter().any(|f| f == FEATURE_DEVICE_SETUP)
139 }
140}
141
142pub const FEATURE_DEVICE_SETUP: &str = "device_setup";
144pub const DEVICE_SETUP_REQUEST_SCHEMA: &str = "leanctx.hive.device-setup/v1";
145pub const DEVICE_SETUP_RESULT_SCHEMA: &str = "leanctx.hive.device-setup-result/v1";
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct DeviceSetupRequestV1 {
153 pub schema: String,
154 pub device_code: String,
155 pub project: Option<DeviceSetupProjectV1>,
156 pub shares: Vec<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct DeviceSetupProjectV1 {
163 pub name: String,
165 pub remote: Option<String>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct DeviceSetupResultV1 {
174 pub schema: String,
175 pub state: String,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub vault: Option<String>,
179}
180
181impl fmt::Debug for DeviceCodeV1 {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 formatter
184 .debug_struct("DeviceCodeV1")
185 .field("device_code", &"<redacted>")
186 .field("user_code", &self.user_code)
187 .field("verification_uri", &self.verification_uri)
188 .field("interval", &self.interval)
189 .field("expires_in", &self.expires_in)
190 .finish_non_exhaustive()
191 }
192}
193
194fn is_loopback_http(uri: &str) -> bool {
196 uri.strip_prefix("http://")
197 .and_then(|rest| rest.split(['/', ':', '?']).next())
198 .is_some_and(|host| host == "localhost" || host == "127.0.0.1")
199}
200
201impl DeviceCodeV1 {
202 pub fn validate(&self) -> Result<(), ValidationError> {
203 require_schema(&self.schema, DEVICE_CODE_SCHEMA)?;
204 validate_text(&self.device_code, 256, "device_code", false)?;
205 validate_text(&self.user_code, 16, "user_code", false)?;
206 for uri in [&self.verification_uri, &self.verification_uri_complete] {
207 if !(uri.starts_with("https://") || is_loopback_http(uri)) || uri.len() > 512 {
208 return Err(ValidationError::new(
209 "verification uri must be https (http only on localhost/127.0.0.1)",
210 ));
211 }
212 }
213 if !(1..=60).contains(&self.interval) || !(60..=3600).contains(&self.expires_in) {
214 return Err(ValidationError::new(
215 "interval must be 1-60 s and expires_in 60-3600 s",
216 ));
217 }
218 Ok(())
219 }
220}
221
222#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct DeviceTokenRequestV1 {
227 pub schema: String,
228 pub device_code: String,
229 pub instance_public_key: String,
231 pub instance_kx_public_key: String,
233 pub instance_label: String,
235 pub instance_version: String,
236 pub os: EngineOs,
237}
238
239impl fmt::Debug for DeviceTokenRequestV1 {
240 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241 formatter
242 .debug_struct("DeviceTokenRequestV1")
243 .field("device_code", &"<redacted>")
244 .field("instance_public_key", &self.instance_public_key)
245 .field("instance_label", &self.instance_label)
246 .field("instance_version", &self.instance_version)
247 .field("os", &self.os)
248 .finish_non_exhaustive()
249 }
250}
251
252impl DeviceTokenRequestV1 {
253 pub fn validate(&self) -> Result<(), ValidationError> {
254 require_schema(&self.schema, DEVICE_TOKEN_REQUEST_SCHEMA)?;
255 validate_text(&self.device_code, 256, "device_code", false)?;
256 validate_b64_len(&self.instance_public_key, 32, "instance_public_key")?;
257 validate_b64_len(&self.instance_kx_public_key, 32, "instance_kx_public_key")?;
258 validate_text(
259 &self.instance_label,
260 MAX_INSTANCE_LABEL_CHARS,
261 "instance_label",
262 false,
263 )?;
264 validate_engine_version(&self.instance_version)
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct OrgRefV1 {
271 pub id: String,
272 pub name: String,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub kind: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub company: Option<CompanyRefV1>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct CompanyRefV1 {
284 pub name: String,
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289pub struct EnrollmentV1 {
290 pub schema: String,
291 pub instance_id: String,
292 pub org: OrgRefV1,
293 pub lease: HiveLeaseEnvelopeV1,
294 pub keys: EnrollmentKeySet,
297 pub org_salt_id: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub web_url: Option<String>,
302}
303
304impl EnrollmentV1 {
305 pub fn validate(&self) -> Result<(), ValidationError> {
308 require_schema(&self.schema, ENROLLMENT_SCHEMA)?;
309 validate_id(&self.instance_id, "instance_id")?;
310 validate_id(&self.org.id, "org.id")?;
311 validate_text(&self.org.name, 128, "org.name", false)?;
312 validate_id(&self.org_salt_id, "org_salt_id")?;
313 if let Some(web_url) = &self.web_url {
314 super::validate_origin(web_url, "web_url")?;
315 }
316 self.keys.validate()?;
317 self.lease.validate_shape()?;
318 if !self.keys.signs_leases_as(&self.lease.kid) {
319 return Err(ValidationError::new(
320 "the lease kid names no lease key of the enrollment's key set",
321 ));
322 }
323 Ok(())
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct InstanceTokenRequestV1 {
331 pub schema: String,
332 pub iat: i64,
334 pub nonce: String,
336 pub assertion: String,
338}
339
340impl InstanceTokenRequestV1 {
341 pub fn validate(&self) -> Result<(), ValidationError> {
342 require_schema(&self.schema, TOKEN_REQUEST_SCHEMA)?;
343 if self.iat <= 0 {
344 return Err(ValidationError::new("iat must be positive unix seconds"));
345 }
346 validate_nonce(&self.nonce)?;
347 validate_b64_len(&self.assertion, 64, "assertion")
348 }
349}
350
351fn validate_nonce(nonce: &str) -> Result<(), ValidationError> {
352 if (16..=64).contains(&nonce.len())
353 && nonce
354 .bytes()
355 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
356 {
357 Ok(())
358 } else {
359 Err(ValidationError::new(
360 "nonce must be 16-64 characters of [A-Za-z0-9_-]",
361 ))
362 }
363}
364
365pub fn token_assertion_input(instance_id: &str, iat: i64, nonce: &str) -> Vec<u8> {
368 format!("{TOKEN_ASSERTION_DOMAIN}{instance_id}\n{iat}\n{nonce}").into_bytes()
369}
370
371#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct AccessTokenV1 {
374 pub schema: String,
375 pub access_token: String,
377 pub expires_in: u32,
379}
380
381impl fmt::Debug for AccessTokenV1 {
382 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
383 formatter
384 .debug_struct("AccessTokenV1")
385 .field("access_token", &"<redacted>")
386 .field("expires_in", &self.expires_in)
387 .finish_non_exhaustive()
388 }
389}
390
391impl AccessTokenV1 {
392 pub fn validate(&self) -> Result<(), ValidationError> {
393 require_schema(&self.schema, ACCESS_TOKEN_SCHEMA)?;
394 validate_text(&self.access_token, 4096, "access_token", false)?;
395 if !(1..=MAX_ACCESS_TOKEN_SECONDS).contains(&self.expires_in) {
396 return Err(ValidationError::new("expires_in must be 1-3600 s"));
397 }
398 Ok(())
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn a_device_code_label_follows_the_text_rule() {
408 let request = |label: Option<&str>| {
409 DeviceCodeRequestV1::new("3.12.0", EngineOs::Macos)
410 .with_label(label.map(str::to_string))
411 };
412 assert!(request(None).validate().is_ok());
413 assert!(request(Some("build-box")).validate().is_ok());
414 let long = "x".repeat(MAX_INSTANCE_LABEL_CHARS + 1);
415 for bad in [
416 "",
417 "line\nbreak",
418 "zero\u{200b}width",
419 "a\u{202e}b",
420 long.as_str(),
421 ] {
422 assert!(request(Some(bad)).validate().is_err(), "{bad:?}");
423 }
424 let unlabelled = serde_json::to_value(request(None)).unwrap();
425 assert!(
426 unlabelled.get("instance_label").is_none(),
427 "older services refuse unknown fields"
428 );
429 }
430
431 #[test]
432 fn assertion_input_is_the_documented_text() {
433 assert_eq!(
434 token_assertion_input("inst_1", 1_792_500_000, "n0nce-n0nce-n0nce"),
435 b"leanctx.hive.token.v1\ninst_1\n1792500000\nn0nce-n0nce-n0nce".to_vec()
436 );
437 }
438
439 #[test]
440 fn secrets_stay_out_of_debug() {
441 let token = AccessTokenV1 {
442 schema: ACCESS_TOKEN_SCHEMA.into(),
443 access_token: "atl_secret_value".into(),
444 expires_in: 3600,
445 };
446 assert!(!format!("{token:?}").contains("atl_secret_value"));
447 let code = DeviceCodeV1 {
448 schema: DEVICE_CODE_SCHEMA.into(),
449 device_code: "dc_secret_value".into(),
450 user_code: "WDJB-MJHT".into(),
451 verification_uri: "https://hive.leanctx.com/device".into(),
452 verification_uri_complete: "https://hive.leanctx.com/device?code=WDJB-MJHT".into(),
453 interval: 5,
454 expires_in: 900,
455 features: Vec::new(),
456 };
457 assert!(!format!("{code:?}").contains("dc_secret_value"));
458 assert!(!code.offers_device_setup());
459 }
460
461 #[test]
464 fn device_setup_documents_are_the_contract_fixtures() {
465 fn round<T: serde::de::DeserializeOwned + Serialize>(raw: &str) {
466 let value: serde_json::Value = serde_json::from_str(raw).unwrap();
467 let typed: T = serde_json::from_value(value.clone()).unwrap();
468 assert_eq!(serde_json::to_value(typed).unwrap(), value);
469 }
470 round::<DeviceSetupRequestV1>(include_str!(
471 "../../tests/fixtures/hive-v2/routes/device_setup.request.json"
472 ));
473 round::<DeviceSetupResultV1>(include_str!(
474 "../../tests/fixtures/hive-v2/routes/device_setup_result.request.json"
475 ));
476 let mut answer = serde_json::json!({
477 "schema": DEVICE_CODE_SCHEMA, "device_code": "dc_x", "user_code": "WDJB-MJHT",
478 "verification_uri": "https://hive.leanctx.com/device",
479 "verification_uri_complete": "https://hive.leanctx.com/device?user_code=WDJB-MJHT",
480 "interval": 5, "expires_in": 900,
481 });
482 let old: DeviceCodeV1 = serde_json::from_value(answer.clone()).unwrap();
483 assert!(!old.offers_device_setup(), "a C0 2.0 Hive lists nothing");
484 answer["features"] = serde_json::json!([FEATURE_DEVICE_SETUP, "later"]);
485 let new: DeviceCodeV1 = serde_json::from_value(answer).unwrap();
486 assert!(new.offers_device_setup());
487 }
488
489 #[test]
492 fn an_enrollment_with_key_set_v1_decodes_and_names_its_lease_key() {
493 let raw: serde_json::Value = serde_json::from_str(include_str!(
494 "../../tests/fixtures/hive/enrollment.response.json"
495 ))
496 .unwrap();
497 let enrollment: EnrollmentV1 = serde_json::from_value(raw.clone()).unwrap();
498 enrollment.validate().unwrap();
499 assert!(matches!(enrollment.keys, EnrollmentKeySet::V1(_)));
500 assert_eq!(serde_json::to_value(&enrollment).unwrap(), raw);
501 let mut other_kid = enrollment;
502 other_kid.lease.kid = "k-unknown".into();
503 assert!(other_kid.validate().is_err(), "the kid follows the set");
504 }
505
506 #[test]
511 fn an_enrollment_with_key_set_v3_decodes_and_names_its_policy_key() {
512 let raw: serde_json::Value = serde_json::from_str(include_str!(
513 "../../tests/fixtures/hive-v2/routes/enrollment.for_2x.response.json"
514 ))
515 .unwrap();
516 let enrollment: EnrollmentV1 = serde_json::from_value(raw.clone()).unwrap();
517 enrollment.validate().unwrap();
518 let EnrollmentKeySet::V3(set) = &enrollment.keys else {
519 panic!("a v3 set decodes as v3");
520 };
521 assert_eq!(
522 set.lease_key(&enrollment.lease.kid)
523 .map(|k| k.key_id.as_str()),
524 Some("552e43422b3589fa")
525 );
526 assert_eq!(serde_json::to_value(&enrollment).unwrap(), raw);
527 let mut event_kid = enrollment.clone();
529 event_kid.lease.kid = "4fc7bebc302357bd".into();
530 assert!(event_kid.validate().is_err());
531 let mut broken = raw;
533 broken["keys"]["keys"][0]
534 .as_object_mut()
535 .unwrap()
536 .remove("key_id");
537 assert!(serde_json::from_value::<EnrollmentV1>(broken).is_err());
538 }
539
540 #[test]
541 fn requests_refuse_unknown_fields() {
542 let json = r#"{"schema":"leanctx.hive.device-code-request/v1","client_id":"lean-ctx-engine","instance_version":"3.12.0","os":"macos","extra":true}"#;
543 assert!(serde_json::from_str::<DeviceCodeRequestV1>(json).is_err());
544 }
545}