Skip to content

Commit 76c4d89

Browse files
committed
feat(eql): Identifier implements Describe; share the native visitor; pin encryption coverage
`Identifier` implements Stack Encrypt's `Describe` (#1050): its ZeroKMS descriptor is the table and the column as two parts, rendered `users/email`, and a name containing `/` is escaped rather than read as two. `IntoContext` now returns `Describe::to_context`, so the AAD the ciphertext is sealed under and the descriptor ZeroKMS binds are one tree seen two ways, and the identifier is the same context as a two-segment `stack_encrypt::Label` and as the pair a `struct = ..` derive binds. The rendering is defined by stack-encrypt's one renderer, not by this crate. Review items on #971 folded in here: - The module rustdoc told readers that Stack Encrypt was unpublished and pointed at a local helper that 7219ea1 removed. Both are gone; the crate is on crates.io and the dependency is path plus version. - The encryption test crate carried its own copy of the visitor that reads a stored `c` back as a native leaf. `NativeCiphertextVisitor` is now `#[doc(hidden)] pub` and the test imports it. - The generated structs promised `Identifier::for_column` in docs built without the `stack-encrypt` feature, where it does not exist. The prose is gated like the example link; `text.rs` is regenerated. - Plan section 5 asked for catalog-derived coverage with an explicit reason for every domain that carries no derive. `ENCRYPTION_DOMAINS` names the pairs that do, `encryption_gap` gives the reason for every other stored domain from the catalog's own facts, `encryption_attrs` follows that answer, and a test holds the three together: a domain added tomorrow fails until it has a derive or a reason. Refs #1049.
1 parent 0e60dc0 commit 76c4d89

4 files changed

Lines changed: 146 additions & 26 deletions

File tree

‎packages/eql/crates/eql-bindings/src/encryption.rs‎

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
//! The output type selects the encryption operations: [`TextEq`] includes
2121
//! ciphertext and an equality term, while [`TextEqQuery`] contains only the term.
2222
//!
23+
//! An [`Identifier`] is Stack Encrypt's [`Describe`]: its ZeroKMS descriptor is
24+
//! the table and the column as two parts, rendered `users/email`, the same
25+
//! context and descriptor as the two-segment [`stack_encrypt::Label`]. A
26+
//! direct consumer of Stack Encrypt names its data with a `Label`; an EQL
27+
//! consumer names it with an `Identifier`, and the two interoperate.
28+
//!
2329
//! This complete example runs locally without credentials. It uses real
2430
//! encryption with `stack_kms::FakeDataKeySource`, whose keys exist only for
2531
//! this process. Add `stack-encrypt`, `stack-kms` (with `default-features = false`
2632
//! and `features = ["test-support"]`), and `tokio` (with `features = ["rt", "macros"]`)
27-
//! to your example's dependencies. Stack Encrypt is currently unpublished;
28-
//! use the local development helper described in the crate README.
33+
//! to your example's dependencies.
2934
//!
3035
//! The code below is executed by `cargo test -p eql-encryption-tests --test
3136
//! text_eq_example`. It is excluded from this crate's doctests so its optional
@@ -72,9 +77,9 @@ use stack_encrypt::sem::EqualityTerm;
7277
use stack_encrypt::target::transcode::{Reader, Transcode, Visitor};
7378
use stack_encrypt::target::{self, AeadContext, CallerContext};
7479
use stack_encrypt::{
75-
CipherText, ContextPiece, Decrypt, DecryptField, DecryptInto, Decryptable, Decryption, Encrypt,
76-
EncryptFrom, Encryption, Error, IntoContext, MaybeEmpty, NonEmpty, SealedValue,
77-
StackCipherText,
80+
CipherText, ContextPiece, Decrypt, DecryptField, DecryptInto, Decryptable, Decryption,
81+
Describe, DescriptorBuilder, Encrypt, EncryptFrom, Encryption, Error, IntoContext, MaybeEmpty,
82+
NonEmpty, SealedValue, StackCipherText,
7883
};
7984
use vitaminc_prf::PrfValue;
8085

