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