Skip to main content

lean_ctx_protocol/hive/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! LeanCTX | Hive engine↔service wire contract, protocol major 1.
4//!
5//! The engine connector and the Hive service build against these types and
6//! the golden fixtures in `tests/fixtures/hive/`. A contract change is a
7//! fixture change that both sides review.
8//!
9//! Conventions shared by every module:
10//! - Base URL `https://hive.leanctx.com/v1`; a self-hosted Hive may use any
11//!   `https` URL (loopback may use `http`).
12//! - Every HTTP body carries `schema` (`leanctx.hive.<area>/v1`). The one
13//!   exception is the error body `{"error":{...}}`, which proxies may also emit.
14//! - Request types refuse unknown fields (the service rejects them with
15//!   `invalid_request`); response types accept unknown fields, so the service
16//!   can add fields in a minor version without breaking older engines.
17//! - Timestamps are `YYYY-MM-DDTHH:MM:SSZ` ([`UtcTimestamp`]); unix seconds
18//!   appear only where a value is signed as text (`iat`).
19//! - Keys, nonces, signatures and ciphertext are standard base64 with padding.
20//! - `vault_id` is 32 lowercase hex characters: the engine API renders the
21//!   vault UUID in simple form (no dashes) while the web API keeps hyphenated
22//!   UUIDs.
23//! - Signatures are Ed25519 over `domain || canonical_json(value)`, see
24//!   [`canonical_json`]. Signed objects are verified over the raw JSON that
25//!   was received, never over a re-serialized typed value, so additive fields
26//!   stay covered by the signature.
27
28pub mod anchor;
29pub mod bus;
30pub mod codegraph;
31pub mod ctxpkg;
32pub mod effective_policy;
33pub mod enroll;
34pub mod error;
35pub mod facts;
36pub mod gate;
37pub mod graph;
38pub mod knowledge;
39pub mod lease;
40pub mod managed;
41pub mod metrics_v2;
42pub mod model_catalog;
43pub mod optin;
44pub mod policy;
45pub mod profiles;
46pub mod receipts_v2;
47pub mod root_cert;
48pub mod status;
49pub mod tasks;
50pub mod telemetry;
51pub mod vault;
52
53pub use error::{HiveErrorBody, HiveErrorCode, HiveErrorDetailV1, HiveUploadKind};
54
55use crate::ValidationError;
56use serde_json::Value;
57use std::collections::BTreeMap;
58
59/// Major protocol version; a different major is incompatible.
60pub const HIVE_PROTOCOL_MAJOR: u16 = 1;
61/// Minor protocol version; minors only add optional response fields or routes.
62pub const HIVE_PROTOCOL_MINOR: u16 = 9;
63
64/// Hosted Hive origin.
65pub const HIVE_DEFAULT_URL: &str = "https://hive.leanctx.com";
66/// Path prefix of every engine-facing route.
67pub const HIVE_API_PREFIX: &str = "/v1";
68/// OAuth-style client id the engine sends in the device flow.
69pub const HIVE_ENGINE_CLIENT_ID: &str = "lean-ctx-engine";
70
71/// `major.minor` the sender speaks; sent on every request and response.
72pub const HEADER_PROTOCOL: &str = "Hive-Protocol";
73/// Instance id on authenticated requests.
74pub const HEADER_INSTANCE: &str = "Hive-Instance";
75/// Lowest engine version the service accepts; sent with `426`.
76pub const HEADER_MIN_ENGINE: &str = "Hive-Min-Instance";
77/// Client-chosen ULID on every `POST`; replays return the first response.
78pub const HEADER_IDEMPOTENCY_KEY: &str = "Idempotency-Key";
79/// 1.9 VA (until the 3.12 release day): an engine that speaks the vault
80/// names sends [`NAMES_VAULT`] here on every request, the bus WebSocket
81/// upgrade included. Hive then answers in the vault form (`vault_id`,
82/// `vault:` channels); without it, in the earlier project form. Hive takes
83/// both forms as input. From the release day only the vault form remains
84/// and the header is ignored.
85pub const HEADER_NAMES: &str = "Hive-Names";
86/// The [`HEADER_NAMES`] value of the vault form.
87pub const NAMES_VAULT: &str = "vault";
88
89/// Domain of the lease signature (Hive signing key).
90pub const LEASE_SIGNATURE_DOMAIN: &str = "leanctx.hive.lease.v1\n";
91/// Domain of the key-set signature (Hive root key).
92pub const KEYSET_SIGNATURE_DOMAIN: &str = "leanctx.hive.keyset.v1\n";
93/// Domain of the instance token assertion (instance key).
94pub const TOKEN_ASSERTION_DOMAIN: &str = "leanctx.hive.token.v1\n";
95/// Domain of a filter-pack statement signature (Hive signing key).
96pub const FILTER_PACK_SIGNATURE_DOMAIN: &str = "leanctx.filter-pack.v1\n";
97/// Domain of the stable repository identity digest input. The bytes keep
98/// their 1.0 name: a different domain would change every digest and unbind
99/// every linked vault.
100pub const REPO_IDENTITY_DOMAIN: &str = "leanctx.project-identity.v1\n";
101
102/// Largest request or response body either side accepts, except knowledge
103/// documents ([`knowledge::MAX_KNOWLEDGE_BODY_BYTES`]).
104pub const MAX_BODY_BYTES: usize = 1 << 20;
105
106/// `Hive-Protocol` value this crate speaks.
107pub fn protocol_header_value() -> String {
108    format!("{HIVE_PROTOCOL_MAJOR}.{HIVE_PROTOCOL_MINOR}")
109}
110
111/// Parse an `Hive-Protocol` header into `(major, minor)`.
112pub fn parse_protocol_header(value: &str) -> Option<(u16, u16)> {
113    let (major, minor) = value.trim().split_once('.')?;
114    let digits = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
115    if !digits(major) || !digits(minor) {
116        return None;
117    }
118    Some((major.parse().ok()?, minor.parse().ok()?))
119}
120
121/// Whether a peer's `Hive-Protocol` header is compatible with this crate.
122pub fn protocol_compatible(value: &str) -> bool {
123    parse_protocol_header(value).is_some_and(|(major, _)| major == HIVE_PROTOCOL_MAJOR)
124}
125
126/// Canonical JSON encoding used under every Hive signature.
127///
128/// UTF-8, no insignificant whitespace, object keys sorted by their UTF-8
129/// bytes at every depth, strings escaped as `serde_json` does (only `"`, `\`
130/// and control characters are escaped; `\b \f \n \r \t` use the short form,
131/// other control characters `\u00xx` with lowercase hex; non-ASCII stays raw
132/// UTF-8), integers in plain decimal. Non-integer numbers are refused so that
133/// two implementations cannot disagree on float formatting.
134pub fn canonical_json(value: &Value) -> Result<Vec<u8>, ValidationError> {
135    let sorted = canonical_value(value)?;
136    serde_json::to_vec(&sorted)
137        .map_err(|error| ValidationError::new(format!("encode canonical JSON: {error}")))
138}
139
140fn canonical_value(value: &Value) -> Result<Value, ValidationError> {
141    Ok(match value {
142        Value::Object(fields) => {
143            let sorted = fields
144                .iter()
145                .map(|(key, value)| Ok((key.clone(), canonical_value(value)?)))
146                .collect::<Result<BTreeMap<_, _>, ValidationError>>()?;
147            Value::Object(sorted.into_iter().collect())
148        }
149        Value::Array(values) => Value::Array(
150            values
151                .iter()
152                .map(canonical_value)
153                .collect::<Result<_, _>>()?,
154        ),
155        Value::Number(number) if !(number.is_i64() || number.is_u64()) => {
156            return Err(ValidationError::new("canonical JSON carries integers only"));
157        }
158        scalar => scalar.clone(),
159    })
160}
161
162/// `domain || canonical_json(value)`: the exact bytes an Ed25519 key signs.
163pub fn signing_input(domain: &str, value: &Value) -> Result<Vec<u8>, ValidationError> {
164    let mut bytes = domain.as_bytes().to_vec();
165    bytes.extend(canonical_json(value)?);
166    Ok(bytes)
167}
168
169/// Signing input of an object that carries its own `signature` field: the
170/// canonical form of the object without that field.
171pub fn signing_input_without_signature(
172    domain: &str,
173    value: &Value,
174) -> Result<Vec<u8>, ValidationError> {
175    let Value::Object(fields) = value else {
176        return Err(ValidationError::new("signed value must be a JSON object"));
177    };
178    let mut fields = fields.clone();
179    fields.remove("signature");
180    signing_input(domain, &Value::Object(fields))
181}
182
183pub(crate) fn require_schema(actual: &str, expected: &str) -> Result<(), ValidationError> {
184    if actual == expected {
185        Ok(())
186    } else {
187        Err(ValidationError::new(format!(
188            "schema must be {expected}, got {actual:?}"
189        )))
190    }
191}
192
193/// Validate standard padded base64 that decodes to exactly `decoded_len` bytes.
194pub(crate) fn validate_b64_len(
195    value: &str,
196    decoded_len: usize,
197    field: &str,
198) -> Result<(), ValidationError> {
199    let encoded_len = decoded_len.div_ceil(3) * 4;
200    let padding = (3 - decoded_len % 3) % 3;
201    if value.len() == encoded_len && b64_shape_ok(value, padding) {
202        Ok(())
203    } else {
204        Err(ValidationError::new(format!(
205            "{field} must be base64 of exactly {decoded_len} bytes"
206        )))
207    }
208}
209
210/// Validate standard padded base64 of any length up to `max_encoded` chars.
211pub(crate) fn validate_b64_bounded(
212    value: &str,
213    max_encoded: usize,
214    field: &str,
215) -> Result<(), ValidationError> {
216    let padding = value.bytes().rev().take_while(|byte| *byte == b'=').count();
217    if !value.is_empty()
218        && value.len() <= max_encoded
219        && value.len().is_multiple_of(4)
220        && padding <= 2
221        && b64_shape_ok(value, padding)
222    {
223        Ok(())
224    } else {
225        Err(ValidationError::new(format!(
226            "{field} must be non-empty padded base64 of at most {max_encoded} characters"
227        )))
228    }
229}
230
231fn b64_shape_ok(value: &str, padding: usize) -> bool {
232    let (body, pad) = value.split_at(value.len() - padding);
233    pad.bytes().all(|byte| byte == b'=')
234        && body
235            .bytes()
236            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/'))
237}
238
239/// 1.9 R3-3e-1: characters that make a text read differently from what it
240/// is, or hide text a person reviewing it cannot see: bidi marks,
241/// embeddings, overrides and isolates, zero-width space, the invisible
242/// operators, a byte-order mark, tag characters and the variation selector
243/// supplement; and (1.9 erratum) the soft hyphen, the Mongolian vowel
244/// separator, the combining grapheme joiner, the Hangul fillers, the Khmer
245/// inherent vowels and the line and paragraph separators (not controls, so
246/// they would break a one-line text), the whole block U+2060–U+206F (with
247/// the deprecated format controls), the interlinear annotation characters
248/// (they hide annotation text) and the invisible format controls of
249/// Egyptian hieroglyphs, shorthand and musical notation. Every text field
250/// refuses them; engines strip them from foreign content. The joiners
251/// U+200C/U+200D and U+FE00–FE0F stay: emoji and Persian need them; the
252/// visible Arabic and Syriac signs (U+0600–0605, U+06DD, U+070F, U+08E2) too.
253#[must_use]
254pub fn invisible(c: char) -> bool {
255    matches!(
256        c,
257        '\u{00AD}'
258            | '\u{034F}'
259            | '\u{061C}'
260            | '\u{115F}'
261            | '\u{1160}'
262            | '\u{17B4}'
263            | '\u{17B5}'
264            | '\u{180E}'
265            | '\u{200B}'
266            | '\u{200E}'
267            | '\u{200F}'
268            | '\u{2028}'
269            | '\u{2029}'
270            | '\u{202A}'..='\u{202E}'
271            | '\u{2060}'..='\u{206F}'
272            | '\u{3164}'
273            | '\u{FEFF}'
274            | '\u{FFA0}'
275            | '\u{FFF9}'..='\u{FFFB}'
276            | '\u{13430}'..='\u{1343F}'
277            | '\u{1BCA0}'..='\u{1BCA3}'
278            | '\u{1D173}'..='\u{1D17A}'
279            | '\u{E0000}'..='\u{E007F}'
280            | '\u{E0100}'..='\u{E01EF}'
281    )
282}
283
284/// Validate a bounded, printable text field (`max` in characters).
285pub(crate) fn validate_text(
286    value: &str,
287    max: usize,
288    field: &str,
289    allow_newlines: bool,
290) -> Result<(), ValidationError> {
291    let count = value.chars().count();
292    if count == 0 || count > max || value.trim().is_empty() {
293        return Err(ValidationError::new(format!(
294            "{field} must be non-empty text of at most {max} characters"
295        )));
296    }
297    if value
298        .chars()
299        .any(|ch| ch.is_control() && !(allow_newlines && matches!(ch, '\n' | '\t' | '\r')))
300    {
301        return Err(ValidationError::new(format!(
302            "{field} contains control characters"
303        )));
304    }
305    if value.chars().any(invisible) {
306        return Err(ValidationError::new(format!(
307            "{field} contains invisible or direction characters"
308        )));
309    }
310    Ok(())
311}
312
313/// Validate a server- or client-assigned opaque id: 1–128 chars of
314/// `[A-Za-z0-9._:-]`, starting alphanumeric.
315pub(crate) fn validate_id(value: &str, field: &str) -> Result<(), ValidationError> {
316    let bytes = value.as_bytes();
317    if !bytes.is_empty()
318        && bytes.len() <= 128
319        && bytes[0].is_ascii_alphanumeric()
320        && bytes
321            .iter()
322            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
323    {
324        Ok(())
325    } else {
326        Err(ValidationError::new(format!(
327            "{field} must be 1-128 characters of [A-Za-z0-9._:-] starting alphanumeric"
328        )))
329    }
330}
331
332/// Validate a Hive `vault_id`: 32 lowercase hex characters (128 random
333/// bits), the same form `leanctx.cloud-metrics/v1` requires of `vault.id`.
334/// The engine API renders the vault UUID in simple form (no dashes) while the
335/// web API keeps hyphenated UUIDs.
336pub fn validate_vault_id(value: &str) -> Result<(), ValidationError> {
337    if value.len() == 32
338        && value
339            .bytes()
340            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
341    {
342        Ok(())
343    } else {
344        Err(ValidationError::new(
345            "vault_id must be 32 lowercase hexadecimal characters",
346        ))
347    }
348}
349
350/// Validate an origin: `https://host[:port]`, or `http://` for
351/// localhost/127.0.0.1/[::1]; no path, query, credentials or trailing slash.
352pub fn validate_origin(value: &str, field: &str) -> Result<(), ValidationError> {
353    let refuse = || {
354        ValidationError::new(format!(
355            "{field} must be an https origin (http only on loopback)"
356        ))
357    };
358    let (scheme, rest) = value.split_once("://").ok_or_else(refuse)?;
359    if rest.is_empty()
360        || value.len() > 256
361        || rest.contains(['/', '?', '#', '@'])
362        || rest.chars().any(char::is_whitespace)
363    {
364        return Err(refuse());
365    }
366    let host = rest
367        .rsplit_once(':')
368        .filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit()))
369        .map_or(rest, |(host, _)| host);
370    match scheme {
371        "https" => Ok(()),
372        "http" if matches!(host, "localhost" | "127.0.0.1" | "[::1]") => Ok(()),
373        _ => Err(refuse()),
374    }
375}
376
377/// Validate a lowercase hexadecimal SHA-256 digest without prefix.
378pub(crate) fn validate_hex64(value: &str, field: &str) -> Result<(), ValidationError> {
379    if value.len() == 64
380        && value
381            .bytes()
382            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
383    {
384        Ok(())
385    } else {
386        Err(ValidationError::new(format!(
387            "{field} must be 64 lowercase hexadecimal characters"
388        )))
389    }
390}
391
392pub(crate) fn parse_time(
393    value: &crate::UtcTimestamp,
394) -> Result<chrono::DateTime<chrono::FixedOffset>, ValidationError> {
395    chrono::DateTime::parse_from_rfc3339(value.as_str())
396        .map_err(|_| ValidationError::new(format!("invalid timestamp {:?}", value.as_str())))
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use serde_json::json;
403
404    #[test]
405    fn text_refuses_what_a_reviewer_cannot_see() {
406        // Tag characters spell "hi" invisibly after a harmless word.
407        assert!(validate_text("fine\u{E0068}\u{E0069}", 100, "t", false).is_err());
408        assert!(validate_text("a\u{E0100}", 100, "t", false).is_err());
409        assert!(validate_text("safe \u{202E}cod.exe", 100, "t", false).is_err());
410        assert!(validate_text("a\u{200B}b", 100, "t", true).is_err());
411        // Emoji sequences and Persian joiners stay.
412        assert!(
413            validate_text(
414                "dev \u{1F469}\u{200D}\u{1F4BB} \u{2764}\u{FE0F}",
415                100,
416                "t",
417                false
418            )
419            .is_ok()
420        );
421        assert!(
422            validate_text(
423                "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}",
424                100,
425                "t",
426                false
427            )
428            .is_ok()
429        );
430    }
431
432    /// 1.9 erratum: every character of the set, one by one, in a one-line
433    /// and a multi-line text and in an anchor path; the joiners stay in
434    /// text only.
435    #[test]
436    fn every_invisible_character_is_refused() {
437        let set = [
438            '\u{00AD}',
439            '\u{034F}',
440            '\u{061C}',
441            '\u{115F}',
442            '\u{1160}',
443            '\u{17B4}',
444            '\u{17B5}',
445            '\u{180E}',
446            '\u{200B}',
447            '\u{200E}',
448            '\u{200F}',
449            '\u{2028}',
450            '\u{2029}',
451            '\u{202A}',
452            '\u{202E}',
453            '\u{2060}',
454            '\u{2064}',
455            '\u{2066}',
456            '\u{2069}',
457            '\u{3164}',
458            '\u{FEFF}',
459            '\u{FFA0}',
460            '\u{E0000}',
461            '\u{E0041}',
462            '\u{E007F}',
463            '\u{E0100}',
464            '\u{E01EF}',
465            '\u{2065}',
466            '\u{206A}',
467            '\u{206F}',
468            '\u{FFF9}',
469            '\u{FFFB}',
470            '\u{13430}',
471            '\u{1343F}',
472            '\u{1BCA0}',
473            '\u{1BCA3}',
474            '\u{1D173}',
475            '\u{1D17A}',
476        ];
477        for c in set {
478            let text = format!("a{c}b");
479            assert!(invisible(c), "{c:?}");
480            assert!(validate_text(&text, 100, "t", false).is_err(), "{c:?}");
481            assert!(validate_text(&text, 100, "t", true).is_err(), "{c:?}");
482            assert!(
483                anchor::validate_anchor_path(&format!("src/{text}.rs")).is_err(),
484                "{c:?}"
485            );
486        }
487        // The joiners, emoji selectors and visible Arabic and Syriac signs stay.
488        for c in [
489            '\u{200C}', '\u{200D}', '\u{FE0F}', '\u{0600}', '\u{06DD}', '\u{070F}', '\u{08E2}',
490            'a', ' ', 'ü',
491        ] {
492            assert!(!invisible(c), "{c:?}");
493        }
494        assert!(anchor::validate_anchor_path("src/a\u{200D}b.rs").is_err());
495    }
496
497    #[test]
498    fn canonical_json_sorts_keys_at_every_depth() {
499        let value = json!({"b": 1, "a": {"z": [ {"y": 2, "x": 1} ], "c": "ü\n"}});
500        assert_eq!(
501            String::from_utf8(canonical_json(&value).unwrap()).unwrap(),
502            r#"{"a":{"c":"ü\n","z":[{"x":1,"y":2}]},"b":1}"#
503        );
504    }
505
506    #[test]
507    fn canonical_json_refuses_floats() {
508        assert!(canonical_json(&json!({"a": 1.5})).is_err());
509        assert!(canonical_json(&json!({"a": -3})).is_ok());
510    }
511
512    #[test]
513    fn signing_input_without_signature_drops_only_the_top_level_field() {
514        let value = json!({"signature": "x", "keys": [{"signature": "kept"}]});
515        let input = signing_input_without_signature("d\n", &value).unwrap();
516        assert_eq!(input, b"d\n{\"keys\":[{\"signature\":\"kept\"}]}".to_vec());
517    }
518
519    #[test]
520    fn protocol_header_parses_and_checks_major() {
521        assert_eq!(parse_protocol_header("1.8"), Some((1, 8)));
522        assert!(protocol_compatible("1.0"));
523        assert!(!protocol_compatible("2.0"));
524        assert!(!protocol_compatible("1"));
525        assert!(!protocol_compatible("1.x"));
526        assert_eq!(protocol_header_value(), "1.9");
527    }
528
529    #[test]
530    fn base64_validation_checks_length_and_padding() {
531        assert!(validate_b64_len(&"A".repeat(44), 32, "k").is_err());
532        assert!(validate_b64_len(&format!("{}=", "A".repeat(43)), 32, "k").is_ok());
533        assert!(validate_b64_len(&format!("{}==", "A".repeat(86)), 64, "s").is_ok());
534        assert!(validate_b64_len(&format!("{}=", "-".repeat(43)), 32, "k").is_err());
535        assert!(validate_b64_bounded("AAAA", 8, "c").is_ok());
536        assert!(validate_b64_bounded("AAA", 8, "c").is_err());
537    }
538}