use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::knowledge::{MAX_PATCH_OPS, PatchOpV1};
use super::{require_schema, validate_b64_bounded, validate_b64_len, validate_id, validate_text};
use crate::{UtcTimestamp, ValidationError};
pub const SEALED_KEY_SCHEMA: &str = "leanctx.hive.sealed-key/v1";
pub const SEALED_GRANT_REQUEST_SCHEMA: &str = "leanctx.hive.sealed-grant-request/v1";
pub const SEALED_PENDING_SCHEMA: &str = "leanctx.hive.sealed-pending/v1";
pub const SEALED_PENDING_SIGNATURE_DOMAIN: &str = "leanctx.hive.sealed-pending.v1\n";
pub const SEALED_PATCH_CONTENT_SCHEMA: &str = "leanctx.hive.sealed-draft/v1";
pub const SEALED_CONTENT_ALG: &str = "xchacha20poly1305";
pub const SEALED_WRAP_ALG: &str = "x25519-hkdf-sha256-xchacha20poly1305";
pub const SEALED_WRAP_INFO: &str = "leanctx.hive.sealed-wrap.v1";
pub const MAX_SEALED_CIPHERTEXT_B64: usize = 4 * 1024 * 1024;
pub fn sealed_aad(vault_id: &str, revision: u64, epoch: u32, schema: &str) -> Vec<u8> {
format!("{vault_id}|{revision}|{epoch}|{schema}").into_bytes()
}
pub fn sealed_wrap_aad(vault_id: &str, epoch: u32, instance_id: &str) -> Vec<u8> {
format!("{vault_id}|{epoch}|{instance_id}").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SealedBlobV1 {
pub alg: String,
pub epoch: u32,
pub content_schema: String,
pub nonce: String,
pub ciphertext: String,
}
impl SealedBlobV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.alg != SEALED_CONTENT_ALG {
return Err(ValidationError::new(format!(
"sealed alg must be {SEALED_CONTENT_ALG}"
)));
}
super::validate_text(&self.content_schema, 128, "content_schema", false)?;
if self.content_schema.contains('|') {
return Err(ValidationError::new("content_schema must not contain '|'"));
}
validate_b64_len(&self.nonce, 24, "nonce")?;
validate_b64_bounded(&self.ciphertext, MAX_SEALED_CIPHERTEXT_B64, "ciphertext")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WrappedKeyV1 {
pub alg: String,
pub ephemeral_pub: String,
pub nonce: String,
pub ciphertext: String,
}
impl WrappedKeyV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.alg != SEALED_WRAP_ALG {
return Err(ValidationError::new(format!(
"wrap alg must be {SEALED_WRAP_ALG}"
)));
}
validate_b64_len(&self.ephemeral_pub, 32, "ephemeral_pub")?;
validate_b64_len(&self.nonce, 24, "nonce")?;
validate_b64_len(&self.ciphertext, 48, "ciphertext")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SealedDraftContentV1 {
pub schema: String,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
pub ops: Vec<PatchOpV1>,
}
impl SealedDraftContentV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, SEALED_PATCH_CONTENT_SCHEMA)?;
validate_text(&self.summary, 200, "summary", false)?;
if let Some(body) = &self.body {
validate_text(body, 20_000, "body", true)?;
}
if self.sources.len() > 16 {
return Err(ValidationError::new("at most 16 sources"));
}
if self.ops.is_empty() || self.ops.len() > MAX_PATCH_OPS {
return Err(ValidationError::new(format!(
"a patch carries 1-{MAX_PATCH_OPS} ops"
)));
}
self.ops.iter().try_for_each(PatchOpV1::validate)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedKeyV1 {
pub schema: String,
pub vault_id: String,
pub instance_id: String,
pub epoch: u32,
pub wrapped_key: WrappedKeyV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epoch_required: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub place_secret_wrapped: Option<String>,
}
impl SealedKeyV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, SEALED_KEY_SCHEMA)?;
super::validate_vault_id(&self.vault_id)?;
validate_id(&self.instance_id, "instance_id")?;
if self.epoch == 0
|| self
.epoch_required
.is_some_and(|required| required < self.epoch)
{
return Err(ValidationError::new(
"epoch must be positive and epoch_required at least epoch",
));
}
validate_place_secret_wrapped(self.place_secret_wrapped.as_deref())?;
self.wrapped_key.validate()
}
pub fn required_epoch(&self) -> u32 {
self.epoch_required.unwrap_or(self.epoch)
}
}
pub fn kx_fingerprint(kx_public_key: &[u8; 32]) -> String {
use sha2::{Digest as _, Sha256};
let hex: String = Sha256::digest(kx_public_key)
.iter()
.take(10)
.map(|byte| format!("{byte:02x}"))
.collect();
hex.as_bytes()
.chunks(4)
.map(|chunk| String::from_utf8_lossy(chunk).into_owned())
.collect::<Vec<_>>()
.join("-")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedPersonV1 {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedRecipientV1 {
pub instance_id: String,
pub label: String,
pub owner: SealedPersonV1,
pub instance_kx_public_key: String,
pub kx_fingerprint: String,
pub approved_by: SealedPersonV1,
pub approved_at: UtcTimestamp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealedPendingV1 {
pub schema: String,
pub vault_id: String,
pub epoch_required: u32,
pub recipients: Vec<SealedRecipientV1>,
pub kid: String,
pub signature: String,
}
impl SealedPendingV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, SEALED_PENDING_SCHEMA)?;
super::validate_vault_id(&self.vault_id)?;
validate_id(&self.kid, "kid")?;
validate_b64_len(&self.signature, 64, "signature")?;
if self.epoch_required == 0 || self.recipients.len() > 256 {
return Err(ValidationError::new(
"epoch_required must be positive and at most 256 recipients listed",
));
}
for recipient in &self.recipients {
validate_id(&recipient.instance_id, "instance_id")?;
validate_b64_len(
&recipient.instance_kx_public_key,
32,
"instance_kx_public_key",
)?;
}
Ok(())
}
pub fn signing_input_raw(raw: &Value) -> Result<Vec<u8>, ValidationError> {
super::signing_input_without_signature(SEALED_PENDING_SIGNATURE_DOMAIN, raw)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SealedGrantRequestV1 {
pub schema: String,
pub instance_id: String,
pub epoch: u32,
pub wrapped_key: WrappedKeyV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub place_secret_wrapped: Option<String>,
}
impl SealedGrantRequestV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, SEALED_GRANT_REQUEST_SCHEMA)?;
validate_id(&self.instance_id, "instance_id")?;
validate_place_secret_wrapped(self.place_secret_wrapped.as_deref())?;
self.wrapped_key.validate()
}
}
fn validate_place_secret_wrapped(value: Option<&str>) -> Result<(), ValidationError> {
match value {
Some(v)
if !(32..=128).contains(&v.len())
|| !v
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') =>
{
Err(ValidationError::new(
"place_secret_wrapped must be 32-128 base64url characters",
))
}
_ => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprints_are_five_groups_of_four_hex() {
let fingerprint = kx_fingerprint(&[7u8; 32]);
assert_eq!(fingerprint.len(), 24);
assert_eq!(fingerprint.split('-').count(), 5);
assert!(fingerprint.split('-').all(|group| {
group.len() == 4
&& group
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}));
assert_ne!(fingerprint, kx_fingerprint(&[8u8; 32]));
}
#[test]
fn aad_is_pipe_separated() {
assert_eq!(
sealed_aad("prj_1", 7, 2, "ctxpkg.research-context.v1"),
b"prj_1|7|2|ctxpkg.research-context.v1".to_vec()
);
}
}