1use serde::{Deserialize, Serialize};
17use std::fmt;
18
19use super::lease::{HiveKeySetV1, 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 "engine_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 engine_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(engine_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 engine_version: engine_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.engine_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 engine_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("engine_version", &self.engine_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.engine_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: HiveKeySetV1,
296 pub org_salt_id: String,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub web_url: Option<String>,
301}
302
303impl EnrollmentV1 {
304 pub fn validate(&self) -> Result<(), ValidationError> {
307 require_schema(&self.schema, ENROLLMENT_SCHEMA)?;
308 validate_id(&self.instance_id, "instance_id")?;
309 validate_id(&self.org.id, "org.id")?;
310 validate_text(&self.org.name, 128, "org.name", false)?;
311 validate_id(&self.org_salt_id, "org_salt_id")?;
312 if let Some(web_url) = &self.web_url {
313 super::validate_origin(web_url, "web_url")?;
314 }
315 self.keys.validate()?;
316 self.lease.validate_shape()
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct InstanceTokenRequestV1 {
324 pub schema: String,
325 pub iat: i64,
327 pub nonce: String,
329 pub assertion: String,
331}
332
333impl InstanceTokenRequestV1 {
334 pub fn validate(&self) -> Result<(), ValidationError> {
335 require_schema(&self.schema, TOKEN_REQUEST_SCHEMA)?;
336 if self.iat <= 0 {
337 return Err(ValidationError::new("iat must be positive unix seconds"));
338 }
339 validate_nonce(&self.nonce)?;
340 validate_b64_len(&self.assertion, 64, "assertion")
341 }
342}
343
344fn validate_nonce(nonce: &str) -> Result<(), ValidationError> {
345 if (16..=64).contains(&nonce.len())
346 && nonce
347 .bytes()
348 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
349 {
350 Ok(())
351 } else {
352 Err(ValidationError::new(
353 "nonce must be 16-64 characters of [A-Za-z0-9_-]",
354 ))
355 }
356}
357
358pub fn token_assertion_input(instance_id: &str, iat: i64, nonce: &str) -> Vec<u8> {
361 format!("{TOKEN_ASSERTION_DOMAIN}{instance_id}\n{iat}\n{nonce}").into_bytes()
362}
363
364#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct AccessTokenV1 {
367 pub schema: String,
368 pub access_token: String,
370 pub expires_in: u32,
372}
373
374impl fmt::Debug for AccessTokenV1 {
375 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376 formatter
377 .debug_struct("AccessTokenV1")
378 .field("access_token", &"<redacted>")
379 .field("expires_in", &self.expires_in)
380 .finish_non_exhaustive()
381 }
382}
383
384impl AccessTokenV1 {
385 pub fn validate(&self) -> Result<(), ValidationError> {
386 require_schema(&self.schema, ACCESS_TOKEN_SCHEMA)?;
387 validate_text(&self.access_token, 4096, "access_token", false)?;
388 if !(1..=MAX_ACCESS_TOKEN_SECONDS).contains(&self.expires_in) {
389 return Err(ValidationError::new("expires_in must be 1-3600 s"));
390 }
391 Ok(())
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn a_device_code_label_follows_the_text_rule() {
401 let request = |label: Option<&str>| {
402 DeviceCodeRequestV1::new("3.12.0", EngineOs::Macos)
403 .with_label(label.map(str::to_string))
404 };
405 assert!(request(None).validate().is_ok());
406 assert!(request(Some("build-box")).validate().is_ok());
407 let long = "x".repeat(MAX_INSTANCE_LABEL_CHARS + 1);
408 for bad in [
409 "",
410 "line\nbreak",
411 "zero\u{200b}width",
412 "a\u{202e}b",
413 long.as_str(),
414 ] {
415 assert!(request(Some(bad)).validate().is_err(), "{bad:?}");
416 }
417 let unlabelled = serde_json::to_value(request(None)).unwrap();
418 assert!(
419 unlabelled.get("instance_label").is_none(),
420 "older services refuse unknown fields"
421 );
422 }
423
424 #[test]
425 fn assertion_input_is_the_documented_text() {
426 assert_eq!(
427 token_assertion_input("inst_1", 1_792_500_000, "n0nce-n0nce-n0nce"),
428 b"leanctx.hive.token.v1\ninst_1\n1792500000\nn0nce-n0nce-n0nce".to_vec()
429 );
430 }
431
432 #[test]
433 fn secrets_stay_out_of_debug() {
434 let token = AccessTokenV1 {
435 schema: ACCESS_TOKEN_SCHEMA.into(),
436 access_token: "atl_secret_value".into(),
437 expires_in: 3600,
438 };
439 assert!(!format!("{token:?}").contains("atl_secret_value"));
440 let code = DeviceCodeV1 {
441 schema: DEVICE_CODE_SCHEMA.into(),
442 device_code: "dc_secret_value".into(),
443 user_code: "WDJB-MJHT".into(),
444 verification_uri: "https://hive.leanctx.com/device".into(),
445 verification_uri_complete: "https://hive.leanctx.com/device?code=WDJB-MJHT".into(),
446 interval: 5,
447 expires_in: 900,
448 features: Vec::new(),
449 };
450 assert!(!format!("{code:?}").contains("dc_secret_value"));
451 assert!(!code.offers_device_setup());
452 }
453
454 #[test]
457 fn device_setup_documents_are_the_contract_fixtures() {
458 fn round<T: serde::de::DeserializeOwned + Serialize>(raw: &str) {
459 let value: serde_json::Value = serde_json::from_str(raw).unwrap();
460 let typed: T = serde_json::from_value(value.clone()).unwrap();
461 assert_eq!(serde_json::to_value(typed).unwrap(), value);
462 }
463 round::<DeviceSetupRequestV1>(include_str!(
464 "../../tests/fixtures/hive-v2/routes/device_setup.request.json"
465 ));
466 round::<DeviceSetupResultV1>(include_str!(
467 "../../tests/fixtures/hive-v2/routes/device_setup_result.request.json"
468 ));
469 let mut answer = serde_json::json!({
470 "schema": DEVICE_CODE_SCHEMA, "device_code": "dc_x", "user_code": "WDJB-MJHT",
471 "verification_uri": "https://hive.leanctx.com/device",
472 "verification_uri_complete": "https://hive.leanctx.com/device?user_code=WDJB-MJHT",
473 "interval": 5, "expires_in": 900,
474 });
475 let old: DeviceCodeV1 = serde_json::from_value(answer.clone()).unwrap();
476 assert!(!old.offers_device_setup(), "a C0 2.0 Hive lists nothing");
477 answer["features"] = serde_json::json!([FEATURE_DEVICE_SETUP, "later"]);
478 let new: DeviceCodeV1 = serde_json::from_value(answer).unwrap();
479 assert!(new.offers_device_setup());
480 }
481
482 #[test]
483 fn requests_refuse_unknown_fields() {
484 let json = r#"{"schema":"leanctx.hive.device-code-request/v1","client_id":"lean-ctx-engine","engine_version":"3.12.0","os":"macos","extra":true}"#;
485 assert!(serde_json::from_str::<DeviceCodeRequestV1>(json).is_err());
486 }
487}