@@ -110,13 +115,27 @@ impl MaybeEmpty for Identifier {
110115
self.t.is_empty() || self.c.is_empty()
111116
}
112117
}
118+
/// The identifier's descriptor is the table and the column, two parts: it
119+
/// renders `users/email` in the ZeroKMS log, and a table or column that
120+
/// contains `/` is escaped rather than read as two. The same two parts are the
121+
/// context the ciphertext is sealed under and the equality term is derived
122+
/// under ([`IntoContext`] below returns [`Describe::to_context`]), so the
123+
/// descriptor ZeroKMS binds and the AAD never disagree. It is the same
124+
/// context as the two-segment [`stack_encrypt::Label`] and as the pair a
125+
/// `#[stash(struct = .., context = "<table>")]` derive binds.
126+
impl Describe for Identifier {
127+
fn describe(&self, out: &mut DescriptorBuilder) {
128+
let _ = out.text(self.t.as_str()).text(self.c.as_str());
129+
}
130+
}
131+
113132
// One context view serves both derivations: vitaminc's `IntoAad` and
114133
// `IntoPrfContext` are blankets over `IntoContext`, so the ciphertext AAD, the
115134
// ZeroKMS descriptor and the equality term's PRF context all see the same
116-
// (table, column) pair.
135+
// (table, column) pair — the one `Describe` pushes.
117136
impl<'a> IntoContext<'a> for Identifier {
118137
fn into_context(self) -> ContextPiece<'a> {
119-
(self.t, self.c).into_context()
138+
self.to_context()
120139
}
121140
}
122141

@@ -158,7 +177,11 @@ impl<S: Encrypt + Clone> EncryptFrom<S> for Ciphertext {
158177
}
159178
}
160179

161-
struct NativeCiphertextVisitor;
180+
/// Reads a stored `c` back as the native sealed leaf it encodes. Public for
181+
/// the encryption test crate, which opens the leaf with Stack Encrypt directly
182+
/// to prove the two decryption paths agree; not part of the supported API.
183+
#[doc(hidden)]
184+
pub struct NativeCiphertextVisitor;
162185
impl Visitor for NativeCiphertextVisitor {
163186
type Value = StackCipherText;
164187
fn sealed(self, leaf: SealedValue) -> Result<Self::Value, Error> {
@@ -219,3 +242,50 @@ impl<P, Ctx> DecryptField<P, Ctx> for Hmac256 {
219242
None
220243
}
221244
}
245+
246+
#[cfg(test)]
247+
mod tests {
248+
use stack_encrypt::{nonempty, Descriptor, Label};
249+
250+
use super::*;
251+
252+
fn id(t: &str, c: &str) -> Identifier {
253+
Identifier {
254+
t: t.into(),
255+
c: c.into(),
256+
}
257+
}
258+
259+
#[test]
260+
fn an_identifier_describes_as_table_slash_column() {
261+
let email = id("users", "email");
262+
assert_eq!(email.descriptor().as_str(), "users/email");
263+
assert_eq!(Descriptor::of(email.clone()).as_str(), "users/email");
264+
// Through `for_column`, the context the targets are sealed under.
265+
let column = Identifier::for_column("users", "email").unwrap();
266+
assert_eq!(Descriptor::of(column).as_str(), "users/email");
267+
}
268+
269+
#[test]
270+
fn an_identifier_is_the_two_segment_label_and_the_derive_pair() {
271+
let email = id("users", "email");
272+
let label = Label::new(["users", "email"]).unwrap();
273+
assert_eq!(email.to_context(), label.to_context());
274+
assert_eq!(email.clone().into_context(), label.into_context());
275+
assert_eq!(
276+
email.into_context(),
277+
nonempty!("users").with("email").into_context()
278+
);
279+
}
280+
281+
#[test]
282+
fn a_separator_in_a_name_is_escaped_never_a_third_part() {
283+
let odd = id("users/email", "x");
284+
assert_eq!(odd.descriptor().as_str(), "b64:dXNlcnMvZW1haWw=/x");
285+
assert_ne!(odd.descriptor(), id("users", "email").descriptor());
286+
assert_ne!(
287+
odd.descriptor(),
288+
Descriptor::of(nonempty!("users").with("email").with("x"))
289+
);
290+
}
291+
}

‎packages/eql/crates/eql-bindings/src/v3/text.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ impl DomainType for Text {
5656
doc = " Plaintext input and decrypted output: [`String`]. With the `stack-encrypt` feature, implements `EncryptFrom<String>` and `DecryptInto<String>`."
5757
)]
5858
#[cfg_attr(
59-
doc,
59+
all(doc, feature = "stack-encrypt"),
6060
doc = " Encryption context: `NonEmpty<Identifier>`, constructed with `Identifier::for_column(table, column)` and stored in `i`."
6161
)]
6262
#[cfg_attr(
@@ -340,7 +340,7 @@ impl DomainType for TextSearch {
340340
doc = " Plaintext input: [`String`]. With the `stack-encrypt` feature, implements `EncryptFrom<String>` to build equality query terms. Query operands do not decrypt."
341341
)]
342342
#[cfg_attr(
343-
doc,
343+
all(doc, feature = "stack-encrypt"),
344344
doc = " Encryption context: `NonEmpty<Identifier>`, constructed with `Identifier::for_column(table, column)` and stored in `i`."
345345
)]
346346
#[cfg_attr(

