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