Skip to main content

lean_ctx_protocol/hive/
sealed.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! End-to-end sealed vaults.
4//!
5//! Each sealed vault has a 32-byte vault key per epoch. Revision documents
6//! and draft patches travel as [`SealedBlobV1`] (XChaCha20-Poly1305, AAD from
7//! [`sealed_aad`]); the service stores ciphertext only. Vault keys reach an
8//! instance wrapped to its X25519 key ([`WrappedKeyV1`]).
9//!
10//! - `GET /v1/vaults/{vault_id}/sealed/keys?instance={instance_id}[&epoch=n]` → [`SealedKeyV1`]
11//! - `POST /v1/vaults/{vault_id}/sealed/grants` [`SealedGrantRequestV1`] → 204
12//!   (an instance that holds the key wraps it for a newly approved instance)
13//! - 1.5: `GET /v1/vaults/{vault_id}/sealed/pending` → [`SealedPendingV1`]
14//!   (approved recipients without a key at `epoch_required`, signed by Hive)
15//!
16//! Nothing readable leaves the engine for a sealed vault: a draft carries
17//! no summary, body or sources in the clear; they travel inside the sealed
18//! patch ([`SealedDraftContentV1`]).
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::knowledge::{MAX_PATCH_OPS, PatchOpV1};
24use super::{require_schema, validate_b64_bounded, validate_b64_len, validate_id, validate_text};
25use crate::{UtcTimestamp, ValidationError};
26
27pub const SEALED_KEY_SCHEMA: &str = "leanctx.hive.sealed-key/v1";
28pub const SEALED_GRANT_REQUEST_SCHEMA: &str = "leanctx.hive.sealed-grant-request/v1";
29pub const SEALED_PENDING_SCHEMA: &str = "leanctx.hive.sealed-pending/v1";
30/// Domain of the Hive signature over a [`SealedPendingV1`] (canonical JSON
31/// without `signature`, like the key set).
32pub const SEALED_PENDING_SIGNATURE_DOMAIN: &str = "leanctx.hive.sealed-pending.v1\n";
33
34/// `content_schema` of a sealed draft patch: the plaintext is a
35/// [`SealedDraftContentV1`].
36pub const SEALED_PATCH_CONTENT_SCHEMA: &str = "leanctx.hive.sealed-draft/v1";
37
38/// Content cipher of [`SealedBlobV1`].
39pub const SEALED_CONTENT_ALG: &str = "xchacha20poly1305";
40/// Key-wrapping scheme of [`WrappedKeyV1`]: X25519 ECDH with an ephemeral
41/// key, HKDF-SHA256 (salt = ephemeral_pub || recipient_pub, info =
42/// `leanctx.hive.sealed-wrap.v1`), then XChaCha20-Poly1305.
43pub const SEALED_WRAP_ALG: &str = "x25519-hkdf-sha256-xchacha20poly1305";
44/// HKDF info of the key wrap.
45pub const SEALED_WRAP_INFO: &str = "leanctx.hive.sealed-wrap.v1";
46/// Largest encoded ciphertext of one sealed blob.
47pub const MAX_SEALED_CIPHERTEXT_B64: usize = 4 * 1024 * 1024;
48
49/// AAD binding a sealed blob to its place: `vault_id|revision|epoch|schema`.
50/// `revision` is the revision a document belongs to, or the base revision of
51/// a draft patch; `schema` is the plaintext's schema or format id.
52pub fn sealed_aad(vault_id: &str, revision: u64, epoch: u32, schema: &str) -> Vec<u8> {
53    format!("{vault_id}|{revision}|{epoch}|{schema}").into_bytes()
54}
55
56/// AAD of a key wrap (1.5): `vault_id|epoch|instance_id` of the
57/// recipient, so a wrapped key opens for that vault, epoch and instance only.
58/// The wrap key is HKDF-SHA256 over the X25519 shared secret with salt
59/// `ephemeral_pub || recipient_pub` and info [`SEALED_WRAP_INFO`]; only
60/// engines wrap and unwrap, Hive stores the result.
61pub fn sealed_wrap_aad(vault_id: &str, epoch: u32, instance_id: &str) -> Vec<u8> {
62    format!("{vault_id}|{epoch}|{instance_id}").into_bytes()
63}
64
65/// Ciphertext of a document or patch.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct SealedBlobV1 {
69    /// Always [`SEALED_CONTENT_ALG`].
70    pub alg: String,
71    pub epoch: u32,
72    /// Plaintext schema/format id, also part of the AAD.
73    pub content_schema: String,
74    /// 24 bytes, base64.
75    pub nonce: String,
76    /// Ciphertext with the 16-byte tag, base64.
77    pub ciphertext: String,
78}
79
80impl SealedBlobV1 {
81    pub fn validate(&self) -> Result<(), ValidationError> {
82        if self.alg != SEALED_CONTENT_ALG {
83            return Err(ValidationError::new(format!(
84                "sealed alg must be {SEALED_CONTENT_ALG}"
85            )));
86        }
87        super::validate_text(&self.content_schema, 128, "content_schema", false)?;
88        if self.content_schema.contains('|') {
89            return Err(ValidationError::new("content_schema must not contain '|'"));
90        }
91        validate_b64_len(&self.nonce, 24, "nonce")?;
92        validate_b64_bounded(&self.ciphertext, MAX_SEALED_CIPHERTEXT_B64, "ciphertext")
93    }
94}
95
96/// A vault key wrapped to one instance's X25519 key.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WrappedKeyV1 {
100    /// Always [`SEALED_WRAP_ALG`].
101    pub alg: String,
102    /// Ephemeral X25519 public key, 32 bytes base64.
103    pub ephemeral_pub: String,
104    /// 24 bytes, base64.
105    pub nonce: String,
106    /// 32-byte key plus 16-byte tag, base64.
107    pub ciphertext: String,
108}
109
110impl WrappedKeyV1 {
111    pub fn validate(&self) -> Result<(), ValidationError> {
112        if self.alg != SEALED_WRAP_ALG {
113            return Err(ValidationError::new(format!(
114                "wrap alg must be {SEALED_WRAP_ALG}"
115            )));
116        }
117        validate_b64_len(&self.ephemeral_pub, 32, "ephemeral_pub")?;
118        validate_b64_len(&self.nonce, 24, "nonce")?;
119        validate_b64_len(&self.ciphertext, 48, "ciphertext")
120    }
121}
122
123/// The plaintext of a sealed draft patch (1.5): what a draft in the clear
124/// carries in its own fields, plus the ops.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct SealedDraftContentV1 {
128    pub schema: String,
129    pub summary: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub body: Option<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub sources: Vec<String>,
134    pub ops: Vec<PatchOpV1>,
135}
136
137impl SealedDraftContentV1 {
138    pub fn validate(&self) -> Result<(), ValidationError> {
139        require_schema(&self.schema, SEALED_PATCH_CONTENT_SCHEMA)?;
140        validate_text(&self.summary, 200, "summary", false)?;
141        if let Some(body) = &self.body {
142            validate_text(body, 20_000, "body", true)?;
143        }
144        if self.sources.len() > 16 {
145            return Err(ValidationError::new("at most 16 sources"));
146        }
147        if self.ops.is_empty() || self.ops.len() > MAX_PATCH_OPS {
148            return Err(ValidationError::new(format!(
149                "a patch carries 1-{MAX_PATCH_OPS} ops"
150            )));
151        }
152        self.ops.iter().try_for_each(PatchOpV1::validate)
153    }
154}
155
156/// Response of `GET /v1/vaults/{vault_id}/sealed/keys`.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct SealedKeyV1 {
159    pub schema: String,
160    pub vault_id: String,
161    pub instance_id: String,
162    pub epoch: u32,
163    pub wrapped_key: WrappedKeyV1,
164    /// 1.5: the epoch new content must be sealed under; above `epoch` while
165    /// a rotation is pending. Absent from an older service: `epoch`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub epoch_required: Option<u32>,
168    /// R135: the place secret wrapped under this epoch's key, when the
169    /// grant carried it.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub place_secret_wrapped: Option<String>,
172}
173
174impl SealedKeyV1 {
175    pub fn validate(&self) -> Result<(), ValidationError> {
176        require_schema(&self.schema, SEALED_KEY_SCHEMA)?;
177        super::validate_vault_id(&self.vault_id)?;
178        validate_id(&self.instance_id, "instance_id")?;
179        if self.epoch == 0
180            || self
181                .epoch_required
182                .is_some_and(|required| required < self.epoch)
183        {
184            return Err(ValidationError::new(
185                "epoch must be positive and epoch_required at least epoch",
186            ));
187        }
188        validate_place_secret_wrapped(self.place_secret_wrapped.as_deref())?;
189        self.wrapped_key.validate()
190    }
191
192    /// The epoch new sealed content must use.
193    pub fn required_epoch(&self) -> u32 {
194        self.epoch_required.unwrap_or(self.epoch)
195    }
196}
197
198/// `kx_fingerprint` of an X25519 public key: the first 20 hex characters of
199/// its SHA-256, in groups of four (`abcd-ef01-2345-6789-abcd`). Members
200/// compare it with the recipient's own `lean-ctx hive status`.
201pub fn kx_fingerprint(kx_public_key: &[u8; 32]) -> String {
202    use sha2::{Digest as _, Sha256};
203    let hex: String = Sha256::digest(kx_public_key)
204        .iter()
205        .take(10)
206        .map(|byte| format!("{byte:02x}"))
207        .collect();
208    hex.as_bytes()
209        .chunks(4)
210        .map(|chunk| String::from_utf8_lossy(chunk).into_owned())
211        .collect::<Vec<_>>()
212        .join("-")
213}
214
215/// Someone named in a [`SealedRecipientV1`].
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct SealedPersonV1 {
218    pub id: String,
219    pub name: String,
220}
221
222/// An approved instance that has no key at `epoch_required` yet (1.5).
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct SealedRecipientV1 {
225    pub instance_id: String,
226    pub label: String,
227    pub owner: SealedPersonV1,
228    /// X25519, 32 bytes base64.
229    pub instance_kx_public_key: String,
230    /// [`kx_fingerprint`] of `instance_kx_public_key`.
231    pub kx_fingerprint: String,
232    pub approved_by: SealedPersonV1,
233    pub approved_at: UtcTimestamp,
234}
235
236/// Response of `GET /v1/vaults/{vault_id}/sealed/pending` (1.5), signed
237/// by a Hive key of the key set ([`SEALED_PENDING_SIGNATURE_DOMAIN`]).
238/// The signature only rules out tampering below Hive; against Hive itself
239/// the member confirms the fingerprint (`[hive] sealed_grants = "confirm"`).
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241pub struct SealedPendingV1 {
242    pub schema: String,
243    pub vault_id: String,
244    pub epoch_required: u32,
245    pub recipients: Vec<SealedRecipientV1>,
246    pub kid: String,
247    /// Ed25519 (base64) over [`SealedPendingV1::signing_input_raw`].
248    pub signature: String,
249}
250
251impl SealedPendingV1 {
252    pub fn validate(&self) -> Result<(), ValidationError> {
253        require_schema(&self.schema, SEALED_PENDING_SCHEMA)?;
254        super::validate_vault_id(&self.vault_id)?;
255        validate_id(&self.kid, "kid")?;
256        validate_b64_len(&self.signature, 64, "signature")?;
257        if self.epoch_required == 0 || self.recipients.len() > 256 {
258            return Err(ValidationError::new(
259                "epoch_required must be positive and at most 256 recipients listed",
260            ));
261        }
262        for recipient in &self.recipients {
263            validate_id(&recipient.instance_id, "instance_id")?;
264            validate_b64_len(
265                &recipient.instance_kx_public_key,
266                32,
267                "instance_kx_public_key",
268            )?;
269        }
270        Ok(())
271    }
272
273    /// Signing input of a pending list as received.
274    pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
275        super::signing_input_without_signature(SEALED_PENDING_SIGNATURE_DOMAIN, raw)
276    }
277}
278
279/// `POST /v1/vaults/{vault_id}/sealed/grants`.
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct SealedGrantRequestV1 {
283    pub schema: String,
284    /// Recipient instance, already approved by an org admin.
285    pub instance_id: String,
286    pub epoch: u32,
287    pub wrapped_key: WrappedKeyV1,
288    /// R135: the place secret wrapped under this grant's epoch key.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub place_secret_wrapped: Option<String>,
291}
292
293impl SealedGrantRequestV1 {
294    pub fn validate(&self) -> Result<(), ValidationError> {
295        require_schema(&self.schema, SEALED_GRANT_REQUEST_SCHEMA)?;
296        validate_id(&self.instance_id, "instance_id")?;
297        validate_place_secret_wrapped(self.place_secret_wrapped.as_deref())?;
298        self.wrapped_key.validate()
299    }
300}
301
302/// `place_secret_wrapped` (R135): base64url without padding, 32–128 chars.
303fn validate_place_secret_wrapped(value: Option<&str>) -> Result<(), ValidationError> {
304    match value {
305        Some(v)
306            if !(32..=128).contains(&v.len())
307                || !v
308                    .bytes()
309                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') =>
310        {
311            Err(ValidationError::new(
312                "place_secret_wrapped must be 32-128 base64url characters",
313            ))
314        }
315        _ => Ok(()),
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn fingerprints_are_five_groups_of_four_hex() {
325        let fingerprint = kx_fingerprint(&[7u8; 32]);
326        assert_eq!(fingerprint.len(), 24);
327        assert_eq!(fingerprint.split('-').count(), 5);
328        assert!(fingerprint.split('-').all(|group| {
329            group.len() == 4
330                && group
331                    .bytes()
332                    .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
333        }));
334        assert_ne!(fingerprint, kx_fingerprint(&[8u8; 32]));
335    }
336
337    #[test]
338    fn aad_is_pipe_separated() {
339        assert_eq!(
340            sealed_aad("prj_1", 7, 2, "ctxpkg.research-context.v1"),
341            b"prj_1|7|2|ctxpkg.research-context.v1".to_vec()
342        );
343    }
344}