1use 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";
30pub const SEALED_PENDING_SIGNATURE_DOMAIN: &str = "leanctx.hive.sealed-pending.v1\n";
33
34pub const SEALED_PATCH_CONTENT_SCHEMA: &str = "leanctx.hive.sealed-draft/v1";
37
38pub const SEALED_CONTENT_ALG: &str = "xchacha20poly1305";
40pub const SEALED_WRAP_ALG: &str = "x25519-hkdf-sha256-xchacha20poly1305";
44pub const SEALED_WRAP_INFO: &str = "leanctx.hive.sealed-wrap.v1";
46pub const MAX_SEALED_CIPHERTEXT_B64: usize = 4 * 1024 * 1024;
48
49pub 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
56pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct SealedBlobV1 {
69 pub alg: String,
71 pub epoch: u32,
72 pub content_schema: String,
74 pub nonce: String,
76 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct WrappedKeyV1 {
100 pub alg: String,
102 pub ephemeral_pub: String,
104 pub nonce: String,
106 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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub epoch_required: Option<u32>,
168 #[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 pub fn required_epoch(&self) -> u32 {
194 self.epoch_required.unwrap_or(self.epoch)
195 }
196}
197
198pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct SealedPersonV1 {
218 pub id: String,
219 pub name: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct SealedRecipientV1 {
225 pub instance_id: String,
226 pub label: String,
227 pub owner: SealedPersonV1,
228 pub instance_kx_public_key: String,
230 pub kx_fingerprint: String,
232 pub approved_by: SealedPersonV1,
233 pub approved_at: UtcTimestamp,
234}
235
236#[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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(deny_unknown_fields)]
282pub struct SealedGrantRequestV1 {
283 pub schema: String,
284 pub instance_id: String,
286 pub epoch: u32,
287 pub wrapped_key: WrappedKeyV1,
288 #[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
302fn 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}