Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 211 additions & 0 deletions packages/eql/tests/encryption/tests/migration_shim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! A migration shim: an EQL v4 type encrypted from a value that contains its
//! own column, with no context argument.
//!
//! To move EQL v3 data to v4, each v3 value is decrypted, then encrypted again
//! as v4. The decrypted value arrives with the column it was stored in, `i`.
//! The shim takes the context from `i`, through `Encryption::context_from`,
//! and the value from `p`. To rename the column, edit `i` before the call.
//!
//! One generic impl covers every v4 type whose context is its column. The
//! shim must write the v4 type itself, byte for byte where the bytes are
//! deterministic. A query built the usual way finds the value, and the value
//! opens as the original plaintext.

mod common;

use std::cmp::Ordering;

use eql_bindings::v4::integer::{IntegerOrdOre, IntegerOrdOreQuery};
use eql_bindings::v4::text::{TextEq, TextEqQuery};
use eql_bindings::Identifier;
use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext, RequiredContext, Zeroize};
use stack_encrypt::{DecryptFrom, EncryptInto, Error, NonEmpty};

/// A value decrypted from EQL v3, with the column it was stored in.
struct V3Plaintext<P> {
i: Identifier,
p: P,
}
impl<P: Zeroize> Zeroize for V3Plaintext<P> {
// The column is not secret: it is stored in the clear in `i`.
fn zeroize(&mut self) {
self.p.zeroize();
}
}

/// A v4 type, encrypted from a v3 plaintext, under the column in the plaintext.
struct Shim<T>(T);

impl<P, T> EncryptTarget<V3Plaintext<P>> for Shim<T>
where
P: Zeroize,
T: EncryptTarget<P, ContextInput = RequiredContext<NonEmpty<Identifier>>>,
{
type ContextInput = NoCallerContext;
fn encryption<'s, K: 'static>() -> Encryption<'s, V3Plaintext<P>, Self, K, NoCallerContext>
where
V3Plaintext<P>: 's,
{
T::encryption()
.project(|source: &V3Plaintext<P>| &source.p)
.context_from(|source: &V3Plaintext<P>| &source.i)
.map(Shim)
}
}

fn identifier(table: &str, column: &str) -> Identifier {
Identifier {
t: table.to_owned(),
c: column.to_owned(),
}
}

fn column(table: &str, column: &str) -> NonEmpty<Identifier> {
Identifier::for_column(table, column).unwrap()
}

async fn email_query(
keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>,
name: &str,
) -> TextEqQuery {
"alan@example.com"
.to_owned()
.encrypt_into_with_context(keyset, column("customers", name))
.await
.unwrap()
}

async fn age_query(
keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>,
age: i32,
) -> IntegerOrdOreQuery {
age.encrypt_into_with_context(keyset, column("customers", "age"))
.await
.unwrap()
}

#[tokio::test]
async fn a_v3_value_encrypts_as_the_v4_type_with_no_context_argument() {
let (cipher, _) = common::cipher().await;
let keyset = cipher.default_keyset();

let source = V3Plaintext {
i: identifier("customers", "email"),
p: "alan@example.com".to_owned(),
};
let Shim(migrated): Shim<TextEq> = source.encrypt_into(&keyset).await.unwrap();

assert_eq!(
(migrated.i.t.as_str(), migrated.i.c.as_str()),
("customers", "email")
);
let query: TextEqQuery = "alan@example.com"
.to_owned()
.encrypt_into_with_context(&keyset, column("customers", "email"))
.await
.unwrap();
assert_eq!(
migrated.hm, query.hm,
"a query built the usual way finds it"
);

let opened = String::decrypt_from_with_context(migrated, &keyset, column("customers", "email"))
.await
.unwrap();
assert_eq!(opened, "alan@example.com");
}

#[tokio::test]
async fn the_same_shim_covers_an_ordered_type() {
let (cipher, _) = common::cipher().await;
let keyset = cipher.default_keyset();

let source = V3Plaintext {
i: identifier("customers", "age"),
p: 41_i32,
};
let Shim(migrated): Shim<IntegerOrdOre> = source.encrypt_into(&keyset).await.unwrap();

// A block ORE term contains a nonce, so the test compares it by order.
// The migrated value orders after the query for 40, equal to the query
// for 41, and before the query for 42.
let compare = |query: &IntegerOrdOreQuery| migrated.ob.partial_cmp(&query.ob);
assert_eq!(
compare(&age_query(&keyset, 40).await),
Some(Ordering::Greater)
);
assert_eq!(
compare(&age_query(&keyset, 41).await),
Some(Ordering::Equal)
);
assert_eq!(compare(&age_query(&keyset, 42).await), Some(Ordering::Less));

let opened = i32::decrypt_from_with_context(migrated, &keyset, column("customers", "age"))
.await
.unwrap();
assert_eq!(opened, 41);
}

