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::{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
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            "engine_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 engine_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 `engine_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(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    /// 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.engine_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) sealed vault keys are wrapped to.
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 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/// 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.
295    pub keys: HiveKeySetV1,
296    /// Id of the org salt the engine fetches with `GET /v1/org/salt`.
297    pub org_salt_id: String,
298    /// 1.3: origin of the Hive web app ("Open Hive"); may equal the API origin.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub web_url: Option<String>,
301}
302
303impl EnrollmentV1 {
304    /// Structural checks; signature checks are the engine's
305    /// (`keys` against the root key, then `lease` against `keys`).
306    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/// `POST /v1/instances/{instance_id}/token`.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct InstanceTokenRequestV1 {
324    pub schema: String,
325    /// Unix seconds; the service accepts ±[`MAX_ASSERTION_SKEW_SECONDS`].
326    pub iat: i64,
327    /// 16–64 chars of `[A-Za-z0-9_-]`, single use within the skew window.
328    pub nonce: String,
329    /// Ed25519 signature (base64) over [`token_assertion_input`].
330    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
358/// Bytes the instance key signs: `"leanctx.hive.token.v1\n" + instance_id +
359/// "\n" + iat + "\n" + nonce` (iat in decimal unix seconds).
360pub 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/// Response of `POST /v1/instances/{instance_id}/token`.
365#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct AccessTokenV1 {
367    pub schema: String,
368    /// Bearer token; never logged, printed or persisted outside `token.json`.
369    pub access_token: String,
370    /// Seconds, at most [`MAX_ACCESS_TOKEN_SECONDS`].
371    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    /// C0 2.1 device setup: the contract fixtures round-trip byte for byte
455    /// through the engine's types, and the features list is optional.
456    #[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}