Skip to main content

lean_ctx_protocol/hive/
enroll.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Device-flow enrollment and instance access tokens.
4//!
5//! - `POST /v1/device/code` [`DeviceCodeRequestV1`] → [`DeviceCodeV1`]
6//! - `POST /v1/device/token` [`DeviceTokenRequestV1`] → [`EnrollmentV1`], or
7//!   400 `authorization_pending` / `slow_down` / `expired_token` / `access_denied`,
8//!   402 `plan_required`, 409 `instance_limit`
9//! - `POST /v1/instances/{instance_id}/token` [`InstanceTokenRequestV1`] → [`AccessTokenV1`]
10//! - `POST /v1/instances/{instance_id}/lease/renew` (empty body) → lease envelope
11//! - `DELETE /v1/instances/{instance_id}` → 204, frees the seat
12//!
13//! There is no refresh token: the instance's Ed25519 key is its only
14//! long-lived secret and mints short access tokens through a signed assertion.
15
16use 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
33/// Longest instance label, in characters.
34pub const MAX_INSTANCE_LABEL_CHARS: usize = 64;
35/// Accepted distance between `iat` and service time.
36pub const MAX_ASSERTION_SKEW_SECONDS: i64 = 300;
37/// Longest access-token lifetime the service issues.
38pub const MAX_ACCESS_TOKEN_SECONDS: u32 = 3600;
39
40/// Operating-system family of the engine.
41#[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/// `POST /v1/device/code`.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct DeviceCodeRequestV1 {
70    pub schema: String,
71    /// Always [`HIVE_ENGINE_CLIENT_ID`].
72    pub client_id: String,
73    pub instance_version: String,
74    pub os: EngineOs,
75    /// The name the user gave this engine (`--label`), shown on the approval
76    /// page and prefilled as its name (Hive task #11). Like `instance_version`
77    /// and `os` it is the requester's claim. Absent when the user gave none:
78    /// the host-name default goes only into [`DeviceTokenRequestV1`].
79    #[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    /// The same request naming the engine (`--label`).
95    #[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/// Response of `POST /v1/device/code`.
116#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct DeviceCodeV1 {
118    pub schema: String,
119    /// Secret polling handle; never shown or logged.
120    pub device_code: String,
121    /// Short code the user confirms in the browser, e.g. `WDJB-MJHT`.
122    pub user_code: String,
123    pub verification_uri: String,
124    pub verification_uri_complete: String,
125    /// Minimum seconds between token polls.
126    pub interval: u32,
127    /// Seconds until `device_code` expires.
128    pub expires_in: u32,
129    /// C0 2.1: what this Hive offers beyond C0 1, e.g.
130    /// [`FEATURE_DEVICE_SETUP`]; empty (absent) on older ones.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub features: Vec<String>,
133}
134
135impl DeviceCodeV1 {
136    /// Whether this Hive takes `POST /v1/device/setup` for this code.
137    pub fn offers_device_setup(&self) -> bool {
138        self.features.iter().any(|f| f == FEATURE_DEVICE_SETUP)
139    }
140}
141
142/// C0 2.1 `features` entry: the approval page shares the project too.
143pub 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/// C0 2.1 `POST /v1/device/setup` (no token; the pending `device_code`):
148/// the project this engine runs in and what it shares, shown on the
149/// approval page (`routes/device_setup.request.json`). Requester's claims.
150#[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    /// `knowledge`, `usage`, `receipts`, `bus`, sorted.
157    pub shares: Vec<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(deny_unknown_fields)]
162pub struct DeviceSetupProjectV1 {
163    /// The checkout's folder name, 1-64 characters.
164    pub name: String,
165    /// The git remote as the engine sees it, for the person to recognise.
166    pub remote: Option<String>,
167}
168
169/// C0 2.1 `POST /v1/device/setup/result` (instance token):
170/// `routes/device_setup_result.request.json`.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct DeviceSetupResultV1 {
174    pub schema: String,
175    /// `shared`, `kept_local`, `no_project` or `failed`.
176    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
194/// `http://localhost[:port]…` or `http://127.0.0.1[:port]…`.
195fn 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/// `POST /v1/device/token`: poll until the user approved, then enroll the
223/// instance with its public keys.
224#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(deny_unknown_fields)]
226pub struct DeviceTokenRequestV1 {
227    pub schema: String,
228    pub device_code: String,
229    /// Ed25519 public key (32 bytes, base64) the instance signs assertions with.
230    pub instance_public_key: String,
231    /// X25519 public key (32 bytes, base64); enrollment carries it.
232    pub instance_kx_public_key: String,
233    /// User-visible name, e.g. the host name when labels are on.
234    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/// Organization reference: the workspace the instance enrolls in.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct OrgRefV1 {
271    pub id: String,
272    pub name: String,
273    /// `personal` | `team` (Hive C line, additive); older servers omit it.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub kind: Option<String>,
276    /// The company a team belongs to (Hive C line, additive).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub company: Option<CompanyRefV1>,
279}
280
281/// The company of a team: the business that holds its Enterprise plan.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct CompanyRefV1 {
284    pub name: String,
285}
286
287/// Successful `POST /v1/device/token`.
288#[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    /// Signed by the Hive root key; verified before the lease. v1 for an
295    /// engine below Hive-Protocol 2.1, v3 from 2.1.
296    pub keys: EnrollmentKeySet,
297    /// Id of the org salt the engine fetches with `GET /v1/org/salt`.
298    pub org_salt_id: String,
299    /// 1.3: origin of the Hive web app ("Open Hive"); may equal the API origin.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub web_url: Option<String>,
302}
303
304impl EnrollmentV1 {
305    /// Structural checks; signature checks are the engine's
306    /// (`keys` against the root key, then `lease` against `keys`).
307    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/// `POST /v1/instances/{instance_id}/token`.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct InstanceTokenRequestV1 {
331    pub schema: String,
332    /// Unix seconds; the service accepts ±[`MAX_ASSERTION_SKEW_SECONDS`].
333    pub iat: i64,
334    /// 16–64 chars of `[A-Za-z0-9_-]`, single use within the skew window.
335    pub nonce: String,
336    /// Ed25519 signature (base64) over [`token_assertion_input`].
337    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
365/// Bytes the instance key signs: `"leanctx.hive.token.v1\n" + instance_id +
366/// "\n" + iat + "\n" + nonce` (iat in decimal unix seconds).
367pub 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/// Response of `POST /v1/instances/{instance_id}/token`.
372#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct AccessTokenV1 {
374    pub schema: String,
375    /// Bearer token; never logged, printed or persisted outside `token.json`.
376    pub access_token: String,
377    /// Seconds, at most [`MAX_ACCESS_TOKEN_SECONDS`].
378    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    /// C0 2.1 device setup: the contract fixtures round-trip byte for byte
462    /// through the engine's types, and the features list is optional.
463    #[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    /// An enrollment as a Hive-Protocol 1.x/2.0 engine receives it: key
490    /// set v1, the lease signed by a `kid` of it.
491    #[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    /// An enrollment as a Hive-Protocol 2.1+ engine receives it
507    /// (`routes/enrollment.for_2x.response.json`): key set v3, the lease of
508    /// a 2.x engine signed by the v3 key of purpose `policy` its `kid`
509    /// names.
510    #[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        // The kid of a key without purpose `policy` signs no lease.
528        let mut event_kid = enrollment.clone();
529        event_kid.lease.kid = "4fc7bebc302357bd".into();
530        assert!(event_kid.validate().is_err());
531        // A v3 key without its v3 fields is refused, not read as v1.
532        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}