#[tokio::test]
async fn a_renamed_column_is_the_column_the_value_is_sealed_under() {
let (cipher, _) = common::cipher().await;
let keyset = cipher.default_keyset();

let mut source = V3Plaintext {
i: identifier("customers", "email"),
p: "alan@example.com".to_owned(),
};
source.i.c = "contact_email".to_owned();
let Shim(migrated): Shim<TextEq> = source.encrypt_into(&keyset).await.unwrap();
assert_eq!(migrated.i.c, "contact_email");

// The query for the new column finds the value. The query for the old
// column does not.
assert_eq!(migrated.hm, email_query(&keyset, "contact_email").await.hm);
assert_ne!(migrated.hm, email_query(&keyset, "email").await.hm);

// The ciphertext is sealed under the new column, so it opens there.
let opened = String::decrypt_from_with_context(
migrated.clone(),
&keyset,
column("customers", "contact_email"),
)
.await
.unwrap();
assert_eq!(opened, "alan@example.com");

// Opened as the old column, the value is refused before any key is
// requested, because the stored column does not match.
let result =
String::decrypt_from_with_context(migrated.clone(), &keyset, column("customers", "email"))
.await;
assert!(
matches!(result, Err(Error::ContextMismatch { .. })),
"{:?}",
result.err()
);

// Change the stored column back to the old one. The ciphertext still
// fails to open, because it is bound to the new column, not to the label.
let mut relabelled = migrated;
relabelled.i.c = "email".to_owned();
let result = String::decrypt_from(relabelled, &keyset).await;
assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err());
}

#[tokio::test]
async fn an_empty_column_is_refused_before_any_key_is_requested() {
let (cipher, calls) = common::cipher().await;
let keyset = cipher.default_keyset();

let source = V3Plaintext {
i: identifier("customers", ""),
p: "alan@example.com".to_owned(),
};
let result = source.encrypt_into::<Shim<TextEq>, _>(&keyset).await;
assert!(matches!(result, Err(Error::Other(_))));
assert!(
calls.lock().unwrap().generate.is_empty(),
"no key was requested"
);
}
15 changes: 15 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A target can take its context from a field of the plaintext:
`Encryption::context_from`** (#1586). Some values arrive together with the
context they are stored under. For example, a value decrypted from an older
format comes with its column. Such a value now encrypts with no context
argument.
- A type that wraps the target names the field once, in its
`EncryptTarget` declaration. The type declares `NoCallerContext`.
- The target must store its context in the row, so its context input must
be a typed `NonEmpty<C>`. Records with a `context_field`, and EQL types,
qualify. Opening a value never needs its plaintext.
- The field is picked by reference, so a context cannot be computed from
the sealed value.
- An empty context fails the encryption. No key is requested.
- The picked field is sent to ZeroKMS and stored in the clear. The field
must not be secret.
- **Optional values through the owned API: `Option<T>`** (#1585). An
`Option<S>` encrypts into an `Option<T>` for any owned target `T`, and
decrypts back.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ literal `"users/age"`. A column is the pair now, which renders the ZeroKMS
descriptor `users/age`; the joined literal is one part and renders escaped.
See ADR-0006.)

A third way, `context_from`, reads the context from a field of the plaintext.
See the amendment of 2026-11-09 below.

Two further combinators change nothing about *which* context reaches a
subtree, only its type at the root. `accepting` converts the context a record
declares its caller supplies into the one its operations need, once, at the
Expand Down Expand Up @@ -354,3 +357,38 @@ language allows; Go-1). The dynamic-to-static dispatch itself is unchanged.
Decisions 1 to 4, 7 and 8 do not change. Decision 2's `extend` is what
`Cipher.Extend` does from Go: a caller's context appends to the declared one,
and never replaces it.

## Amended 2026-11-09 (#1586)

Decision 2 gains a third way to set the context of a subtree:
`Encryption::context_from`. `under` and `extend` do not cover one case: a
value that arrives together with the context it is stored under. For
example, a value decrypted from EQL v3 comes with its column. Re-encrypting
it as EQL v4 should read that column, not ask the caller to supply it again.

`context_from(pick)` reads the context from a field of the plaintext.
`context_from` discharges the requirement into `NoCallerContext`, so the
caller supplies no context. Three limits keep the reasons for decision 2:

- **The target must store its context.** The context input of the target
must be a typed context, `NonEmpty<C>`. A record that stores its context in
a `context_field` declares such an input. An EQL type declares one too, and
stores its column in `i`. So the context comes from the plaintext on the
way in, and from the row on the way out. Opening a value never needs its
plaintext.
- **`pick` returns a reference to a field of the plaintext.** So `pick`
cannot compute a context from the plaintext. When the context type differs
from the type of the sealed value, the sealed field cannot be picked at
all.
- **The declaration names the field.** `pick` is a plain function pointer,
visible at review, like the selector of `project`. A value supplied at the
call could reach one side of a target and not the other. A named field
cannot.

`context_from` replaces the surrounding context, and does not extend it. A
target that takes its context this way cannot be a field of a record that
declares contexts, because its context input is `NoCallerContext`.

Two halves of one target, zipped after two `context_from` calls, could each
take a different field. Decision 1 already accepts that difference for
contexts declared per side, because the declaration names each field.
Loading
Loading