#![cfg(feature = "tokio")]
mod formats;
use std::borrow::Cow;
use std::sync::Arc;
use crate::{
encryption::{
self, Encrypted, EncryptedEntry, EncryptedSteVecTerm, EncryptedSteVecTermCompat,
EncryptedSteVecTermStandard, IndexTerm, QueryOp, SteQueryVec,
},
zerokms::{self, RecordDecryptError},
};
use stack_auth::AuthStrategyBounds;
use crate::{
encryption::StorageBuilder,
zerokms::{GenerateKeyPayload, IndexKey},
};
use super::zerokms::EncryptedRecord;
use cipherstash_config::{column::IndexType, ColumnConfig};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::encryption::{PlaintextTarget, Queryable, ScopedCipher};
use cipherstash_config::column::Index;
use zerokms_protocol::{Context, DecryptionPolicy, UnverifiedContext};
pub const EQL_SCHEMA_VERSION: u16 = 2;
pub async fn encrypt_eql<'a, C>(
cipher: Arc<ScopedCipher<C>>,
plaintexts: Vec<PreparedPlaintext<'a>>,
opts: &EqlEncryptOpts<'a>,
) -> Result<Vec<EqlOutput>, EqlError>
where
C: AuthStrategyBounds,
for<'b> &'b C: stack_auth::AuthStrategy,
{
use std::collections::VecDeque;
let effective_keyset_id = opts.keyset_id.unwrap_or(cipher.keyset_id());
let targets: Vec<EncryptionTarget> =
to_encryption_targets(cipher.index_key(), plaintexts, effective_keyset_id)?;
let mut data_keys = VecDeque::from(
cipher
.generate_data_keys(
generate_data_key_payloads(opts, &targets),
opts.unverified_context.clone(),
)
.await?,
);
let encrypted = targets
.into_iter()
.map(|target| -> Result<EqlOutput, EqlError> {
match target {
EncryptionTarget::ForStorage(identifier, builder) => {
let encrypted = builder.build_for_encryption().encrypt(
data_keys
.remove(0)
.expect("insufficient data keys to encrypt all plaintexts"),
)?;
Ok(EqlOutput::Store(to_eql_ciphertext(encrypted, &identifier)?))
}
EncryptionTarget::ForQuery(identifier, plaintext, index_type, query_op) => {
let index = Index::new(index_type.clone());
let index_term =
(index, plaintext).build_queryable(cipher.clone(), query_op)?;
Ok(EqlOutput::Query(to_eql_query_payload(
index_term, identifier,
)?))
}
}
})
.collect::<Result<Vec<_>, _>>()?;
Ok(encrypted)
}
pub async fn decrypt_eql<'a, C>(
cipher: Arc<ScopedCipher<C>>,
ciphertexts: impl IntoIterator<Item = EqlCiphertext>,
opts: &EqlDecryptOpts<'a>,
) -> Result<Vec<encryption::Plaintext>, EqlError>
where
C: AuthStrategyBounds,
for<'b> &'b C: stack_auth::AuthStrategy,
{
use crate::{encryption::DecryptOptions, zerokms::WithContext};
let decrypt_opts = DecryptOptions {
keyset_id: opts.keyset_id,
unverified_context: opts.unverified_context.clone(),
};
let ciphertexts = ciphertexts
.into_iter()
.map(|eql| {
let (_, ciphertext) = extract_root_ciphertext(eql)?;
Ok(WithContext {
record: ciphertext,
context: opts.lock_context.clone(),
})
})
.collect::<Result<Vec<_>, EqlError>>()?;
Ok(cipher
.decrypt(ciphertexts, &decrypt_opts)
.await
.map_err(|err| convert_zerokms_error(err, cipher.keyset_id(), opts.keyset_id))?
.into_iter()
.map(|decrypted| encryption::Plaintext::from_slice(&decrypted))
.collect::<Result<Vec<_>, _>>()?)
}
pub async fn decrypt_eql_fallible<'a, C>(
cipher: Arc<ScopedCipher<C>>,
ciphertexts: impl IntoIterator<Item = EqlCiphertext>,
opts: &EqlDecryptOpts<'a>,
) -> Result<Vec<Result<encryption::Plaintext, EqlError>>, EqlError>
where
C: AuthStrategyBounds,
for<'b> &'b C: stack_auth::AuthStrategy,
{
use crate::{encryption::DecryptOptions, zerokms::WithContext};
let decrypt_opts = DecryptOptions {
keyset_id: opts.keyset_id,
unverified_context: opts.unverified_context.clone(),
};
let inputs: Vec<_> = ciphertexts.into_iter().collect();
let input_count = inputs.len();
let mut results: Vec<Option<Result<encryption::Plaintext, EqlError>>> =
(0..input_count).map(|_| None).collect();
let mut valid_payloads: Vec<(usize, WithContext<EncryptedRecord>)> =
Vec::with_capacity(input_count);
for (index, eql) in inputs.into_iter().enumerate() {
match extract_root_ciphertext(eql) {
Ok((_, ciphertext)) => valid_payloads.push((
index,
WithContext {
record: ciphertext,
context: opts.lock_context.clone(),
},
)),
Err(err) => {
results[index] = Some(Err(err));
}
}
}
let (indices, payloads): (Vec<usize>, Vec<_>) = valid_payloads.into_iter().unzip();
let decrypt_results = cipher
.decrypt_fallible(payloads, &decrypt_opts)
.await
.map_err(|err| convert_zerokms_error(err, cipher.keyset_id(), opts.keyset_id))?;
for (index, decrypt_result) in indices.into_iter().zip(decrypt_results) {
results[index] = Some(match decrypt_result {
Ok(bytes) => encryption::Plaintext::from_slice(&bytes).map_err(Into::into),
Err(err) => Err(EqlError::from(err)),
});
}
Ok(results
.into_iter()
.map(|r| r.expect("all result slots filled"))
.collect())
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct Identifier {
#[serde(rename = "t")]
pub table: String,
#[serde(rename = "c")]
pub column: String,
}
impl Identifier {
pub fn new(table: impl Into<String>, column: impl Into<String>) -> Self {
Self {
table: table.into(),
column: column.into(),
}
}
pub fn table(&self) -> &str {
&self.table
}
pub fn column(&self) -> &str {
&self.column
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "k")]
pub enum EqlCiphertext {
#[serde(rename = "ct")]
Encrypted(EncryptedPayload),
#[serde(rename = "sv")]
SteVec(SteVecPayload),
}
impl EqlCiphertext {
pub fn identifier(&self) -> &Identifier {
match self {
EqlCiphertext::Encrypted(p) => &p.identifier,
EqlCiphertext::SteVec(p) => &p.identifier,
}
}
pub fn version(&self) -> u16 {
match self {
EqlCiphertext::Encrypted(p) => p.version,
EqlCiphertext::SteVec(p) => p.version,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EncryptedPayload {
#[serde(rename = "v")]
pub version: u16,
#[serde(rename = "i")]
pub identifier: Identifier,
#[serde(rename = "c", with = "formats::mp_base85")]
pub ciphertext: EncryptedRecord,
#[serde(rename = "hm", default, skip_serializing_if = "Option::is_none")]
pub hmac_256: Option<String>,
#[serde(rename = "bf", default, skip_serializing_if = "Option::is_none")]
pub bloom_filter: Option<Vec<u16>>,
#[serde(rename = "ob", default, skip_serializing_if = "Option::is_none")]
pub ore_block_u64_8_256: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SteVecPayload {
#[serde(rename = "v")]
pub version: u16,
#[serde(rename = "i")]
pub identifier: Identifier,
#[serde(rename = "sv")]
pub ste_vec: Vec<SteVecEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SteVecEntry {
#[serde(rename = "s")]
pub selector: String,
#[serde(rename = "c", with = "formats::mp_base85")]
pub ciphertext: EncryptedRecord,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
pub is_array: Option<bool>,
#[serde(flatten)]
pub term: SteVecEntryTerm,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum SteVecEntryTerm {
Hmac {
#[serde(rename = "hm")]
hmac_256: String,
},
OreCllw {
#[serde(rename = "oc")]
ore_cllw_8: String,
},
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum EqlOutput {
Store(EqlCiphertext),
Query(EqlQueryPayload),
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "k")]
pub enum EqlQueryPayload {
#[serde(rename = "ct")]
Encrypted(EncryptedQueryPayload),
#[serde(rename = "sv")]
SteVec(SteVecQueryPayload),
}
impl EqlQueryPayload {
pub fn identifier(&self) -> &Identifier {
match self {
EqlQueryPayload::Encrypted(p) => &p.identifier,
EqlQueryPayload::SteVec(p) => &p.identifier,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EncryptedQueryPayload {
#[serde(rename = "v")]
pub version: u16,
#[serde(rename = "i")]
pub identifier: Identifier,
#[serde(flatten)]
pub term: RootQueryTerm,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum RootQueryTerm {
Hmac {
#[serde(rename = "hm")]
hmac_256: String,
},
BloomFilter {
#[serde(rename = "bf")]
bloom_filter: Vec<u16>,
},
OreBlock {
#[serde(rename = "ob")]
ore_block_u64_8_256: Vec<String>,
},
}
#[derive(Debug, Deserialize, Serialize)]
pub struct SteVecQueryPayload {
#[serde(rename = "v")]
pub version: u16,
#[serde(rename = "i")]
pub identifier: Identifier,
#[serde(flatten)]
pub term: SteVecQueryTerm,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum SteVecQueryTerm {
Selector {
#[serde(rename = "s")]
selector: String,
},
Hmac {
#[serde(rename = "hm")]
hmac_256: String,
},
OreCllw {
#[serde(rename = "oc")]
ore_cllw_8: String,
},
Containment {
#[serde(rename = "q")]
query_vec: SteQueryVec<16>,
},
}
fn extract_root_ciphertext(eql: EqlCiphertext) -> Result<(Identifier, EncryptedRecord), EqlError> {
match eql {
EqlCiphertext::Encrypted(p) => Ok((p.identifier, p.ciphertext)),
EqlCiphertext::SteVec(p) => {
let SteVecPayload {
identifier,
ste_vec,
..
} = p;
let root = ste_vec
.into_iter()
.next()
.ok_or_else(|| EqlError::MissingCiphertext(identifier.clone()))?;
Ok((identifier, root.ciphertext))
}
}
}
fn to_eql_ciphertext(
encrypted: Encrypted,
identifier: &Identifier,
) -> Result<EqlCiphertext, EqlError> {
match encrypted {
Encrypted::Record(ciphertext, terms) => {
let mut payload = EncryptedPayload {
version: EQL_SCHEMA_VERSION,
identifier: identifier.clone(),
ciphertext,
hmac_256: None,
bloom_filter: None,
ore_block_u64_8_256: None,
};
for term in terms {
apply_root_term(&mut payload, term);
}
Ok(EqlCiphertext::Encrypted(payload))
}
Encrypted::SteVec(ste_vec) => {
let elements: Vec<SteVecEntry> = ste_vec
.into_iter()
.map(
|EncryptedEntry {
tokenized_selector,
term,
record,
parent_is_array,
}| {
SteVecEntry {
selector: hex::encode(tokenized_selector.as_bytes()),
ciphertext: record,
is_array: Some(parent_is_array),
term: ste_vec_term_from_encrypted(term),
}
},
)
.collect();
Ok(EqlCiphertext::SteVec(SteVecPayload {
version: EQL_SCHEMA_VERSION,
identifier: identifier.clone(),
ste_vec: elements,
}))
}
}
}
fn apply_root_term(payload: &mut EncryptedPayload, term: IndexTerm) {
match term {
IndexTerm::Binary(bytes) => {
payload.hmac_256 = Some(hex::encode(bytes));
}
IndexTerm::BitMap(bf) => {
payload.bloom_filter = Some(bf);
}
IndexTerm::OreFull(bytes) | IndexTerm::OreLeft(bytes) => {
payload.ore_block_u64_8_256 = Some(vec![hex::encode(bytes)]);
}
IndexTerm::OreArray(arr) => {
payload.ore_block_u64_8_256 = Some(arr.iter().map(hex::encode).collect());
}
IndexTerm::OpeFixed(_)
| IndexTerm::OpeVariable(_)
| IndexTerm::BinaryVec(_)
| IndexTerm::SteVecSelector(_)
| IndexTerm::SteVecTerm(_)
| IndexTerm::SteQueryVec(_)
| IndexTerm::Null => {}
}
}
fn ste_vec_term_from_encrypted(term: EncryptedSteVecTerm) -> SteVecEntryTerm {
match term {
EncryptedSteVecTerm::Compat(EncryptedSteVecTermCompat::Mac(bytes))
| EncryptedSteVecTerm::Standard(EncryptedSteVecTermStandard::Mac(bytes)) => {
SteVecEntryTerm::Hmac {
hmac_256: hex::encode(bytes),
}
}
EncryptedSteVecTerm::Standard(EncryptedSteVecTermStandard::Ore(ore)) => {
SteVecEntryTerm::OreCllw {
ore_cllw_8: hex::encode(ore.as_ref()),
}
}
EncryptedSteVecTerm::Compat(EncryptedSteVecTermCompat::Ope(ope)) => {
SteVecEntryTerm::OreCllw {
ore_cllw_8: hex::encode(ope.as_ref()),
}
}
}
}
fn to_eql_query_payload(
index_term: IndexTerm,
identifier: Identifier,
) -> Result<EqlQueryPayload, EqlError> {
match index_term {
IndexTerm::Binary(bytes) => Ok(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: RootQueryTerm::Hmac {
hmac_256: hex::encode(bytes),
},
})),
IndexTerm::BitMap(bf) => Ok(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: RootQueryTerm::BloomFilter { bloom_filter: bf },
})),
IndexTerm::OreFull(bytes) | IndexTerm::OreLeft(bytes) => {
Ok(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: RootQueryTerm::OreBlock {
ore_block_u64_8_256: vec![hex::encode(bytes)],
},
}))
}
IndexTerm::OreArray(arr) => Ok(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: RootQueryTerm::OreBlock {
ore_block_u64_8_256: arr.iter().map(hex::encode).collect(),
},
})),
IndexTerm::SteVecSelector(selector) => Ok(EqlQueryPayload::SteVec(SteVecQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: SteVecQueryTerm::Selector {
selector: hex::encode(selector.as_bytes()),
},
})),
IndexTerm::SteVecTerm(term) => {
let query_term = match ste_vec_term_from_encrypted(term) {
SteVecEntryTerm::Hmac { hmac_256 } => SteVecQueryTerm::Hmac { hmac_256 },
SteVecEntryTerm::OreCllw { ore_cllw_8 } => SteVecQueryTerm::OreCllw { ore_cllw_8 },
};
Ok(EqlQueryPayload::SteVec(SteVecQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: query_term,
}))
}
IndexTerm::SteQueryVec(query_vec) => Ok(EqlQueryPayload::SteVec(SteVecQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier,
term: SteVecQueryTerm::Containment { query_vec },
})),
IndexTerm::OpeFixed(_)
| IndexTerm::OpeVariable(_)
| IndexTerm::BinaryVec(_)
| IndexTerm::Null => Err(EqlError::InvalidIndexTerm),
}
}
#[derive(Error, Debug)]
pub enum EqlError {
#[error(transparent)]
CiphertextCouldNotBeSerialised(#[from] serde_json::Error),
#[error("Encrypted column could not be parsed")]
ColumnCouldNotBeParsed,
#[error("Encrypted column is null")]
ColumnIsNull,
#[error("Column '{column}' in table '{table}' could not be deserialised")]
ColumnCouldNotBeDeserialised { table: String, column: String },
#[error("Column '{column}' in table '{table}' could not be encrypted")]
ColumnCouldNotBeEncrypted { table: String, column: String },
#[error("Column configuration for column '{column}' in table '{table}' does not match the encrypted column")]
ColumnConfigurationMismatch { table: String, column: String },
#[error("Could not decrypt data using keyset '{keyset_id}'")]
CouldNotDecryptDataForKeyset {
keyset_id: String,
#[source]
source: zerokms::Error,
},
#[error("InvalidIndexTerm")]
InvalidIndexTerm,
#[error("EQL payload for column '{}' in table '{}' is missing ciphertext", _0.column(), _0.table())]
MissingCiphertext(Identifier),
#[error("KeysetId `{id}` could not be parsed using `SET CIPHERSTASH.KEYSET_ID`. KeysetId should be a valid UUID")]
KeysetIdCouldNotBeParsed { id: String },
#[error("Keyset Id could not be set using `SET CIPHERSTASH.KEYSET_ID`")]
KeysetIdCouldNotBeSet,
#[error("Keyset Name could not be set using `SET CIPHERSTASH.KEYSET_NAME`")]
KeysetNameCouldNotBeSet,
#[error("Missing encrypt configuration for column type `{plaintext_type}`")]
MissingEncryptConfiguration { plaintext_type: &'static str },
#[error("Decrypted column could not be encoded as the expected type")]
PlaintextCouldNotBeEncoded,
#[error(transparent)]
Pipeline(#[from] encryption::EncryptionError),
#[error(transparent)]
PlaintextCouldNotBeDecoded(#[from] encryption::TypeParseError),
#[error("Missing keyset identifer")]
MissingKeysetIdentifier,
#[error("Cannot SET CIPHERSTASH.KEYSET if a default keyset has been configured")]
UnexpectedSetKeyset,
#[error("Column '{column}' in table '{table}' has no Encrypt configuration")]
UnknownColumn { table: String, column: String },
#[error("Unknown keyset name or id '{keyset}'. Check the configured credentials")]
UnknownKeysetIdentifier { keyset: String },
#[error("Table '{table}' has no Encrypt configuration")]
UnknownTable { table: String },
#[error("Unknown Index Term for column '{}' in table '{}'", _0.column(), _0.table())]
UnknownIndexTerm(Identifier),
#[error("ZeroKMS error '{}'", _0)]
ZeroKMS(#[from] zerokms::Error),
#[error("Record decryption error '{}'", _0)]
RecordDecrypt(#[from] RecordDecryptError),
}
#[derive(Debug)]
pub enum EqlOperation<'a> {
Store,
Query(&'a IndexType, QueryOp),
}
pub struct PreparedPlaintext<'a> {
identifier: Identifier,
plaintext: encryption::Plaintext,
eql_op: EqlOperation<'a>,
column_config: Cow<'a, ColumnConfig>,
}
impl<'a> PreparedPlaintext<'a> {
pub fn new(
column_config: Cow<'a, ColumnConfig>,
identifier: Identifier,
plaintext: encryption::Plaintext,
eql_op: EqlOperation<'a>,
) -> Self {
Self {
identifier,
plaintext,
eql_op,
column_config,
}
}
}
enum EncryptionTarget<'a> {
ForStorage(Identifier, StorageBuilder<'a, encryption::Plaintext>),
ForQuery(Identifier, encryption::Plaintext, &'a IndexType, QueryOp),
}
fn generate_data_key_payloads<'a>(
opts: &EqlEncryptOpts<'a>,
targets: &'a Vec<EncryptionTarget<'a>>,
) -> Vec<GenerateKeyPayload<'a>> {
targets
.iter()
.filter_map(|target| match target {
EncryptionTarget::ForStorage(_, builder) => {
let payload =
GenerateKeyPayload::new(builder.descriptor(), opts.lock_context.clone());
Some(match opts.decryption_policy.clone() {
Some(p) => payload.with_decryption_policy(p),
None => payload,
})
}
EncryptionTarget::ForQuery(_, _, _, _) => None,
})
.collect()
}
fn to_encryption_targets<'a>(
index_key: &'a IndexKey,
plaintexts: Vec<PreparedPlaintext<'a>>,
effective_keyset_id: Uuid,
) -> Result<Vec<EncryptionTarget<'a>>, encryption::EncryptionError> {
plaintexts
.into_iter()
.map(
move |prepared_plaintext| -> Result<EncryptionTarget, encryption::EncryptionError> {
use crate::encryption::Encryptable;
let PreparedPlaintext {
identifier,
plaintext,
eql_op,
column_config,
} = prepared_plaintext;
match eql_op {
EqlOperation::Store => Ok(EncryptionTarget::ForStorage(
identifier,
PlaintextTarget::new(plaintext, (*column_config).clone())
.build_encryptable(index_key, effective_keyset_id)?,
)),
EqlOperation::Query(index_type, query_op) => Ok(EncryptionTarget::ForQuery(
identifier, plaintext, index_type, query_op,
)),
}
},
)
.collect::<Result<Vec<_>, _>>()
}
#[derive(Debug, Default)]
pub struct EqlDecryptOpts<'a> {
pub keyset_id: Option<Uuid>,
pub lock_context: Cow<'a, [Context]>,
pub unverified_context: Option<Cow<'a, UnverifiedContext>>,
}
#[derive(Debug, Default)]
pub struct EqlEncryptOpts<'a> {
pub keyset_id: Option<Uuid>,
pub lock_context: Cow<'a, [Context]>,
pub unverified_context: Option<Cow<'a, UnverifiedContext>>,
pub index_types: Option<Cow<'a, [IndexType]>>,
pub decryption_policy: Option<DecryptionPolicy>,
}
fn convert_zerokms_error(
err: zerokms::Error,
cipher_keyset_id: Uuid,
keyset_id_override: Option<Uuid>,
) -> EqlError {
match err {
zerokms::Error::Decrypt(_) => {
let error_msg = err.to_string();
if error_msg.contains("Failed to retrieve key") {
EqlError::CouldNotDecryptDataForKeyset {
keyset_id: keyset_id_override
.map(|id| id.to_string())
.unwrap_or(cipher_keyset_id.to_string()),
source: err,
}
} else {
EqlError::ZeroKMS(err)
}
}
_ => EqlError::ZeroKMS(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_zerokms_error_preserves_source_chain() {
use crate::zerokms::{DecryptError, RetrieveKeyError};
use std::error::Error as StdError;
let cipher_keyset_id = Uuid::new_v4();
let override_id = Uuid::new_v4();
let inner = zerokms::Error::from(DecryptError::from(RetrieveKeyError::FailedRetrieval(
"no such key".into(),
)));
let converted = convert_zerokms_error(inner, cipher_keyset_id, Some(override_id));
match converted {
EqlError::CouldNotDecryptDataForKeyset { ref keyset_id, .. } => {
assert_eq!(keyset_id, &override_id.to_string());
}
ref other => panic!("expected CouldNotDecryptDataForKeyset, got {other:?}"),
}
let source = StdError::source(&converted).expect("source() should be Some");
assert!(
source.downcast_ref::<zerokms::Error>().is_some(),
"source should downcast to &zerokms::Error"
);
}
#[test]
fn empty_ste_vec_payload_yields_missing_ciphertext() {
let identifier = Identifier::new("test_table", "test_column");
let eql = EqlCiphertext::SteVec(SteVecPayload {
version: EQL_SCHEMA_VERSION,
identifier: identifier.clone(),
ste_vec: Vec::new(),
});
let result = extract_root_ciphertext(eql);
assert!(matches!(result, Err(EqlError::MissingCiphertext(_))));
}
#[test]
fn mp_base85_deserialize_invalid_input_returns_error() {
use serde::de::value::{Error as ValueError, StrDeserializer};
use serde::de::IntoDeserializer;
let invalid: StrDeserializer<ValueError> = "not-valid-base85!!!".into_deserializer();
let result: Result<EncryptedRecord, ValueError> = formats::mp_base85::deserialize(invalid);
assert!(result.is_err(), "Invalid base85 input should return error");
}
#[test]
fn encrypted_payload_serializes_with_k_ct_discriminator() {
let record = EncryptedRecord {
iv: Default::default(),
ciphertext: vec![1; 16],
tag: vec![2; 16],
descriptor: "users/email".to_string(),
keyset_id: Some(Uuid::nil()),
decryption_policy: None,
};
let payload = EqlCiphertext::Encrypted(EncryptedPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "email"),
ciphertext: record,
hmac_256: Some("deadbeef".into()),
bloom_filter: None,
ore_block_u64_8_256: None,
});
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(value["k"], "ct");
assert_eq!(value["v"], EQL_SCHEMA_VERSION);
assert_eq!(value["i"]["t"], "users");
assert_eq!(value["i"]["c"], "email");
assert_eq!(value["hm"], "deadbeef");
assert!(value.get("c").is_some());
assert!(value.get("sv").is_none());
assert!(value.get("ob").is_none());
}
#[test]
fn ste_vec_entry_term_round_trips_under_flatten() {
let term: SteVecEntryTerm =
serde_json::from_value(serde_json::json!({ "hm": "deadbeef" })).unwrap();
match term {
SteVecEntryTerm::Hmac { hmac_256 } => assert_eq!(hmac_256, "deadbeef"),
other => panic!("expected Hmac, got {other:?}"),
}
let term: SteVecEntryTerm =
serde_json::from_value(serde_json::json!({ "oc": "cafebabe" })).unwrap();
match term {
SteVecEntryTerm::OreCllw { ore_cllw_8 } => assert_eq!(ore_cllw_8, "cafebabe"),
other => panic!("expected OreCllw, got {other:?}"),
}
}
#[test]
fn query_payload_root_serializes_with_k_ct() {
let payload = EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "name"),
term: RootQueryTerm::BloomFilter {
bloom_filter: vec![1, 2, 3],
},
});
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(value["k"], "ct");
assert_eq!(value["bf"], serde_json::json!([1, 2, 3]));
assert!(value.get("c").is_none(), "query payloads omit ciphertext");
}
#[test]
fn eql_output_round_trips_untagged() {
let query = EqlOutput::Query(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "name"),
term: RootQueryTerm::Hmac {
hmac_256: "deadbeef".into(),
},
}));
let value = serde_json::to_value(&query).unwrap();
assert_eq!(value["k"], "ct");
assert_eq!(value["hm"], "deadbeef");
assert!(
value.get("c").is_none(),
"Query payload must not serialize a ciphertext — if this fires, Store won disambiguation"
);
let back: EqlOutput = serde_json::from_value(value).unwrap();
match back {
EqlOutput::Query(EqlQueryPayload::Encrypted(p)) => {
assert_eq!(p.identifier, Identifier::new("users", "name"));
match p.term {
RootQueryTerm::Hmac { hmac_256 } => assert_eq!(hmac_256, "deadbeef"),
other => panic!("expected Hmac term, got {other:?}"),
}
}
other => panic!("expected EqlOutput::Query(Encrypted), got {other:?}"),
}
let record = EncryptedRecord {
iv: Default::default(),
ciphertext: vec![1; 16],
tag: vec![2; 16],
descriptor: "users/email".to_string(),
keyset_id: Some(Uuid::nil()),
decryption_policy: None,
};
let store = EqlOutput::Store(EqlCiphertext::Encrypted(EncryptedPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "email"),
ciphertext: record,
hmac_256: Some("cafebabe".into()),
bloom_filter: None,
ore_block_u64_8_256: None,
}));
let value = serde_json::to_value(&store).unwrap();
assert_eq!(value["k"], "ct");
assert!(value.get("c").is_some());
let back: EqlOutput = serde_json::from_value(value).unwrap();
match back {
EqlOutput::Store(EqlCiphertext::Encrypted(p)) => {
assert_eq!(p.identifier, Identifier::new("users", "email"));
assert_eq!(p.hmac_256.as_deref(), Some("cafebabe"));
}
other => panic!("expected EqlOutput::Store(Encrypted), got {other:?}"),
}
}
#[test]
fn query_payload_ste_vec_selector_serializes_with_k_sv() {
let payload = EqlQueryPayload::SteVec(SteVecQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "profile"),
term: SteVecQueryTerm::Selector {
selector: "abcd".into(),
},
});
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(value["k"], "sv");
assert_eq!(value["s"], "abcd");
}
#[test]
fn eql_output_query_hmac_renders_exact_json() {
let query = EqlOutput::Query(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "name"),
term: RootQueryTerm::Hmac {
hmac_256: "deadbeef".into(),
},
}));
assert_eq!(
serde_json::to_value(&query).unwrap(),
serde_json::json!({
"k": "ct",
"v": EQL_SCHEMA_VERSION,
"i": { "t": "users", "c": "name" },
"hm": "deadbeef",
})
);
}
#[test]
fn eql_output_query_bloom_filter_renders_exact_json() {
let query = EqlOutput::Query(EqlQueryPayload::Encrypted(EncryptedQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "name"),
term: RootQueryTerm::BloomFilter {
bloom_filter: vec![1, 2, 3],
},
}));
assert_eq!(
serde_json::to_value(&query).unwrap(),
serde_json::json!({
"k": "ct",
"v": EQL_SCHEMA_VERSION,
"i": { "t": "users", "c": "name" },
"bf": [1, 2, 3],
})
);
}
#[test]
fn eql_output_query_ste_vec_selector_renders_exact_json() {
let query = EqlOutput::Query(EqlQueryPayload::SteVec(SteVecQueryPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "profile"),
term: SteVecQueryTerm::Selector {
selector: "abcd".into(),
},
}));
assert_eq!(
serde_json::to_value(&query).unwrap(),
serde_json::json!({
"k": "sv",
"v": EQL_SCHEMA_VERSION,
"i": { "t": "users", "c": "profile" },
"s": "abcd",
})
);
}
#[test]
fn eql_output_store_encrypted_renders_exact_json() {
let record = EncryptedRecord {
iv: Default::default(),
ciphertext: vec![1; 16],
tag: vec![2; 16],
descriptor: "users/email".to_string(),
keyset_id: Some(Uuid::nil()),
decryption_policy: None,
};
let store = EqlOutput::Store(EqlCiphertext::Encrypted(EncryptedPayload {
version: EQL_SCHEMA_VERSION,
identifier: Identifier::new("users", "email"),
ciphertext: record.clone(),
hmac_256: Some("cafebabe".into()),
bloom_filter: None,
ore_block_u64_8_256: None,
}));
let encoded_c = record.to_mp_base85().unwrap();
assert_eq!(
serde_json::to_value(&store).unwrap(),
serde_json::json!({
"k": "ct",
"v": EQL_SCHEMA_VERSION,
"i": { "t": "users", "c": "email" },
"c": encoded_c,
"hm": "cafebabe",
})
);
}
}