‎packages/eql/crates/eql-codegen/src/bindings.rs‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,41 @@ fn render_query_struct(family: &DomainFamily, domain: &Domain) -> TokenStream {
304304
}
305305
}
306306

307-
/// Text equality is the first supported encryption domain. Keep attributes in
308-
/// the generator so regeneration preserves the opt-in derives without enabling
309-
/// other domains before their operations and plaintext contracts are supported.
307+
/// The `(family, domain)` pairs whose generated structs carry the opt-in
308+
/// `stack-encrypt` derives. Text equality is the first; every other stored
309+
/// domain has a reason in [`encryption_gap`], and a test holds the two
310+
/// together so a new domain cannot arrive without either a derive or a reason.
311+
pub const ENCRYPTION_DOMAINS: &[(&str, &str)] = &[("text", "eq")];
312+
313+
/// Why a stored domain carries no `stack-encrypt` derive yet, or `None` when
314+
/// it does. Derived from the catalog's own facts (family, terms), not from a
315+
/// per-domain list, so it answers for a domain added tomorrow.
316+
pub fn encryption_gap(family: &DomainFamily, domain: &Domain) -> Option<&'static str> {
317+
if ENCRYPTION_DOMAINS.contains(&(family.name, domain.name)) {
318+
return None;
319+
}
320+
Some(if family.name != "text" {
321+
"only the text family's plaintext encoding is specified for the stack-encrypt \
322+
producer profile; the numeric, date, boolean and JSON encodings are plan section 2 \
323+
and have not been settled"
324+
} else if domain.terms.iter().any(|t| !matches!(t, Term::Hm)) {
325+
"ordering and match terms need stack-encrypt operations that have not landed \
326+
(block-ORE and OPE term derivation, bloom-filter match terms)"
327+
} else {
328+
"the storage-only text domain follows once TextEq is proven end to end in \
329+
PostgreSQL and the interoperability matrix is recorded"
330+
})
331+
}
332+
333+
/// Keep attributes in the generator so regeneration preserves the opt-in
334+
/// derives without enabling other domains before their operations and
335+
/// plaintext contracts are supported ([`ENCRYPTION_DOMAINS`]).
310336
fn encryption_attrs(
311337
family: &DomainFamily,
312338
domain: &Domain,
313339
query: bool,
314340
) -> (TokenStream, TokenStream, TokenStream) {
315-
if family.name != "text" || domain.name != "eq" {
341+
if encryption_gap(family, domain).is_some() {
316342
return Default::default();
317343
}
318344
let decrypt = (!query).then(
@@ -329,7 +355,7 @@ fn encryption_attrs(
329355
quote! {
330356
#[cfg_attr(doc, doc = "")]
331357
#[cfg_attr(doc, doc = #plaintext)]
332-
#[cfg_attr(doc, doc = " Encryption context: `NonEmpty<Identifier>`, constructed with `Identifier::for_column(table, column)` and stored in `i`.")]
358+
#[cfg_attr(all(doc, feature = "stack-encrypt"), doc = " Encryption context: `NonEmpty<Identifier>`, constructed with `Identifier::for_column(table, column)` and stored in `i`.")]
333359
#[cfg_attr(all(doc, feature = "stack-encrypt"), doc = " See the [complete encryption example](crate::encryption#example).")]
334360
#[cfg_attr(feature = "stack-encrypt", derive(stack_encrypt::EncryptFrom))]
335361
#decrypt
@@ -1405,4 +1431,36 @@ mod tests {
14051431
// Idempotent: re-running rustfmt over the output changes nothing.
14061432
assert_eq!(rustfmt(&out), out);
14071433
}
1434+
/// Plan section 5: catalog-derived coverage, with an explicit reason for
1435+
/// every domain that carries no derive. The catalog is the input, so a
1436+
/// domain added tomorrow fails here until it has a derive or a reason.
1437+
#[test]
1438+
fn every_stored_domain_has_an_encryption_derive_or_a_reason() {
1439+
let mut derived = Vec::new();
1440+
for (family, domain) in stored_payload_domains() {
1441+
match encryption_gap(family, domain) {
1442+
None => derived.push((family.name, domain.name)),
1443+
Some(reason) => assert!(
1444+
!reason.is_empty(),
1445+
"{}: an empty reason explains nothing",
1446+
domain.full_name(family.name)
1447+
),
1448+
}
1449+
}
1450+
// Every derived pair is a real catalog domain, and every catalog
1451+
// derive is listed: the list and the catalog agree both ways.
1452+
assert_eq!(derived, ENCRYPTION_DOMAINS.to_vec());
1453+
// The generated attributes follow the same answer: exactly the listed
1454+
// pairs carry the opt-in derive.
1455+
for (family, domain) in stored_payload_domains() {
1456+
let (attrs, _, _) = encryption_attrs(family, domain, false);
1457+
let has_derive = attrs.to_string().contains("stack_encrypt :: EncryptFrom");
1458+
assert_eq!(
1459+
has_derive,
1460+
ENCRYPTION_DOMAINS.contains(&(family.name, domain.name)),
1461+
"{}",
1462+
domain.full_name(family.name)
1463+
);
1464+
}
1465+
}
14081466
}

‎packages/eql/tests/encryption/tests/text_eq.rs‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
11
mod common;
22

3+
use eql_bindings::encryption::NativeCiphertextVisitor as NativeLeaf;
34
use eql_bindings::v3::terms::{Ciphertext, Hmac256};
45
use eql_bindings::v3::text::{TextEq, TextEqQuery};
56
use eql_bindings::{Identifier, SchemaVersion};
6-
use stack_encrypt::target::transcode::{Reader, Transcode, Visitor};
7-
use stack_encrypt::{
8-
Cipher, CipherText, Encrypt, Error, IntoAad, NonEmpty, SealedValue, StackCipherText,
9-
};
7+
use stack_encrypt::target::transcode::{Reader, Transcode};
8+
use stack_encrypt::{Cipher, Encrypt, IntoAad, NonEmpty};
109

1110
fn column() -> NonEmpty<Identifier> {
1211
Identifier::for_column("users", "email").unwrap()
1312
}
14-
struct NativeLeaf;
15-
impl Visitor for NativeLeaf {
16-
type Value = StackCipherText;
17-
fn sealed(self, leaf: SealedValue) -> Result<Self::Value, Error> {
18-
Ok(CipherText::Single(leaf))
19-
}
20-
}
2113

2214
#[tokio::test]
2315
async fn text_round_trips_through_json_and_both_canonical_directions() {

0 commit comments

Comments
 (0)