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