diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 3ac4db69f..602ecd6da 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -81,6 +81,9 @@ exclude_re = [ 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace for (DeclaredContext|ExpectedContext)>::from -> Self with Default::default\(\)$', + # A caller-free declaration adds nothing to the declared context: the + # conversion returns Self::default(). + 'stack-encrypt/src/target/input\.rs:\d+:\d+: replace for super::DeclaredContext>::from -> Self with Default::default\(\)$', # The resolver of a build without EQL types holds none: its `targets` IS # the empty list (`dynamic::target::NoTargets`), which a test asserts on # and `resolve` turns into `TargetError::NoTargets`; `vec![]` is the body. diff --git a/.changeset/stack-encrypt-owned-scalars.md b/.changeset/stack-encrypt-owned-scalars.md new file mode 100644 index 000000000..450b783af --- /dev/null +++ b/.changeset/stack-encrypt-owned-scalars.md @@ -0,0 +1,13 @@ +--- +--- + +Stack Encrypt's Rust scalar API now consumes plaintext and returns an opaque +`Ciphertext`, with separate methods for omitted and supplied context. Owned +`EncryptInto` / `DecryptFrom` conversions use explicit context capabilities. +The stored sealed-leaf format is unchanged. + +Low-level structural encryption and decryption use `encrypt_tree` and +`decrypt_tree`, returning and opening a tree of individually sealed leaves. + +This is a Rust-only release, versioned by release-plz from the conventional +commit. No npm package needs a version bump; EQL changes only adapt tests. diff --git a/Cargo.lock b/Cargo.lock index 50b087fea..e14762064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3420,6 +3420,7 @@ dependencies = [ "ore-rs", "proptest", "rand 0.10.1", + "rmp-serde", "rust_decimal", "serde", "serde_json", diff --git a/packages/eql/tests/encryption/tests/text_eq.rs b/packages/eql/tests/encryption/tests/text_eq.rs index 9e77000b6..d3526e38d 100644 --- a/packages/eql/tests/encryption/tests/text_eq.rs +++ b/packages/eql/tests/encryption/tests/text_eq.rs @@ -52,7 +52,7 @@ async fn text_round_trips_through_json_and_both_canonical_directions() { let opened: String = cipher.decrypt_as(stored, Default::default()).await.unwrap(); assert_eq!(opened, value); - let native = keyset.encrypt(value.clone(), column()).await.unwrap(); + let native = keyset.encrypt_tree(value.clone(), column()).await.unwrap(); let stored = TextEq { v: SchemaVersion::CURRENT, i: column().into_inner(), diff --git a/packages/eql/tests/encryption/tests/v4_types.rs b/packages/eql/tests/encryption/tests/v4_types.rs index 0e69f11a4..587815fab 100644 --- a/packages/eql/tests/encryption/tests/v4_types.rs +++ b/packages/eql/tests/encryption/tests/v4_types.rs @@ -84,7 +84,7 @@ async fn sealed_from_frame( keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>, value: ValueFrame, ) -> Ciphertext { - match keyset.encrypt(value, column()).await.unwrap() { + match keyset.encrypt_tree(value, column()).await.unwrap() { CipherText::Single(leaf) => CiphertextVisitor.sealed(leaf).unwrap(), _ => panic!("a scalar seals as one leaf"), } diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 4572b7ea4..ed09cfa97 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -72,17 +72,23 @@ building it needs a `T` that is both `IntoAad` and `IntoPrfContext`. A record made only of ciphertexts needs only the first, and the leaf it wraps (`StackCipherText`) asks for only that: its context is `AeadContext`. Such a record says so with `#[stash(context_type = AeadContext)]`, and then accepts -every context the canonical `keyset.encrypt(value, context)` path accepts, +every context the canonical `keyset.encrypt_tree(value, context)` path accepts, including a type that implements `IntoAad` alone: -```rust,ignore +```rust,no_run +use stack_encrypt::{DecryptInto, EncryptFrom, NonEmpty, StackCipherText}; +use stack_encrypt::target::{AeadContext, legacy::EncryptInto}; + #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = String, context_type = AeadContext)] struct SealedName { c: StackCipherText, } +# async fn example(keyset: stack_encrypt::KeysetCipher<'_, K>, name: String, tenant: &str) -> Result<(), Box> { let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(tenant)?).await?; +# Ok(()) +# } ``` The derive cannot pick this for you: it sees the field types' names, not @@ -93,8 +99,8 @@ record with a `context_field`, or a `struct` derive, settles its context itself and refuses the attribute. A declaration is executed by `keyset.encrypt_as(&value, context)` and -`cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and -`DecryptFrom` traits for the source-side spelling (`value.encrypt_into(&keyset)`, +`cipher.decrypt_as(record, context)`, or through the blanket +`stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}` traits for the source-side spelling (`value.encrypt_into(&keyset)`, `record.decrypt_into(&cipher, context)`). A hand-written target implements the same two declaration methods the derives emit, `EncryptFrom::encryption` and `DecryptInto::decryption`; neither receives the plaintext or a cipher. diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 8f4f4c4e7..fe6766795 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **`KeysetCipher::encrypt` and `decrypt` are the owned scalar API.** + `keyset.encrypt(value)` consumes an `OpaqueEncode` value (`bool`, the fixed-width + integers except `u8`, `f32`, `f64`, `String`), wipes it, and returns one + opaque `Ciphertext`; `keyset.decrypt::(ciphertext)` opens it to an `OpaqueDecode` type. + `encrypt_with_context` and `decrypt_with_context` bind a context. The + previous structural forms, which take any `Encrypt` value and a context and + return a `StackCipherText`, are renamed `encrypt_tree` and `decrypt_tree`. + `Ciphertext`'s bytes are the unchanged `SealedValue` v1 envelope, so + stored values open through either API. + Nullable `Option` values remain on `encrypt_tree` / `decrypt_tree` in this + slice, including Vitamin C's padded-null encoding for fixed-width types. +- **The root `EncryptInto` and `DecryptFrom` are new owned conversion + traits** over `EncryptTarget` / `DecryptSource`. The borrowed traits they + replace, and that `#[derive(EncryptFrom, DecryptInto)]` types use, are + `stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}`; import one + pair or the other, not both. - **`OpSpec`, `Operand` and the query term's wire form grow for documents** (#1350). `OpSpec` gains twelve operators and `Operand` gains `Text` and `TextList`; a binding that matches on either adds the arms. The query diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index f7ba8eaeb..31ddbbf93 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -143,6 +143,9 @@ trybuild = "1" # stack-guest-abi takes it: `std` carries the failure persistence and the # ChaCha RNG the runs are seeded with, and nothing else is needed. proptest = { version = "1.12", default-features = false, features = ["std"] } +# A binary Serde format for `Ciphertext`'s bytes form (tests/scalar.rs). +# Already in the graph through vitaminc-traits. +rmp-serde = "1.3" # The derive diagnostics are recorded with every feature on, as `test:unit` # and CI run them. They cannot be relied on to hold under both feature sets @@ -171,3 +174,7 @@ required-features = ["http"] [package.metadata.docs.rs] all-features = true + +[[example]] +name = "scalar" +required-features = ["http"] diff --git a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md index b27ccc3ed..4b5c4809c 100644 --- a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md +++ b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md @@ -61,6 +61,18 @@ not close it. A concrete associated context type does not implicitly accept arbitrary context extensions; any extension facility must preserve the declared base context and be specified explicitly. +Amended 2026-10-31, clarified 2026-11-01: the opaque scalar operation producing +`Ciphertext` accepts empty context, because it is the cipher-directed +contract in owned form: `keyset.encrypt(value)` seals what +`keyset.encrypt_tree(value, ())` seals. It reaches the shared core through a +separate entry. Context policy belongs to the encryption operation, not the +Rust type wrapping its output: `scalar_ciphertext().map(Wrapper)` retains the +scalar policy and the same authenticated bytes. Mapping already-encrypted output +does not derive a term or change its AAD. EQL ciphertext and term operations +still require `NonEmpty`; wrapping a scalar does not bypass their constructors. +Custom scalar wrappers can require caller context with `RequiredContext`, or +declare a nonempty context with `under`. + Transcoding consumes native encryption output through a custom encrypted-data protocol. It distinguishes sealed leaves, sequences, maps, authenticated absence and empty-container markers, passthrough metadata, and typed terms. Readers expose diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index d77e72940..3e0c8a978 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -27,7 +27,7 @@ //! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, OreTerm}; -use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText}; // --- The shapes ---------------------------------------------------------------- diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index 48e3e43ba..1e13f629e 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -126,7 +126,7 @@ async fn main() -> Result<(), Box> { // in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys). let ciphertext = cipher .default_keyset() - .encrypt(users, record.clone()) + .encrypt_tree(users, record.clone()) .await?; println!("what the stored ciphertext reveals:"); diff --git a/packages/stack-encrypt/examples/scalar.rs b/packages/stack-encrypt/examples/scalar.rs new file mode 100644 index 000000000..1d11733cd --- /dev/null +++ b/packages/stack-encrypt/examples/scalar.rs @@ -0,0 +1,33 @@ +//! Run with ZeroKMS credentials: `cargo run -p stack-encrypt --example scalar`. +//! Authenticate with `npx stash auth login`, or configure the `CS_*` credentials +//! described in the crate's quick start. Initialization fails without credentials. +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, StackCipher}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let cipher = StackCipher::new().await?; + let keyset = cipher.default_keyset(); + + let encrypted = keyset.encrypt(42i32).await?; + let stored = encrypted.to_bytes(); + let restored = Ciphertext::from_bytes(&stored)?; + let value: i32 = keyset.decrypt(restored).await?; + assert_eq!(value, 42); + + let encrypted = keyset.encrypt_with_context(42i32, ("users", "age")).await?; + let value: i32 = keyset + .decrypt_with_context(encrypted, ("users", "age")) + .await?; + assert_eq!(value, 42); + + let encrypted: Ciphertext = 42i32.encrypt_into(&keyset).await?; + let value = i32::decrypt_from(encrypted, &keyset).await?; + assert_eq!(value, 42); + + let encrypted: Ciphertext = 42i32 + .encrypt_into_with_context(&keyset, ("users", "age")) + .await?; + let value = i32::decrypt_from_with_context(encrypted, &keyset, ("users", "age")).await?; + assert_eq!(value, 42); + Ok(()) +} diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index e6de54335..579fee6c9 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -19,7 +19,8 @@ //! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; -use stack_encrypt::target::EncryptInto; + +use stack_encrypt::target::legacy::EncryptInto; use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; #[tokio::main(flavor = "current_thread")] diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index 9da02ab4c..433aea540 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -56,7 +56,7 @@ async fn main() -> Result<(), Box> { let keyset = cipher.default_keyset(); println!("connected; keyset {}", keyset.keyset_id()); - let ciphertext = keyset.encrypt("hello", "demo/greeting").await?; + let ciphertext = keyset.encrypt_tree("hello", "demo/greeting").await?; let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?; assert_eq!(plaintext, "hello"); println!("round-tripped a value under the default keyset"); @@ -72,7 +72,7 @@ async fn main() -> Result<(), Box> { // let customers = cipher // .keyset(IdentifiedBy::Name("customers".to_string().into())) // .await?; - // let ciphertext = customers.encrypt("hello", "demo/greeting").await?; + // let ciphertext = customers.encrypt_tree("hello", "demo/greeting").await?; // // `default_keyset()` above is not one of these: it is the client's own // default, the keyset a ZeroKMS administrator set for this client, and diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index c1962a854..3a1b43b1b 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -23,7 +23,7 @@ //! //! * **Encrypt** — [`PendingStackCipherText::plan`] walks the value into //! vitaminc's pending tree and does no I/O. A single -//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`] +//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt_tree`] //! convenience) then batches **one** `generate_keys` call for the whole //! tree, under the handle's keyset, and seals every leaf through //! `MintedKeys`, the sealing half of this crate's [`Cipher`], one key per @@ -667,7 +667,7 @@ impl StackCipher { /// .init() /// .await?; /// let tenant = cipher.keyset(IdentifiedBy::Name("acme".to_string().into())).await?; - /// let sealed = tenant.encrypt("hello".to_string(), nonempty!("greeting")).await?; + /// let sealed = tenant.encrypt_tree("hello".to_string(), nonempty!("greeting")).await?; /// # Ok(()) /// # } /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); @@ -1010,10 +1010,15 @@ fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::Hm } impl KeysetCipher<'_, K> { + /// Encrypt a value into a tree of individually sealed leaves. + /// + /// For one opaque scalar use + /// [`Self::encrypt`] or [`Self::encrypt_with_context`]. + /// /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data /// keys in a single batched `generate_keys` call under this keyset. /// Every key is requested under the [`Descriptor`] of `aad`. - pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result + pub async fn encrypt_tree<'a, T, A>(&self, value: T, aad: A) -> Result where T: Encrypt, A: IntoContext<'a>, @@ -1023,10 +1028,16 @@ impl KeysetCipher<'_, K> { pending.seal(self, context).await } - /// [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed + /// Decrypt a tree of individually sealed leaves. + /// + /// Like [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed /// under any other is [`Error::ForeignKeyset`], refused before any key /// is retrieved. - pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result + pub async fn decrypt_tree<'a, T, A>( + &self, + ciphertext: StackCipherText, + aad: A, + ) -> Result where T: Decrypt, A: IntoContext<'a>, @@ -1075,7 +1086,9 @@ impl StackCipher { /// Not keyset-scoped: each leaf carries the id of the keyset it was /// sealed under, and this opens leaves from any keyset the client is /// authorised for. To insist on one keyset, decrypt through its - /// [`KeysetCipher`] instead. + /// [`KeysetCipher::decrypt_tree`] instead. For an owned scalar + /// [`Ciphertext`](crate::Ciphertext), use [`DecryptFrom`](crate::DecryptFrom): + /// `i32::decrypt_from(ciphertext, &cipher)`. /// /// # Fan-out /// @@ -1087,7 +1100,7 @@ impl StackCipher { /// assembled from many tenants' leaves costs a round trip per tenant to /// open here, whoever assembled it. A service opening rows it does not /// trust — one tenant's data at a time — should hold that tenant's - /// [`KeysetCipher`], whose decrypt is one round trip at most and refuses + /// [`KeysetCipher::decrypt_tree`], which is one round trip at most and refuses /// a foreign leaf before any. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result where @@ -1497,7 +1510,7 @@ impl PendingStackCipherText { /// [`plan`](Self::plan), in the same shape (see the /// [descriptor docs](crate::descriptor)). The tree itself only carries /// the *derived* per-leaf AADs, so the root is named here; nothing can - /// check that the two agree, which is why [`KeysetCipher::encrypt`], which + /// check that the two agree, which is why [`KeysetCipher::encrypt_tree`], which /// does both steps from one value, is the form to prefer. /// /// Settles through the target layer's request carrier @@ -1784,13 +1797,16 @@ mod tests { .await .unwrap(); let keyset = cipher.default_keyset(); - let leaf = keyset.encrypt(7u32, "users/age").await.unwrap(); + let leaf = keyset.encrypt_tree(7u32, "users/age").await.unwrap(); let opened: Result = cipher.decrypt(leaf, "users/age").await; assert!( matches!(opened, Err(Error::OpenedType { .. })), "{opened:?}" ); - let seq = keyset.encrypt(vec![7u32, 8], "users/age").await.unwrap(); + let seq = keyset + .encrypt_tree(vec![7u32, 8], "users/age") + .await + .unwrap(); let opened: Result = cipher.decrypt(seq, "users/age").await; assert!( matches!(opened, Err(Error::OpenedType { .. })), diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 7a9a7b633..ba58194fb 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -57,7 +57,7 @@ //! # What is not here //! //! Encrypting a whole value is not: a [`ValueFrame`], like any source, -//! implements `Encrypt` already, so `keyset.encrypt(value, aad)` is the whole +//! implements `Encrypt` already, so `keyset.encrypt_tree(value, aad)` is the whole //! of it and needs nothing from this module. Nor is a second executor: nothing here calls //! the term functions or the seal path to produce a record. A capability a //! data plan needs and the builder lacks is added to the builder, once. diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index c1d891e5a..a4473d13b 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -6,9 +6,10 @@ //! it is what every operation that *mints* something binds to: sealing //! values, sealing records, deriving index terms. Decrypting is not //! keyset-scoped (a sealed leaf carries the id of the keyset it was sealed -//! under), so it lives on [`StackCipher`] as well, with the [`KeysetCipher`] -//! form adding a constraint rather than a capability — see -//! [`KeysetCipher::decrypt`]. +//! under). For an owned scalar, import [`DecryptFrom`](crate::DecryptFrom) +//! and use `i32::decrypt_from(ciphertext, &cipher)`; [`KeysetCipher::decrypt`] +//! adds the keyset constraint. For a structural tree, [`StackCipher::decrypt`] +//! opens across keysets and [`KeysetCipher::decrypt_tree`] adds that constraint. //! //! Keysets load lazily. [`StackCipher::keyset`] resolves an id or a name //! through a bounded cache and loads the keyset from ZeroKMS on a miss — diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index d45570e82..fa25af1c8 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -25,14 +25,15 @@ //! A [`StackCipher`] is scoped to one ZeroKMS client, and a [`KeysetCipher`] — //! the cipher bound to one of that client's keysets, from //! [`default_keyset`](StackCipher::default_keyset) or -//! [`keyset`](StackCipher::keyset) — encrypts any value that implements -//! vitaminc's [`Encrypt`] (`String`, `Vec`, `HashMap`, -//! `Option`, `Protected`, your own types through -//! `#[derive(vitaminc_aead::Encrypt)]`, and any nesting of them). Either -//! cipher decrypts back into any [`Decrypt`] type. Every scalar inside the value is -//! sealed under its **own** ZeroKMS data key, so each value access is an -//! individually auditable key retrieval — there is no long-lived key in your -//! process. +//! [`keyset`](StackCipher::keyset) — consumes a scalar with +//! [`KeysetCipher::encrypt`] and returns one opaque [`Ciphertext`]. Use +//! [`KeysetCipher::decrypt`] to open it under the same keyset. Each ciphertext +//! has its own ZeroKMS data key and independently auditable retrieval. +//! +//! Structural encryption, searchable targets and derives use the existing +//! plan/target engine. Its [`StackCipherText`] nodes and Vitamin C +//! [`Encrypt`]/[`Decrypt`] visitors remain available through the low-level +//! API while the owned Rust interface is extended beyond scalars. //! //! # Quick start //! @@ -52,10 +53,9 @@ use stack_encrypt::StackCipher; let cipher = StackCipher::new().await?; let keyset = cipher.default_keyset(); -// A `&str` encrypts as it is; decryption is owned, so it comes back a -// `String` — nothing borrows from a ciphertext. -let ciphertext = keyset.encrypt("secret message", ()).await?; -let plaintext: String = cipher.decrypt(ciphertext, ()).await?; +// Encryption consumes and wipes the owned plaintext. +let ciphertext = keyset.encrypt("secret message".to_owned()).await?; +let plaintext: String = keyset.decrypt(ciphertext).await?; assert_eq!(plaintext, "secret message"); # Ok(()) # } @@ -71,25 +71,29 @@ assert_eq!(plaintext, "secret message"); //! //! Encrypting binds to a keyset (every data key is minted under one); decrypting //! does not (every sealed leaf carries the id of the keyset it was sealed -//! under), so it goes through the client-scoped `cipher` — or through the -//! `keyset`, which then refuses leaves from any other keyset. +//! under). Open an owned scalar through the client with +//! `String::decrypt_from(ciphertext, &cipher)` (import [`DecryptFrom`]), or +//! through [`KeysetCipher::decrypt`] to refuse leaves from any other keyset. +//! [`StackCipher::decrypt`] still opens structural [`StackCipherText`] values; +//! its keyset-constrained counterpart is [`KeysetCipher::decrypt_tree`]. //! [`default_keyset`](StackCipher::default_keyset) is the client's own — //! the keyset a ZeroKMS administrator set for it — and is always that one. //! A client may use many others, one per tenant say; [`StackCipher::keyset`] //! selects any of them by id or name, loading it on first use. The [`keyset`](crate::keyset) //! module docs lay out the model. //! -//! The second argument is the *associated data* (AAD): anything that implements -//! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Context`]. It is +//! Use `encrypt_with_context` and `decrypt_with_context` for *associated data* +//! (AAD): anything implementing [`IntoContext`] — `()`, `&[u8]`, `&str`, a tuple, +//! or a derived [`Context`]. It is //! authenticated, not encrypted, and must be supplied identically on decrypt. //! Use it to bind a ciphertext to its context (a table name, a tenant, a record //! id) so it cannot be replayed elsewhere: //! //! ```no_run -//! # async fn example(cipher: stack_encrypt::StackCipher) -> Result<(), stack_encrypt::Error> { +//! # async fn example(cipher: stack_encrypt::StackCipher) -> Result<(), stack_encrypt::Error> { //! # let keyset = cipher.default_keyset(); -//! let ct = keyset.encrypt("4111 1111 1111 1111", "users/42/card").await?; -//! let card: String = cipher.decrypt(ct, "users/42/card").await?; // ok +//! let ct = keyset.encrypt_with_context("4111 1111 1111 1111".to_owned(), "users/42/card").await?; +//! let card: String = keyset.decrypt_with_context(ct, "users/42/card").await?; // ok //! # Ok(()) //! # } //! ``` @@ -197,30 +201,50 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! .init() //! .await?; //! let keyset = cipher.default_keyset(); -//! let ct = keyset.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; -//! let pt: Vec = cipher.decrypt(ct, ()).await?; -//! assert_eq!(pt, vec!["a", "b"]); +//! let ct = keyset.encrypt(42i32).await?; +//! let pt: i32 = keyset.decrypt(ct).await?; +//! assert_eq!(pt, 42); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); //! ``` //! //! # Storing ciphertext //! -//! [`encrypt`](KeysetCipher::encrypt) returns a [`StackCipherText`]: a tree whose -//! shape mirrors the value (a scalar is a single leaf, a `Vec` a sequence of -//! leaves, a map a set of named leaves) and whose leaves are [`SealedValue`]s. -//! A `SealedValue` is the persistable unit: its canonical, frozen byte -//! encoding is [`to_bytes`](SealedValue::to_bytes) / -//! [`from_bytes`](SealedValue::from_bytes) — the format a database column -//! holds and every language binding reads. Each leaf carries the id of the -//! keyset it was sealed under, which is what lets a column be opened with no -//! keyset named. For callers that manage their own -//! storage format it also implements `serde` `Serialize`/`Deserialize` and -//! offers [`into_parts`](SealedValue::into_parts) / -//! [`from_parts`](SealedValue::from_parts). Map keys are stored in the clear -//! (and authenticated); nothing else about a value is visible without its -//! data keys. Index terms have their own frozen encodings — see -//! [`sem`](crate::sem#byte-encodings). +//! [`KeysetCipher::encrypt`] returns one opaque [`Ciphertext`]. Persist it with +//! [`Ciphertext::to_bytes`] and restore it with [`Ciphertext::from_bytes`]. +//! These bytes are exactly the existing [`SealedValue`] v1 envelope, with no +//! extra wrapper. Its Serde form is those bytes: padded standard Base64 in a +//! human-readable format such as JSON, raw bytes in a binary one. Parsing +//! does not authenticate a value; decryption does. +//! +//! The scalar API accepts owned [`OpaqueEncode`] values. It wipes the owned source +//! before returning the pending request, including on planning failure. A +//! successful decrypt returns an ordinary owned value: the caller is then +//! responsible for its cleanup. Copy types may still have copies held by the +//! caller, and this is not a guarantee about every compiler-generated copy. +//! +//! For target-directed conversion, import [`EncryptInto`] and [`DecryptFrom`]: +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::{Ciphertext, EncryptInto, DecryptFrom, StackCipher}; +//! let cipher = StackCipher::builder() +//! .kms(stack_encrypt::kms::FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let encrypted: Ciphertext = 42i32.encrypt_into(&keyset).await?; +//! let plain = i32::decrypt_from(encrypted, &keyset).await?; +//! assert_eq!(plain, 42); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! Existing structural plans and derives still use [`StackCipherText`], a tree +//! of sealed leaves. Their low-level direct entry points are +//! [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`]; their +//! borrowed conversion helpers live in [`target::legacy`]. They are separate +//! from the owned scalar API. There is no generic `Serialize` fallback and +//! no implicit `Vec` blob. //! //! # What is authenticated //! @@ -244,7 +268,7 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! # Relationship to vitaminc //! //! A `KeysetCipher` seals through a vitaminc `Cipher`; everything vitaminc -//! can encrypt, it can encrypt, and the data model, the AAD derivations and +//! can encrypt remains available through the structural engine. Its data model, AAD derivations and //! the leaf wire format are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random //! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] //! carries is *not* that nonce: it identifies the data key, and is sent back to @@ -269,6 +293,8 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod cipher; +mod scalar; +pub use scalar::{Ciphertext, OpaqueDecode, OpaqueEncode}; #[cfg(test)] mod codes; pub mod descriptor; @@ -301,9 +327,10 @@ pub use stack_kms as kms; /// crate, so a binding encodes an error from any of them the same way. pub use stack_kms::{diagnostic, ErrorPayload}; pub use target::{ - CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, - EncryptFrom, EncryptInto, Encrypted, Encryption, Equality, Index, IndexSpec, Indexes, Json, - Match, Ope, Ore, OreBlock, Pending, PendingFuture, Request, Responses, TermSet, + CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, DecryptSource, Decryptable, + Decryption, EncryptFrom, EncryptInto, EncryptTarget, Encrypted, Encryption, Equality, Index, + IndexSpec, Indexes, Json, Match, Ope, Ore, OreBlock, Pending, PendingFuture, Request, + Responses, TermSet, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they @@ -327,8 +354,8 @@ pub use vitaminc_prf::IntoPrfContext; #[allow(deprecated)] pub use vitaminc_prf::PrfContext; -// And the proof every target-directed leaf asks for: a `NonEmpty` is what -// `encrypt_into_with_context` / `decrypt_into` take, built with `nonempty!` +// The nonempty proof used by searchable targets and legacy declarations, +// built with `nonempty!` // (a literal, checked at compile time) or `NonEmpty::new` (a runtime value, // checked once); `MaybeEmpty` is what a context type of your own implements // to be wrapped. `#[derive(EncryptFrom)]` names these through this crate. diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 2096fc562..2b8137a72 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -269,7 +269,7 @@ impl StackCipher { /// /// This is the fluent spelling of the cipher-directed and /// target-directed entry points on [`KeysetCipher`] - /// ([`encrypt`](KeysetCipher::encrypt), [`encrypt_as`](KeysetCipher::encrypt_as), + /// ([`encrypt_tree`](KeysetCipher::encrypt_tree), [`encrypt_as`](KeysetCipher::encrypt_as), /// [`run`](KeysetCipher::run)), which stay; see the [`plan`](crate::plan) /// module for the whole chain. pub fn encrypt<'a, S: ?Sized>(&'a self, value: &'a S) -> EncryptBuilder<'a, S, K> { @@ -325,7 +325,7 @@ pub struct EncryptBuilder<'a, S: ?Sized, K> { impl<'a, S: ?Sized, K: 'static> EncryptBuilder<'a, S, K> { /// Encrypt under `context`: one tree, sealed under that label, as - /// `keyset.encrypt(&value, label)` does. Text is parsed as a + /// `keyset.encrypt_tree(&value, label)` does. Text is parsed as a /// [`Label`] (`"documents/v2/body"` is three segments), and text that /// is not one is refused when the chain is awaited. pub fn context(self, context: impl IntoLabel) -> EncryptWithContext<'a, S, K> { diff --git a/packages/stack-encrypt/src/scalar.rs b/packages/stack-encrypt/src/scalar.rs new file mode 100644 index 000000000..b5aadf1f8 --- /dev/null +++ b/packages/stack-encrypt/src/scalar.rs @@ -0,0 +1,260 @@ +//! Single opaque ciphertexts over the existing sealed-leaf storage format. + +use crate::target::{ + DecryptFrom, DecryptSource, Decryption, EncryptInto, EncryptTarget, Encryption, + OptionalContext, Pending, ScalarContext, +}; +use crate::{CipherText, Decrypt, Error, KeysetCipher, LeafBytesError, SealedValue}; +use vitaminc_aead::plaintext::{Encrypt, Sink}; +use vitaminc_protected::OpaqueDebug; +use zeroize::Zeroize; + +mod sealed { + pub trait Encode { + const DEPTH: Option; + const FIXED: Option; + fn visit(&self, sink: S) -> Result; + } + pub trait Decode {} +} + +/// A plaintext type that encrypts as one opaque [`Ciphertext`]. +/// +/// Implemented for `bool`, the fixed-width signed integers, the unsigned +/// integers from `u16` through `u128`, `f32`, `f64` and `String`. The trait is +/// sealed: collections and borrowed values deliberately do not implement it. +/// `u8` waits on separating Vitamin C's byte-buffer and element +/// representations, and must not make `Vec` an implicit single +/// ciphertext. +/// Nullable `Option` values are also deferred in this scalar slice. Use +/// [`KeysetCipher::encrypt_tree`] and [`KeysetCipher::decrypt_tree`] for them; +/// fixed-width nullable values retain Vitamin C's padded-null representation. +pub trait OpaqueEncode: sealed::Encode + Zeroize + Send + 'static {} + +/// A plaintext type that one opaque [`Ciphertext`] decrypts to: the types +/// that implement [`OpaqueEncode`]. Sealed, like it. +pub trait OpaqueDecode: sealed::Decode + Decrypt + Zeroize + Send + 'static {} + +macro_rules! opaque { + ($($ty:ty),* $(,)?) => {$( + impl sealed::Encode for $ty { + const DEPTH: Option = <&$ty as Encrypt>::DEPTH; + const FIXED: Option = <&$ty as Encrypt>::FIXED; + fn visit(&self, sink: S) -> Result { + Encrypt::visit(self, sink) + } + } + impl sealed::Decode for $ty {} + impl OpaqueEncode for $ty {} + impl OpaqueDecode for $ty {} + )*}; +} +opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); + +pub(crate) struct ScalarRef<'a, S>(pub &'a S); +// Vitamin C 0.8 implements Encrypt for &Protected, but not ProtectedRef. +// This bridge preserves its encoding metadata while the owned conversion keeps +// the source in Protected. Replace it when the encoder accepts protected borrows. +impl Encrypt for ScalarRef<'_, S> { + const DEPTH: Option = ::DEPTH; + const FIXED: Option = ::FIXED; + fn visit(self, sink: V) -> Result { + sealed::Encode::visit(self.0, sink) + } +} + +/// One opaque ciphertext, with no exposed structural node variants. +/// +/// Its bytes are exactly the existing [`SealedValue`] v1 envelope. Parsing +/// validates the envelope shape, not authenticity; only decryption authenticates +/// it. No new storage framing or cryptographic context is introduced. +#[derive(Clone, OpaqueDebug)] +pub struct Ciphertext(pub(crate) SealedValue); +impl Ciphertext { + /// Encode the existing canonical sealed-leaf storage format. + pub fn to_bytes(&self) -> Vec { + self.0.to_bytes() + } + /// Parse that format. Authentication occurs only when decrypting. + /// + /// # Errors + /// Rejects truncated envelopes, unsupported versions and invalid lengths. + pub fn from_bytes(bytes: &[u8]) -> Result { + SealedValue::from_bytes(bytes).map(Self) + } + /// The key provider's tag carried by the envelope, read without + /// decrypting. This is the ZeroKMS key tag, not the AEAD tag, and it is + /// untrusted metadata until a successful opening. + pub fn key_tag(&self) -> &[u8] { + self.0.tag() + } + // Any tree other than one leaf is a shape refusal, not tampering. + pub(crate) fn from_tree(tree: crate::StackCipherText) -> Result { + match tree { + CipherText::Single(leaf) => Ok(Self(leaf)), + _ => Err(Error::OpenedType { + expected: "a single ciphertext", + }), + } + } +} +/// Human-readable formats store the canonical bytes as padded standard +/// Base64; binary formats store them as bytes. +impl serde::Serialize for Ciphertext { + fn serialize(&self, serializer: S) -> Result { + let bytes = self.to_bytes(); + if serializer.is_human_readable() { + serializer.serialize_str(&crate::json::base64(&bytes)) + } else { + serializer.serialize_bytes(&bytes) + } + } +} +impl<'de> serde::Deserialize<'de> for Ciphertext { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Ciphertext; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a sealed ciphertext as Base64 text or bytes") + } + fn visit_str(self, text: &str) -> Result { + let bytes = crate::json::unbase64(text).map_err(E::custom)?; + self.visit_bytes(&bytes) + } + fn visit_bytes(self, bytes: &[u8]) -> Result { + Ciphertext::from_bytes(bytes).map_err(E::custom) + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + // The hint is a length the input declares, unchecked: a few + // bytes can declare billions. Reserve at most a typical + // envelope's worth and let a longer one grow as it is read. + let mut bytes = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(256)); + while let Some(byte) = seq.next_element::()? { + bytes.push(byte); + } + self.visit_bytes(&bytes) + } + } + if deserializer.is_human_readable() { + deserializer.deserialize_str(V) + } else { + deserializer.deserialize_bytes(V) + } + } +} +impl EncryptTarget for Ciphertext { + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's, + { + crate::target::scalar_ciphertext() + } +} +impl DecryptSource

for Ciphertext { + type ContextInput = OptionalContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + let context = context.piece(); + Decryption::from_open(move |cipher| { + crate::target::core::open_opaque(CipherText::Single(self.0), cipher, context) + }) + } +} +impl KeysetCipher<'_, K> { + /// Consume and encrypt a scalar as one opaque ciphertext with empty context. + /// The owned source is wiped before this returns the batched pending request. + /// + /// # Errors + /// Awaiting reports encoding, provider or encryption failures. + pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + value.encrypt_into(self) + } + /// Consume and encrypt a scalar, authenticating the supplied context. + /// Context conversion preserves the existing canonical AAD encoding. + /// + /// # Errors + /// Awaiting reports invalid descriptors, encoding, provider or encryption failures. + pub fn encrypt_with_context<'c, S: OpaqueEncode>( + &self, + value: S, + context: impl crate::IntoContext<'c>, + ) -> Pending<'_, Ciphertext, K> { + value.encrypt_into_with_context(self, context) + } + /// Open a scalar sealed with empty context, constrained to this keyset. + /// The caller owns and is responsible for wiping the returned plaintext. + /// + /// # Errors + /// Refuses foreign keysets before retrieval; also reports provider, + /// authentication and plaintext type errors. + pub fn decrypt(&self, ciphertext: Ciphertext) -> Pending<'_, P, K> { + P::decrypt_from(ciphertext, self) + } + /// Open a scalar under the same context used when encrypting it. + /// + /// # Errors + /// Refuses foreign keysets before retrieval; wrong context or altered + /// ciphertext fails authentication. Provider and type errors propagate. + pub fn decrypt_with_context<'c, P: OpaqueDecode>( + &self, + ciphertext: Ciphertext, + context: impl crate::IntoContext<'c>, + ) -> Pending<'_, P, K> { + P::decrypt_from_with_context(ciphertext, self, ScalarContext::from(context)) + } +} + +#[cfg(test)] +mod tests { + use super::ScalarRef; + use crate::{CipherText, StackCipher}; + + #[tokio::test] + async fn scalar_ref_preserves_fixed_width_nullable_padding() { + let cipher = StackCipher::builder() + .kms(stack_kms::FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + macro_rules! check { + ($value:expr, $ty:ty) => {{ + let value: $ty = $value; + let some = keyset + .encrypt_tree(Some(ScalarRef(&value)), "nullable") + .await + .unwrap(); + let none = keyset + .encrypt_tree(None::>, "nullable") + .await + .unwrap(); + let (CipherText::Single(present), CipherText::Single(absent)) = (&some, &none) + else { + panic!("nullable scalars must remain single leaves"); + }; + assert_eq!(present.to_bytes().len(), absent.to_bytes().len()); + assert_eq!( + keyset + .decrypt_tree::, _>(some, "nullable") + .await + .unwrap(), + Some(value) + ); + assert_eq!( + keyset + .decrypt_tree::, _>(none, "nullable") + .await + .unwrap(), + None + ); + }}; + } + check!(42, i32); + check!(42, u128); + check!(42.5, f64); + } +} diff --git a/packages/stack-encrypt/src/target/conversion.rs b/packages/stack-encrypt/src/target/conversion.rs new file mode 100644 index 000000000..223eb4afb --- /dev/null +++ b/packages/stack-encrypt/src/target/conversion.rs @@ -0,0 +1,102 @@ +//! Owned conversions over core-owned declarations. + +use super::{ + AcceptsContext, AllowsNoContext, CipherScope, ContextInput, Decryption, Encryption, Pending, +}; +use crate::KeysetCipher; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroize; + +/// Declare how to encrypt an owned plaintext into this target. +/// +/// The library guards the source with [`Protected`], then synchronously runs +/// this description over a borrow of it. The description may select core +/// operations and map their outputs; it never receives the cipher and +/// plaintext together. Source buffers are wiped before the pending request +/// is returned, including when description construction or execution fails. +/// No `Clone` bound is imposed on the source. +pub trait EncryptTarget: Sized + 'static { + /// What the caller must or may supply, independently of declared context. + type ContextInput: ContextInput; + /// Describe the encryption; key I/O is deferred to the returned pending. + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::ContextInput> + where + S: 's; +} + +/// Declare how stored output recovers plaintext, without performing key I/O. +pub trait DecryptSource: Sized { + /// The decrypt-side caller requirement, independent of the encrypt side. + type ContextInput: ContextInput; + /// Inspect this stored value and describe its opening. + fn decryption(self, context: Self::ContextInput) -> Decryption; +} + +/// Consume plaintext and execute a target declaration through the shared engine. +pub trait EncryptInto: Zeroize + Sized { + /// Encrypt with explicitly omitted caller context. + fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + where + T: EncryptTarget, + T::ContextInput: AllowsNoContext, + { + let source = Protected::new(self); + cipher.run( + T::encryption(), + source.risky_ref(), + T::ContextInput::omitted(), + ) + } + + /// Encrypt with a supplied context, without manually wrapping its presence. + fn encrypt_into_with_context<'a, T, K: 'static>( + self, + cipher: &'a KeysetCipher<'_, K>, + context: impl Into<::Value>, + ) -> Pending<'a, T, K> + where + T: EncryptTarget, + T::ContextInput: AcceptsContext, + { + let source = Protected::new(self); + let context = T::ContextInput::supplied(context.into()); + cipher.run(T::encryption(), source.risky_ref(), context) + } +} +impl EncryptInto for S {} + +/// Construct owned plaintext from a declared encrypted source. +/// +/// Successful return transfers cleanup responsibility to the caller: a +/// `Zeroize` bound does not make ordinary returned values wipe themselves on drop. +pub trait DecryptFrom: Zeroize + Sized + 'static { + /// Decrypt with explicitly omitted caller context. + fn decrypt_from<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + ) -> Pending<'a, Self, K> + where + S: DecryptSource, + S::ContextInput: AllowsNoContext, + { + source + .decryption(S::ContextInput::omitted()) + .open_in(cipher) + } + + /// Decrypt with the context supplied at encryption. + fn decrypt_from_with_context<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + context: impl Into<::Value>, + ) -> Pending<'a, Self, K> + where + S: DecryptSource, + S::ContextInput: AcceptsContext, + { + source + .decryption(S::ContextInput::supplied(context.into())) + .open_in(cipher) + } +} +impl DecryptFrom for P {} diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index c06a84f03..39aa3b373 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -30,6 +30,24 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( source: S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty, +) -> Pending<'a, StackCipherText, K> { + encrypt_opaque(source, cipher, context) +} +/// The opening half of [`encrypt_native`]; the context is nonempty. +pub(crate) fn open_native<'a, 'c, P: Decrypt + Send + 'static, K, T: IntoContext<'c>>( + tree: StackCipherText, + cipher: &'a StackCipher, + context: NonEmpty, +) -> Pending<'a, P, K> { + open_opaque(tree, cipher, context) +} +/// [`encrypt_native`] for the opaque scalar operation, whose context may +/// be empty. Mapping its encrypted output preserves that policy. Structural +/// target operations use `encrypt_native` and retain its nonempty requirement. +pub(crate) fn encrypt_opaque<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( + source: S, + cipher: &'a KeysetCipher<'_, K>, + context: T, ) -> Pending<'a, StackCipherText, K> { let context = context.into_context(); let descriptor = Descriptor::from_piece(&context); @@ -42,10 +60,11 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt, K, T: IntoContext<'c>>( Err(error) => Pending::ready(cipher, Err(error)), } } -pub(crate) fn open_native<'a, 'c, P: Decrypt + Send + 'static, K, T: IntoContext<'c>>( +/// The opening half of [`encrypt_opaque`]. +pub(crate) fn open_opaque<'a, 'c, P: Decrypt + Send + 'static, K, T: IntoContext<'c>>( tree: StackCipherText, cipher: &'a StackCipher, - context: NonEmpty, + context: T, ) -> Pending<'a, P, K> { let context = context.into_context(); let descriptor = Descriptor::from_piece(&context); diff --git a/packages/stack-encrypt/src/target/input.rs b/packages/stack-encrypt/src/target/input.rs new file mode 100644 index 000000000..81abebd5e --- /dev/null +++ b/packages/stack-encrypt/src/target/input.rs @@ -0,0 +1,131 @@ +//! Explicit caller-context capabilities. `Default` never grants permission to omit context. + +use crate::{ContextPiece, IntoContext}; + +mod sealed { + pub trait Sealed {} +} + +/// A library-owned description of what context a caller may supply. +pub trait ContextInput: sealed::Sealed {} + +/// A declaration permits omitting caller context. +pub trait AllowsNoContext: ContextInput + Sized { + /// Construct the explicitly omitted input. + fn omitted() -> Self; +} + +/// A declaration accepts a context argument. +pub trait AcceptsContext: ContextInput + Sized { + /// The caller's value, before the presence wrapper is added. + type Value; + /// Record that the caller supplied a value. + fn supplied(value: Self::Value) -> Self; +} + +/// The declaration supplies all context itself; a caller cannot add any. +#[derive(Clone, Copy, Debug)] +pub struct NoCallerContext; +impl sealed::Sealed for NoCallerContext {} +impl ContextInput for NoCallerContext {} +impl AllowsNoContext for NoCallerContext { + fn omitted() -> Self { + Self + } +} + +/// Caller context is required, even when `C` implements `Default`. +#[derive(Clone, Debug)] +pub struct RequiredContext(C); +impl RequiredContext { + /// Take the caller's required value when interpreting a declaration. + pub fn into_inner(self) -> C { + self.0 + } +} +impl sealed::Sealed for RequiredContext {} +impl ContextInput for RequiredContext {} +impl AcceptsContext for RequiredContext { + type Value = C; + fn supplied(value: C) -> Self { + Self(value) + } +} + +/// Caller context may be omitted. Absence is distinct from a supplied value. +#[derive(Clone, Debug)] +pub struct OptionalContext(Option); +impl OptionalContext { + /// Inspect presence when interpreting a declaration. + pub fn into_inner(self) -> Option { + self.0 + } +} +impl sealed::Sealed for OptionalContext {} +impl ContextInput for OptionalContext {} +impl AllowsNoContext for OptionalContext { + fn omitted() -> Self { + Self(None) + } +} +impl AcceptsContext for OptionalContext { + type Value = C; + fn supplied(value: C) -> Self { + Self(Some(value)) + } +} + +/// Owned AEAD context for an opaque scalar. Empty context is valid. +/// +/// Contexts use the existing canonical encoding. No extra tuple, prefix or +/// presence marker is added to AAD. Convert ordinary contexts with `Into`; +/// the conversion methods do this automatically. +/// +/// Keep an owned context when the same value is needed for sealing and opening: +/// +/// ```no_run +/// use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, target::ScalarContext}; +/// # async fn example(cipher: &stack_encrypt::StackCipher) -> Result<(), stack_encrypt::Error> { +/// let context = ScalarContext::from(("users", "age")); +/// let encrypted: Ciphertext = 42i32 +/// .encrypt_into_with_context(&cipher.default_keyset(), context.clone()) +/// .await?; +/// let age = i32::decrypt_from_with_context(encrypted, cipher, context).await?; +/// assert_eq!(age, 42); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Debug)] +pub struct ScalarContext(ContextPiece<'static>); +impl<'c, C: IntoContext<'c>> From for ScalarContext { + fn from(value: C) -> Self { + Self(value.into_context().into_owned()) + } +} +impl OptionalContext { + pub(crate) fn piece(self) -> ContextPiece<'static> { + self.0 + .map_or_else(|| ().into_context(), |context| context.0) + } +} + +// A required input can drive the scalar operation without weakening the +// declaration's caller-facing requirement. +impl> From> for OptionalContext { + fn from(context: RequiredContext) -> Self { + Self::supplied(context.into_inner().into()) + } +} + +// `Encryption::under` supplies the declared nonempty context to the scalar +// operation. The caller's omitted input must not extend it with an empty tuple. +impl From for OptionalContext { + fn from(context: super::CallerContext) -> Self { + Self::supplied(context.into()) + } +} +impl From for super::DeclaredContext { + fn from(_: NoCallerContext) -> Self { + Self::default() + } +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 21fe1ef58..79df136cd 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -1,5 +1,15 @@ //! Targets declare operations; the cipher executes them through Vitamin C. //! +//! The owned API uses [`EncryptTarget`] and [`DecryptSource`] declarations, +//! executed by [`EncryptInto`] and [`DecryptFrom`]. Their `ContextInput` +//! capabilities explicitly allow omission or supply; `Default` does not +//! determine either. [`scalar_ciphertext`] is the first owned operation. +//! +//! # Structural API during migration +//! +//! Existing derives, plans and dynamic targets still use the declarations +//! below. Their borrowed convenience methods are isolated in [`legacy`]. +//! //! [`EncryptFrom`] describes ciphertext and/or term operations and has an //! associated context type. It does not receive plaintext or a cipher. The //! cipher's `encrypt_as` executes that description, returning the existing @@ -82,12 +92,14 @@ //! Implement `encryption`/`decryption`, returning core-owned descriptions, in place //! of `encrypt_from`/`decrypt_into` methods that took a cipher. The derives do this //! automatically. Call `keyset.encrypt_as(&value, context)` and -//! `cipher.decrypt_as(record, context)`, or import the blanket [`EncryptInto`] and -//! [`DecryptFrom`] convenience traits for the existing source-side call syntax. -//! `EncryptTarget` and `DecryptTarget` are no longer extension points. +//! `cipher.decrypt_as(record, context)`, or import [`legacy::EncryptInto`] and +//! [`legacy::DecryptFrom`] while migrating these declarations. New scalar +//! targets implement [`EncryptTarget`] and [`DecryptSource`]. mod context; +mod conversion; pub(crate) mod core; mod index; +mod input; mod operations; mod pending; mod request; @@ -106,11 +118,26 @@ pub(crate) use operations::chosen; pub(crate) use operations::inspect; pub use operations::{ ciphertext, equality, json_document, json_terms, matching, ope, open, ore, ore_block, - passthrough, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, EncryptFrom, - EncryptInto, Encryption, Layout, + passthrough, scalar_ciphertext, DecryptField, DecryptInto, Decryptable, Decryption, + EncryptFrom, Encryption, Layout, }; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; pub use source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; pub use tuples::JoinContext; + +// The old borrowed declarations remain for plans, derives and the dynamic +// engine during the staged migration. New applications use the traits above. +/// Borrowed conversion helpers for declarations not yet migrated to the owned API. +/// Do not import these alongside the new conversion traits. +pub mod legacy { + pub use super::operations::{ + LegacyDecryptFrom as DecryptFrom, LegacyEncryptInto as EncryptInto, + }; +} +pub use conversion::{DecryptFrom, DecryptSource, EncryptInto, EncryptTarget}; +pub use input::{ + AcceptsContext, AllowsNoContext, ContextInput, NoCallerContext, OptionalContext, + RequiredContext, ScalarContext, +}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 07b608263..cc50c253e 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -462,6 +462,32 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S ), } } +/// Encrypt a supported scalar as one opaque ciphertext, permitting empty context. +/// +/// This operation borrows its source. The owned [`EncryptInto`](super::EncryptInto) +/// conversions supply a [`Protected`](vitaminc_protected::Protected) owner and +/// wipe it after planning. Calling [`KeysetCipher::run`] directly leaves source +/// cleanup with the caller; this operation alone does not wipe that borrow. +/// Mapping the encrypted output preserves the scalar's optional-context policy; +/// it does not turn this into an EQL ciphertext or term operation. A wrapper may +/// require caller context with [`RequiredContext`](super::RequiredContext), or +/// declare a nonempty context with [`Encryption::under`]. +pub fn scalar_ciphertext<'s, S: crate::OpaqueEncode, K: 'static>( +) -> Encryption<'s, S, crate::Ciphertext, K, super::OptionalContext> { + Encryption { + build: Box::new( + move |source, cipher, context: super::OptionalContext| { + super::core::encrypt_opaque( + crate::scalar::ScalarRef(source), + cipher, + context.piece(), + ) + .try_map(crate::Ciphertext::from_tree) + }, + ), + } +} + /// Carry the plaintext through unchanged: the output is the source itself, /// and the context the description is handed is ignored. /// @@ -808,7 +834,7 @@ impl Decryption { } /// Execute under a scope. A [`KeysetCipher`] scope refuses a leaf from any /// other keyset; a [`StackCipher`] scope opens leaves from any. - fn open_in<'a>(self, scope: impl CipherScope<'a, K>) -> Pending<'a, T, K> { + pub(super) fn open_in<'a>(self, scope: impl CipherScope<'a, K>) -> Pending<'a, T, K> { let pending = match self.inner { Opening::Failed(error) => return Pending::failed(scope, error), Opening::Open(open) => open(scope.cipher()), @@ -967,7 +993,7 @@ impl StackCipher { /// Source-side call syntax for [`KeysetCipher::encrypt_as`], implemented for /// every type: `value.encrypt_into(&keyset)`. Targets implement /// [`EncryptFrom`], never these methods. -pub trait EncryptInto: Sized { +pub trait LegacyEncryptInto: Sized { /// Encrypt into `T` with its default context — `()` for a declaration /// that carries its own contexts. fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> @@ -1002,11 +1028,11 @@ pub trait EncryptInto: Sized { cipher.encrypt_as(source, context.into()) } } -impl EncryptInto for T {} +impl LegacyEncryptInto for T {} /// Source-side call syntax for `decrypt_as`, implemented for every type: /// `stored.decrypt_into(&cipher, ctx)`. Targets implement [`DecryptInto`], /// never these methods. -pub trait DecryptFrom: Sized + 'static { +pub trait LegacyDecryptFrom: Sized + 'static { /// Recover `P` through `cipher`, which may be a [`KeysetCipher`] (refusing /// foreign leaves) or a [`StackCipher`] (opening any). fn decrypt_into<'a, P: 'static, K: 'static>( @@ -1044,7 +1070,7 @@ pub trait DecryptFrom: Sized + 'static { source.decrypt_into(cipher, context.into()) } } -impl DecryptFrom for T {} +impl LegacyDecryptFrom for T {} // The leaf declarations below keep `Clone` on purpose. An `EncryptFrom` // declaration runs in the `Borrowed` mode (`encrypt_as` hands it `&S`), and an diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 37fcd0e01..b208600fa 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -11,7 +11,8 @@ use std::sync::atomic::Ordering as AtomicOrdering; use common::{counting_cipher, recording_cipher, stack_cipher}; use stack_encrypt::sem::Orderable; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OreTerm, TermError}; -use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto, Encrypted}; +use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; +use stack_encrypt::target::{AeadContext, Encrypted}; use stack_encrypt::{ nonempty, ContextPiece, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, IntoContext, MaybeEmpty, NonEmpty, StackCipherText, @@ -419,7 +420,7 @@ async fn a_ciphertext_only_record_accepts_an_aead_only_context_like_the_leaf_doe let opened: String = cipher.decrypt(record.c, tenant()).await.unwrap(); assert_eq!(opened, name); - let leaf = keyset.encrypt(name.clone(), tenant()).await.unwrap(); + let leaf = keyset.encrypt_tree(name.clone(), tenant()).await.unwrap(); let opened: String = SealedName { c: leaf } .decrypt_into(&cipher, tenant()) .await diff --git a/packages/stack-encrypt/tests/derive_plan.rs b/packages/stack-encrypt/tests/derive_plan.rs index 26ae77162..7f8e2c3e7 100644 --- a/packages/stack-encrypt/tests/derive_plan.rs +++ b/packages/stack-encrypt/tests/derive_plan.rs @@ -14,9 +14,10 @@ use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::op; use stack_encrypt::plan::{pick, FieldValues}; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OpeTerm, OreTerm}; +use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; use stack_encrypt::target::{ - AeadContext, CallerContext, DeclaredContext, DecryptField, DecryptFrom, Decryptable, - Decryption, EncryptInto, Encrypted, Encryption, ExpectedContext, IndexSpec, + AeadContext, CallerContext, DeclaredContext, DecryptField, Decryptable, Decryption, Encrypted, + Encryption, ExpectedContext, IndexSpec, }; use stack_encrypt::{ nonempty, DecryptInto, EncryptFrom, Error, NonEmpty, Plan, PlanError, StackCipherText, diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index 89dc05671..8e69a6683 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -10,7 +10,8 @@ mod common; use common::recording_cipher; use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{DecryptFrom, EncryptInto, Encrypted}; +use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; +use stack_encrypt::target::Encrypted; use stack_encrypt::{ nonempty, DecryptInto, Describe, Descriptor, EncryptFrom, Error, Label, NonEmpty, StackCipherText, @@ -178,10 +179,10 @@ async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), E let (cipher, sent) = recording_cipher().await; let keyset = cipher.default_keyset(); - let ct = keyset.encrypt(42u32, ("users", "age")).await?; + let ct = keyset.encrypt_tree(42u32, ("users", "age")).await?; let _: u32 = cipher.decrypt(ct, ("users", "age")).await?; // No AAD at all is the empty descriptor: ZeroKMS binds nothing. - let ct = keyset.encrypt(42u32, ()).await?; + let ct = keyset.encrypt_tree(42u32, ()).await?; let _: u32 = cipher.decrypt(ct, ()).await?; let sent = sent.lock().expect("lock").clone(); diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 06605aa53..9cbcec7e7 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -25,7 +25,8 @@ use stack_encrypt::nonempty; use stack_encrypt::sem::{ DefaultMatch, EqualityTerm, MatchTerms, OpeTerm, OreTerm, TermBytesError, }; -use stack_encrypt::target::EncryptInto; + +use stack_encrypt::target::legacy::EncryptInto; use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, @@ -210,7 +211,7 @@ async fn sealed_leaf_survives_persistence_via_bytes() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("durable".to_string(), b"ctx".as_slice()) + .encrypt_tree("durable".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); let leaf = match ct { @@ -236,7 +237,7 @@ async fn sealed_value_keyset_id_is_authenticated() { let keyset = cipher.default_keyset(); let aad = b"ctx".as_slice(); let ct = keyset - .encrypt("durable".to_string(), aad) + .encrypt_tree("durable".to_string(), aad) .await .expect("encrypt"); let leaf = match ct { @@ -311,7 +312,7 @@ async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { let result = cipher .default_keyset() - .encrypt("boundary".to_string(), b"ctx".as_slice()) + .encrypt_tree("boundary".to_string(), b"ctx".as_slice()) .await; assert!( matches!(result, Err(Error::Aead)), diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index 0b59145c6..001b42c73 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -583,8 +583,9 @@ mod json_index { use stack_encrypt::json::{ JsonDocument, JsonNode, JsonOptions, JsonPath, JsonQuery, JsonSubtree, }; + use stack_encrypt::target::legacy::DecryptFrom; use stack_encrypt::target::{json_document, AeadContext, Borrowed, CallerContext}; - use stack_encrypt::{op, DecryptFrom, Plan, StackCipher}; + use stack_encrypt::{op, Plan, StackCipher}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, DeterministicSource, GenerateKeyPayload, IdentifiedBy, IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index bf90b1f2d..90297d594 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -15,10 +15,11 @@ use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{ DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, }; +use stack_encrypt::target::legacy::DecryptFrom; use stack_encrypt::target::{ ciphertext, equality, indexed, matching, ope, ore, passthrough, AeadContext, Borrowed, - CallerContext, DecryptFrom, Decryption, Encrypted, Encryption, Equality, Index, IndexSpec, - Indexes, Match, Ope, Ore, Owned, + CallerContext, Decryption, Encrypted, Encryption, Equality, Index, IndexSpec, Indexes, Match, + Ope, Ore, Owned, }; use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy}; @@ -763,7 +764,8 @@ mod given_encrypted_terms_as_a_target { #[tokio::test] async fn a_boolean_has_no_equality_term() { use stack_encrypt::sem::TermError; - use stack_encrypt::EncryptInto; + + use stack_encrypt::target::legacy::EncryptInto; let (cipher, generates, _) = counting_cipher().await; let keyset = cipher.default_keyset(); diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index 3a4ea84ee..468f32fe9 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -1,7 +1,7 @@ //! Keysets: one client, many keysets. Selection, caching, and the //! keyset-scoped versus client-scoped decrypt paths. -use stack_encrypt::DecryptFrom; +use stack_encrypt::target::legacy::DecryptFrom; use std::borrow::Cow; use std::collections::HashMap; use std::num::NonZeroUsize; @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; -use stack_encrypt::target::EncryptInto; +use stack_encrypt::target::legacy::EncryptInto; use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, @@ -537,7 +537,7 @@ async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { let tenant = cipher.keyset(name("acme")).await.expect("select"); let sealed = tenant - .encrypt("hello".to_string(), "greeting") + .encrypt_tree("hello".to_string(), "greeting") .await .expect("seal"); assert_eq!( @@ -548,7 +548,7 @@ async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { let sealed = cipher .default_keyset() - .encrypt("hello".to_string(), "greeting") + .encrypt_tree("hello".to_string(), "greeting") .await .expect("seal"); assert_eq!( @@ -567,7 +567,7 @@ async fn the_client_opens_a_leaf_from_any_keyset() { let cipher = cipher().await; let tenant = cipher.keyset(name("acme")).await.expect("select"); let sealed = tenant - .encrypt("hello".to_string(), "greeting") + .encrypt_tree("hello".to_string(), "greeting") .await .expect("seal"); @@ -585,11 +585,11 @@ async fn a_keyset_handle_opens_its_own_leaves() { let cipher = cipher().await; let tenant = cipher.keyset(name("acme")).await.expect("select"); let sealed = tenant - .encrypt("hello".to_string(), "greeting") + .encrypt_tree("hello".to_string(), "greeting") .await .expect("seal"); - let opened: String = tenant.decrypt(sealed, "greeting").await.expect("open"); + let opened: String = tenant.decrypt_tree(sealed, "greeting").await.expect("open"); assert_eq!(opened, "hello", "a keyset handle opens its own leaf"); } @@ -599,11 +599,11 @@ async fn a_keyset_handle_refuses_another_keysets_leaf_before_any_retrieve() { let acme = cipher.keyset(name("acme")).await.expect("acme"); let globex = cipher.keyset(name("globex")).await.expect("globex"); let sealed = acme - .encrypt("hello".to_string(), "greeting") + .encrypt_tree("hello".to_string(), "greeting") .await .expect("seal"); - let result: Result = globex.decrypt(sealed, "greeting").await; + let result: Result = globex.decrypt_tree(sealed, "greeting").await; assert!( matches!( result, diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index 25889f4bb..4094cd475 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -1316,7 +1316,7 @@ async fn a_one_value_typed_plan_is_the_plaintext_records_bytes() { assert_eq!(hm, derived.hm, "the same equality term"); // Each ciphertext opens through the other's reader. - let opened: String = stack_encrypt::target::DecryptFrom::decrypt_from_with_context( + let opened: String = stack_encrypt::target::legacy::DecryptFrom::decrypt_from_with_context( DerivedEmail { c, hm: hm.clone() }, &cipher, CallerContext::from(nonempty!("users").with("email")), diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 9afa770e8..134fa16c0 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -25,7 +25,7 @@ async fn scalar_roundtrips_with_no_aad() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("hello world".to_string(), ()) + .encrypt_tree("hello world".to_string(), ()) .await .expect("encrypt"); let pt: String = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -38,7 +38,7 @@ async fn scalar_roundtrips_with_matching_aad() { let keyset = cipher.default_keyset(); let aad = b"public-context".as_slice(); let ct = keyset - .encrypt("secret".to_string(), aad) + .encrypt_tree("secret".to_string(), aad) .await .expect("encrypt"); let pt: String = cipher.decrypt(ct, aad).await.expect("decrypt"); @@ -50,7 +50,7 @@ async fn decrypt_fails_with_wrong_aad() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("secret".to_string(), b"aad-a".as_slice()) + .encrypt_tree("secret".to_string(), b"aad-a".as_slice()) .await .expect("encrypt"); let result: Result = cipher.decrypt(ct, b"aad-b".as_slice()).await; @@ -62,7 +62,7 @@ async fn decrypt_fails_when_aad_omitted() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("secret".to_string(), b"bound".as_slice()) + .encrypt_tree("secret".to_string(), b"bound".as_slice()) .await .expect("encrypt"); // The leaf bound the PAE-encoded tuple `(aad, tag)`; dropping the caller @@ -76,7 +76,10 @@ async fn vec_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let items = vec!["a".to_string(), "b".to_string(), "c".to_string()]; - let ct = keyset.encrypt(items.clone(), ()).await.expect("encrypt"); + let ct = keyset + .encrypt_tree(items.clone(), ()) + .await + .expect("encrypt"); let pt: Vec = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt, items); } @@ -90,7 +93,7 @@ async fn map_roundtrips() { input.insert("name", "alice".to_string()); input.insert("role", "admin".to_string()); - let ct = keyset.encrypt(input, ()).await.expect("encrypt"); + let ct = keyset.encrypt_tree(input, ()).await.expect("encrypt"); let pt: HashMap = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt.get("name"), Some(&"alice".to_string())); @@ -103,7 +106,7 @@ async fn option_some_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(Some("present".to_string()), ()) + .encrypt_tree(Some("present".to_string()), ()) .await .expect("encrypt"); let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -115,7 +118,7 @@ async fn option_none_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(Option::::None, ()) + .encrypt_tree(Option::::None, ()) .await .expect("encrypt"); let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -131,13 +134,13 @@ async fn a_plain_null_opens_as_a_fixed_width_none() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(Option::::None, ()) + .encrypt_tree(Option::::None, ()) .await .expect("encrypt"); let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt, None, "i64"); let ct = keyset - .encrypt(Option::::None, ()) + .encrypt_tree(Option::::None, ()) .await .expect("encrypt"); let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -159,7 +162,7 @@ async fn a_fixed_width_none_is_as_long_as_a_some() { where T: Encrypt + Send + 'static, { - match keyset.encrypt(value, ()).await.expect("encrypt") { + match keyset.encrypt_tree(value, ()).await.expect("encrypt") { CipherText::Single(leaf) => leaf.to_bytes().len(), other => panic!("a scalar seals as one leaf: {other:?}"), } @@ -185,7 +188,7 @@ async fn a_fixed_width_none_is_as_long_as_a_some() { "text has no width to pad to" ); let ct = keyset - .encrypt(Option::::None, ()) + .encrypt_tree(Option::::None, ()) .await .expect("encrypt"); let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -199,7 +202,7 @@ async fn protected_roundtrip() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let secret = Protected::new("classified".to_string()); - let ct = keyset.encrypt(secret, ()).await.expect("encrypt"); + let ct = keyset.encrypt_tree(secret, ()).await.expect("encrypt"); let pt: Protected = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt.risky_unwrap(), "classified".to_string()); } @@ -212,7 +215,10 @@ async fn nested_vec_roundtrips() { vec!["a".to_string(), "b".to_string()], vec!["c".to_string()], ]; - let ct = keyset.encrypt(nested.clone(), ()).await.expect("encrypt"); + let ct = keyset + .encrypt_tree(nested.clone(), ()) + .await + .expect("encrypt"); let pt: Vec> = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt, nested); } @@ -225,7 +231,7 @@ async fn context_tag_binds_and_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("token".to_string(), ContextTag::aad("user:42")) + .encrypt_tree("token".to_string(), ContextTag::aad("user:42")) .await .expect("encrypt"); @@ -242,7 +248,7 @@ async fn context_tag_wrong_context_fails() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("token".to_string(), ContextTag::aad("user:42")) + .encrypt_tree("token".to_string(), ContextTag::aad("user:42")) .await .expect("encrypt"); @@ -256,7 +262,7 @@ async fn empty_vec_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(Vec::::new(), ()) + .encrypt_tree(Vec::::new(), ()) .await .expect("encrypt"); let pt: Vec = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -268,7 +274,7 @@ async fn empty_map_roundtrips() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(HashMap::<&'static str, String>::new(), ()) + .encrypt_tree(HashMap::<&'static str, String>::new(), ()) .await .expect("encrypt"); let pt: HashMap = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -280,7 +286,7 @@ async fn empty_marker_does_not_decode_under_wrong_aad() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(Vec::::new(), b"bound".as_slice()) + .encrypt_tree(Vec::::new(), b"bound".as_slice()) .await .expect("encrypt"); let result: Result, _> = cipher.decrypt(ct, ()).await; @@ -340,7 +346,7 @@ fn clear(value: u32) -> PassthroughFrame { async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let ct = keyset.encrypt(Row::once(), ()).await.expect("encrypt"); + let ct = keyset.encrypt_tree(Row::once(), ()).await.expect("encrypt"); let CipherText::Map(entries) = ct else { panic!("a record encrypts as a map: {ct:?}"); }; @@ -359,7 +365,7 @@ async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { "{entries:?}" ); - let result = keyset.encrypt(Row::with_duplicate_id(), ()).await; + let result = keyset.encrypt_tree(Row::with_duplicate_id(), ()).await; assert!(result.is_err(), "a passthrough key given twice is refused"); } @@ -410,7 +416,7 @@ async fn renamed_map_key_fails() { let mut input: HashMap<&'static str, String> = HashMap::new(); input.insert("name", "alice".to_string()); - let ct = keyset.encrypt(input, ()).await.expect("encrypt"); + let ct = keyset.encrypt_tree(input, ()).await.expect("encrypt"); let tampered = match ct { CipherText::Map(entries) => CipherText::Map( entries @@ -432,7 +438,7 @@ async fn sequence_element_cannot_be_rehomed_as_scalar() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt(vec!["a".to_string()], ()) + .encrypt_tree(vec!["a".to_string()], ()) .await .expect("encrypt"); let element = match ct { @@ -453,7 +459,7 @@ async fn wrong_shape_fails() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("scalar".to_string(), ()) + .encrypt_tree("scalar".to_string(), ()) .await .expect("encrypt"); let result: Result, _> = cipher.decrypt(ct, ()).await; @@ -468,7 +474,7 @@ async fn leaf_survives_persistence_via_parts() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("durable".to_string(), b"ctx".as_slice()) + .encrypt_tree("durable".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); let leaf = match ct { @@ -490,7 +496,7 @@ async fn leaf_survives_persistence_via_serde() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("durable".to_string(), ()) + .encrypt_tree("durable".to_string(), ()) .await .expect("encrypt"); let leaf = match ct { @@ -516,7 +522,7 @@ async fn tampered_leaf_bytes_fail() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset - .encrypt("durable".to_string(), ()) + .encrypt_tree("durable".to_string(), ()) .await .expect("encrypt"); let leaf = match ct { diff --git a/packages/stack-encrypt/tests/scalar.rs b/packages/stack-encrypt/tests/scalar.rs new file mode 100644 index 000000000..66f243b05 --- /dev/null +++ b/packages/stack-encrypt/tests/scalar.rs @@ -0,0 +1,539 @@ +//! The first owned public API slice; no service credentials are needed. +use stack_encrypt::target::{ + AcceptsContext, AllowsNoContext, DecryptSource, Decryption, EncryptTarget, Encryption, + OptionalContext, RequiredContext, ScalarContext, +}; +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto, Error}; +mod common; + +#[tokio::test] +async fn scalar_entry_points_cross_open_with_empty_and_supplied_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(42i32).await.unwrap(); + let value = i32::decrypt_from(ct, &keyset).await.unwrap(); + assert_eq!(value, 42); + + let ct: Ciphertext = (-42i32).encrypt_into(&keyset).await.unwrap(); + assert_eq!(keyset.decrypt::(ct).await.unwrap(), -42); + + let ct = keyset + .encrypt_with_context(42i32, ("users", "age")) + .await + .unwrap(); + assert_eq!( + i32::decrypt_from_with_context(ct, &keyset, ("users", "age")) + .await + .unwrap(), + 42 + ); + + let ct: Ciphertext = 42i32 + .encrypt_into_with_context(&keyset, ("users", "age")) + .await + .unwrap(); + assert_eq!( + keyset + .decrypt_with_context::(ct, ("users", "age")) + .await + .unwrap(), + 42 + ); +} + +#[tokio::test] +async fn rejects_wrong_context_type_and_tampered_ciphertext() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt_with_context(42i32, "users/age") + .await + .unwrap(); + assert!(matches!( + keyset.decrypt::(ct.clone()).await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::(ct.clone(), "users/name") + .await, + Err(Error::Aead) + )); + assert!(matches!( + keyset + .decrypt_with_context::(ct.clone(), "users/age") + .await, + Err(Error::OpenedType { .. }) + )); + let mut bytes = ct.to_bytes(); + *bytes.last_mut().unwrap() ^= 1; + let tampered = Ciphertext::from_bytes(&bytes).unwrap(); + assert!(matches!( + keyset + .decrypt_with_context::(tampered, "users/age") + .await, + Err(Error::Aead) + )); +} + +#[tokio::test] +async fn scalar_storage_cross_opens_with_existing_leaf_format() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + // Existing structural writer -> new reader, same envelope and AAD. + let node = keyset.encrypt_tree(42i32, "users/age").await.unwrap(); + let stack_encrypt::CipherText::Single(leaf) = node else { + panic!("scalar leaf") + }; + let bytes = leaf.to_bytes(); + let ct = Ciphertext::from_bytes(&bytes).unwrap(); + assert_eq!(ct.to_bytes(), bytes); + assert_eq!(ct.key_tag(), leaf.tag()); + assert_eq!( + keyset + .decrypt_with_context::(ct, "users/age") + .await + .unwrap(), + 42 + ); + + // New writer -> existing structural reader. + let ct = keyset + .encrypt_with_context(42i32, "users/age") + .await + .unwrap(); + let leaf = stack_encrypt::SealedValue::from_bytes(&ct.to_bytes()).unwrap(); + let opened: i32 = cipher + .decrypt(stack_encrypt::CipherText::Single(leaf), "users/age") + .await + .unwrap(); + assert_eq!(opened, 42); + + let omitted = keyset.encrypt(7i32).await.unwrap(); + assert_eq!( + keyset + .decrypt_with_context::(omitted, ()) + .await + .unwrap(), + 7 + ); + let explicit = keyset.encrypt_with_context(7i32, ()).await.unwrap(); + assert_eq!(keyset.decrypt::(explicit).await.unwrap(), 7); +} + +// A target author can require a context even for u64, which implements Default. +struct BoundInteger(Ciphertext); +impl EncryptTarget for BoundInteger { + type ContextInput = RequiredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .accepting() + } +} +impl DecryptSource for BoundInteger { + type ContextInput = RequiredContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + self.0 + .decryption(OptionalContext::::supplied( + context.into_inner().into(), + )) + } +} +#[tokio::test] +async fn required_context_target_uses_the_same_engine() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct: BoundInteger = 42i32 + .encrypt_into_with_context(&keyset, 0u64) + .await + .unwrap(); + assert_eq!( + i32::decrypt_from_with_context(ct, &keyset, 0u64) + .await + .unwrap(), + 42 + ); +} + +#[tokio::test] +async fn supported_scalar_kinds_round_trip() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + macro_rules! check { ($($value:expr => $ty:ty),* $(,)?) => {$( + let value: $ty = $value; + let expected = value.clone(); + let ct = keyset.encrypt(value).await.unwrap(); + assert_eq!(keyset.decrypt::<$ty>(ct).await.unwrap(), expected); + )*}; } + check!(true => bool, i8::MIN => i8, i16::MIN => i16, i32::MIN => i32, + i64::MIN => i64, i128::MIN => i128, u16::MAX => u16, + u32::MAX => u32, u64::MAX => u64, u128::MAX => u128, + 1.25 => f32, -2.5 => f64, "secret".to_owned() => String); +} + +#[tokio::test] +async fn wrong_keyset_is_rejected_before_retrieval() { + use std::sync::atomic::Ordering; + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let other = cipher + .keyset(stack_encrypt::kms::IdentifiedBy::Uuid(uuid::Uuid::new_v4())) + .await + .unwrap(); + let ct = keyset.encrypt(42i32).await.unwrap(); + assert!(matches!( + other.decrypt::(ct).await, + Err(Error::ForeignKeyset { .. }) + )); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); +} + +// Deliberately not Clone: selecting a scalar field must not copy its owner. +struct Tracked { + value: String, + wiped: std::sync::Arc, +} +impl zeroize::Zeroize for Tracked { + fn zeroize(&mut self) { + self.value.zeroize(); + self.wiped.store(true, std::sync::atomic::Ordering::SeqCst); + } +} +impl Drop for Tracked { + fn drop(&mut self) { + assert!(self.value.is_empty(), "owned source dropped without wiping"); + } +} +struct TrackedCiphertext(Ciphertext); +impl EncryptTarget for TrackedCiphertext { + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, Tracked, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext::() + .project(|source: &Tracked| &source.value) + .map(Self) + } +} +fn tracked() -> (Tracked, std::sync::Arc) { + let wiped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + ( + Tracked { + value: "secret".to_owned(), + wiped: wiped.clone(), + }, + wiped, + ) +} +#[tokio::test] +async fn mapped_scalar_preserves_its_authenticated_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let (source, _) = tracked(); + let encrypted: TrackedCiphertext = source + .encrypt_into_with_context(&keyset, "users/secret") + .await + .unwrap(); + assert!(matches!( + keyset.decrypt::(encrypted.0.clone()).await, + Err(Error::Aead) + )); + assert_eq!( + keyset + .decrypt_with_context::(encrypted.0, "users/secret") + .await + .unwrap(), + "secret" + ); +} +#[tokio::test] +async fn owned_source_is_wiped_before_await_or_discard() { + use std::sync::atomic::Ordering; + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = tracked(); + let pending = source.encrypt_into::(&keyset); + assert!(wiped.load(Ordering::SeqCst)); + assert_eq!(generates.load(Ordering::SeqCst), 0); + drop(pending); + assert_eq!(generates.load(Ordering::SeqCst), 0); + + let (source, wiped) = tracked(); + let pending = source.encrypt_into::(&keyset); + assert!(wiped.load(Ordering::SeqCst)); + let encrypted = pending.await.unwrap(); + assert_eq!( + keyset.decrypt::(encrypted.0).await.unwrap(), + "secret" + ); +} +#[tokio::test] +async fn owned_source_is_wiped_when_context_validation_fails() { + use std::sync::atomic::Ordering; + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = tracked(); + let pending = + source.encrypt_into_with_context::(&keyset, "x".repeat(65536)); + assert!(wiped.load(Ordering::SeqCst)); + assert!(pending.await.is_err()); + assert_eq!(generates.load(Ordering::SeqCst), 0); +} +#[tokio::test] +async fn owned_source_is_guarded_during_context_conversion() { + struct Panics; + impl From for ScalarContext { + fn from(_: Panics) -> Self { + panic!("conversion failed") + } + } + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let (source, wiped) = tracked(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + source.encrypt_into_with_context::(&keyset, Panics) + })); + assert!(result.is_err()); + assert!(wiped.load(std::sync::atomic::Ordering::SeqCst)); +} + +struct FixedInteger(Ciphertext); +impl EncryptTarget for FixedInteger { + type ContextInput = stack_encrypt::target::NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext() + .map(Self) + .under(stack_encrypt::nonempty!("users/fixed-age")) + .accepting() + } +} +impl DecryptSource for FixedInteger { + type ContextInput = stack_encrypt::target::NoCallerContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + self.0 + .decryption(OptionalContext::::supplied( + "users/fixed-age".into(), + )) + } +} +#[tokio::test] +async fn no_caller_context_preserves_the_declared_context() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let encrypted: FixedInteger = 42i32.encrypt_into(&keyset).await.unwrap(); + // A fixed declaration has exactly the same context as the direct API. + assert_eq!( + keyset + .decrypt_with_context::(encrypted.0.clone(), "users/fixed-age") + .await + .unwrap(), + 42 + ); + assert_eq!(i32::decrypt_from(encrypted, &keyset).await.unwrap(), 42); +} + +#[tokio::test] +async fn debug_names_the_type_and_nothing_else() { + let cipher = common::stack_cipher().await; + let ct = cipher.default_keyset().encrypt(42i32).await.unwrap(); + fn opaque(_: &T) {} + opaque(&ct); + assert!(format!("{ct:?}").ends_with("Ciphertext(\"***\")")); +} + +#[test] +fn optional_context_records_presence() { + assert!(OptionalContext::::omitted().into_inner().is_none()); + assert_eq!(OptionalContext::supplied(7u8).into_inner(), Some(7)); +} + +#[tokio::test] +async fn serde_stores_base64_text_or_raw_bytes() { + use base64ct::{Base64, Encoding}; + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt_with_context(42i32, "users/age") + .await + .unwrap(); + let bytes = ct.to_bytes(); + + // Human-readable: the canonical bytes as padded standard Base64. + let json = serde_json::to_value(&ct).unwrap(); + assert_eq!( + json, + serde_json::Value::String(Base64::encode_string(&bytes)) + ); + let from_json: Ciphertext = serde_json::from_value(json).unwrap(); + assert_eq!(from_json.to_bytes(), bytes); + + // Binary: the raw bytes behind a MessagePack bin header of at most 3 bytes. + let packed = rmp_serde::to_vec(&ct).unwrap(); + assert!(packed.ends_with(&bytes) && packed.len() <= bytes.len() + 3); + let from_packed: Ciphertext = rmp_serde::from_slice(&packed).unwrap(); + assert_eq!( + keyset + .decrypt_with_context::(from_packed, "users/age") + .await + .unwrap(), + 42 + ); + // A format that writes bytes as a sequence of integers reads back too. + let as_seq = rmp_serde::to_vec(&bytes).unwrap(); + let from_seq: Ciphertext = rmp_serde::from_slice(&as_seq).unwrap(); + assert_eq!(from_seq.to_bytes(), bytes); + + // A binary format that reads nothing but bytes, as bincode does. + let strict: Ciphertext = serde::Deserialize::deserialize(OnlyBytes(&bytes)).unwrap(); + assert_eq!(strict.to_bytes(), bytes); + + assert!(serde_json::from_str::("\"not base64!\"").is_err()); + // A sequence declaring usize::MAX elements over three is an envelope + // error, not an allocation of the declared length. + let lying: Result = serde::Deserialize::deserialize(LyingSeq(3)); + assert!(lying.is_err()); + + // A value of the wrong type names the form it should have had. + let wrong = serde_json::from_str::("42").unwrap_err(); + assert!( + wrong + .to_string() + .contains("expected a sealed ciphertext as Base64 text or bytes"), + "{wrong}" + ); + // Valid Base64, but too short to be an envelope. + assert!(serde_json::from_str::("\"AAAA\"").is_err()); +} + +/// A non-human-readable deserializer that answers only `deserialize_bytes`. +struct OnlyBytes<'a>(&'a [u8]); +impl<'de> serde::Deserializer<'de> for OnlyBytes<'_> { + type Error = serde::de::value::Error; + fn is_human_readable(&self) -> bool { + false + } + fn deserialize_bytes>(self, v: V) -> Result { + v.visit_bytes(self.0) + } + fn deserialize_any>(self, _: V) -> Result { + Err(serde::de::Error::custom("only bytes")) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map struct enum identifier ignored_any + } +} + +/// One fixed data key, IV and key tag for every request, so bytes sealed +/// once can be pinned and opened by any later build. Index keys come from +/// the deterministic fake. +struct FixedKeySource(stack_kms::FakeDataKeySource); +const FIXED_KEY: [u8; 32] = *b"fixed-data-key-for-pinned-leaf!!"; +const FIXED_IV: [u8; 16] = *b"fixed-iv-pinned!"; +const FIXED_TAG: &[u8] = b"fixed-key-tag"; +impl stack_kms::DataKeySource for FixedKeySource { + async fn generate_keys( + &self, + payloads: Vec>, + _: Option, + _: Option>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .into_iter() + .map(|payload| stack_kms::DataKeyWithTag { + key: stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }, + tag: FIXED_TAG.to_vec(), + decryption_policy: payload.decryption_policy, + }) + .collect()) + } + async fn retrieve_keys( + &self, + payloads: Vec>, + _: Option, + _: Option<&stack_kms::UnverifiedContext>, + ) -> Result, stack_kms::Error> { + Ok(payloads + .iter() + .map(|_| stack_kms::DataKey { + iv: FIXED_IV, + key: FIXED_KEY, + }) + .collect()) + } +} +impl stack_kms::IndexKeySource for FixedKeySource { + async fn load_index_key( + &self, + keyset: Option, + ) -> Result<(uuid::Uuid, stack_kms::IndexKey), stack_kms::Error> { + self.0.load_index_key(keyset).await + } +} + +/// `42i32` sealed under `"users/age"` over [`FixedKeySource`] by the build +/// before the scalar API existed, through its `KeysetCipher::encrypt`, the +/// structural writer now named `encrypt_tree`. +const PINNED_LEAF: &str = "010000000000000000000000000000000066697865642d69762d70696e6e6564210d0066697865642d6b65792d7461670256d8b5db5b9934b9260bda2e332ad9a37c3deff2deab9ced88f4b2ae75d34dc3ce"; + +#[tokio::test] +async fn opens_pinned_bytes_from_the_structural_writer() { + let cipher = stack_encrypt::StackCipher::builder() + .kms(FixedKeySource(stack_kms::FakeDataKeySource::new())) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let bytes: Vec = (0..PINNED_LEAF.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&PINNED_LEAF[i..i + 2], 16).unwrap()) + .collect(); + let ct = Ciphertext::from_bytes(&bytes).unwrap(); + assert_eq!(ct.key_tag(), FIXED_TAG); + assert_eq!(ct.to_bytes(), bytes); + assert_eq!( + keyset + .decrypt_with_context::(ct.clone(), "users/age") + .await + .unwrap(), + 42 + ); + assert!(matches!(keyset.decrypt::(ct).await, Err(Error::Aead))); +} + +/// A binary deserializer whose sequence of `n` bytes claims `usize::MAX`. +struct LyingSeq(u8); +impl<'de> serde::de::SeqAccess<'de> for LyingSeq { + type Error = serde::de::value::Error; + fn next_element_seed>( + &mut self, + seed: T, + ) -> Result, Self::Error> { + if self.0 == 0 { + return Ok(None); + } + self.0 -= 1; + seed.deserialize(serde::de::value::U8Deserializer::new(0)) + .map(Some) + } + fn size_hint(&self) -> Option { + Some(usize::MAX) + } +} +impl<'de> serde::Deserializer<'de> for LyingSeq { + type Error = serde::de::value::Error; + fn is_human_readable(&self) -> bool { + false + } + fn deserialize_any>(self, v: V) -> Result { + v.visit_seq(self) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map struct enum identifier ignored_any + } +} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 14e12f98b..83910d273 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -9,9 +9,10 @@ use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::Arc; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OreTerm}; +use stack_encrypt::target::legacy::{DecryptFrom, EncryptInto}; use stack_encrypt::target::{ - ciphertext, equality, CallerContext, DeclaredContext, DecryptFrom, DecryptInto, Decryption, - EncryptFrom, EncryptInto, Encryption, Pending, Request, + ciphertext, equality, CallerContext, DeclaredContext, DecryptInto, Decryption, EncryptFrom, + Encryption, Pending, Request, }; use stack_encrypt::{nonempty, EmptyError, Error, NonEmpty, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -923,7 +924,10 @@ async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { .into_iter() .map(String::from) .collect(); - let ct = keyset.encrypt(names.clone(), "users/name").await.unwrap(); + let ct = keyset + .encrypt_tree(names.clone(), "users/name") + .await + .unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, @@ -946,7 +950,10 @@ async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { let value = vec!["one".to_string(), "two".to_string(), "three".to_string()]; // Sealed by the cipher-directed API, opened by the target-directed one. - let ct = keyset.encrypt(value.clone(), "users/tags").await.unwrap(); + let ct = keyset + .encrypt_tree(value.clone(), "users/tags") + .await + .unwrap(); let via_target: Vec = ct .decrypt_into(&cipher, nonempty!("users/tags")) .await diff --git a/packages/stack-encrypt/tests/term_serde.rs b/packages/stack-encrypt/tests/term_serde.rs index 4bafad099..ad28aca4b 100644 --- a/packages/stack-encrypt/tests/term_serde.rs +++ b/packages/stack-encrypt/tests/term_serde.rs @@ -5,7 +5,8 @@ use serde_json::{json, Value}; use stack_encrypt::nonempty; use stack_encrypt::sem::{OpeTerm, Orderable, OreBlockTerm, OreTerm, TermBytesError}; -use stack_encrypt::target::EncryptInto; + +use stack_encrypt::target::legacy::EncryptInto; use stack_encrypt::{KeysetCipher, StackCipher}; use stack_kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index bb69b6ec2..fd157849f 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -149,7 +149,7 @@ async fn canonical_ciphertext_opens_through_the_record() { let keyset = cipher.default_keyset(); let email = "alice@example.com".to_owned(); let canonical = keyset - .encrypt(email.clone(), Identifier::email()) + .encrypt_tree(email.clone(), Identifier::email()) .await .unwrap(); let probe = keyset @@ -309,7 +309,7 @@ impl DecryptInto

for FixedLeaf { #[tokio::test] async fn unit_context_target_supplies_its_own_context() { - use stack_encrypt::EncryptInto; + use stack_encrypt::target::legacy::EncryptInto; let cipher = common::stack_cipher().await; let keyset = cipher.default_keyset(); let text = String::from("fixed"); @@ -468,7 +468,7 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { ("empty".into(), vec![]), ]); let tree = keyset - .encrypt(value.clone(), nonempty!("document")) + .encrypt_tree(value.clone(), nonempty!("document")) .await .unwrap(); let stored = tree.read(TreeVisitor).unwrap(); @@ -491,7 +491,10 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { "and opened with one retrieval" ); // Transcoding keeps the authentication: changing a cryptographic map key fails. - let tree = keyset.encrypt(value, nonempty!("document")).await.unwrap(); + let tree = keyset + .encrypt_tree(value, nonempty!("document")) + .await + .unwrap(); let Stored::Object(mut entries) = tree.read(TreeVisitor).unwrap() else { panic!("object") }; @@ -504,7 +507,7 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { "a map key is part of its entry's authenticated context: renaming it fails to open" ); let tree = keyset - .encrypt(HashMap::::new(), nonempty!("document")) + .encrypt_tree(HashMap::::new(), nonempty!("document")) .await .unwrap(); let Stored::EmptyObject(marker) = tree.read(TreeVisitor).unwrap() else { diff --git a/packages/stack-encrypt/tests/ui/bare_context.rs b/packages/stack-encrypt/tests/ui/bare_context.rs index e93bdd9b2..e70aea855 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.rs +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -6,7 +6,8 @@ //! plaintext-derived record, a leaf opened directly — so the proof cannot be //! skipped by passing the raw value. use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{DecryptFrom, EncryptInto}; + +use stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}; use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; use stack_kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index ad2d31cb0..327a95d9f 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.stderr +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -1,7 +1,7 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied - --> tests/ui/bare_context.rs:32:44 + --> tests/ui/bare_context.rs:33:44 | -32 | .encrypt_into_with_context(cipher, "users/email") +33 | .encrypt_into_with_context(cipher, "users/email") | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` | | | required by a bound introduced by this call @@ -17,7 +17,7 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied `CallerContext` implements `From` and $N others = note: required for `&str` to implement `Into` -note: required by a bound in `encrypt_into_with_context` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` --> src/target/operations.rs | | fn encrypt_into_with_context<'a, T, K: 'static>( @@ -27,9 +27,9 @@ note: required by a bound in `encrypt_into_with_context` | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied - --> tests/ui/bare_context.rs:36:44 + --> tests/ui/bare_context.rs:37:44 | -36 | .encrypt_into_with_context(cipher, "tenant/acme") +37 | .encrypt_into_with_context(cipher, "tenant/acme") | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` | | | required by a bound introduced by this call @@ -37,15 +37,15 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From` + `DeclaredContext` implements `From` `DeclaredContext` implements `From>` `DeclaredContext` implements `From` `DeclaredContext` implements `From` `DeclaredContext` implements `From` `DeclaredContext` implements `From` - `DeclaredContext` implements `From` and $N others = note: required for `&str` to implement `Into` -note: required by a bound in `encrypt_into_with_context` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` --> src/target/operations.rs | | fn encrypt_into_with_context<'a, T, K: 'static>( @@ -55,9 +55,9 @@ note: required by a bound in `encrypt_into_with_context` | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied - --> tests/ui/bare_context.rs:40:44 + --> tests/ui/bare_context.rs:41:44 | -40 | .encrypt_into_with_context(cipher, "users/age") +41 | .encrypt_into_with_context(cipher, "users/age") | ------------------------- ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` | | | required by a bound introduced by this call @@ -73,7 +73,7 @@ error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied `CallerContext` implements `From` and $N others = note: required for `&str` to implement `Into` -note: required by a bound in `encrypt_into_with_context` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into_with_context` --> src/target/operations.rs | | fn encrypt_into_with_context<'a, T, K: 'static>( @@ -83,9 +83,9 @@ note: required by a bound in `encrypt_into_with_context` | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied - --> tests/ui/bare_context.rs:50:62 + --> tests/ui/bare_context.rs:51:62 | -50 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") +51 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") | ------------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` | | | required by a bound introduced by this call @@ -93,15 +93,15 @@ error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied = help: the following other types implement trait `From`: `DeclaredContext` implements `From<()>` `DeclaredContext` implements `From` + `DeclaredContext` implements `From` `DeclaredContext` implements `From>` `DeclaredContext` implements `From` `DeclaredContext` implements `From` `DeclaredContext` implements `From` `DeclaredContext` implements `From` - `DeclaredContext` implements `From` and $N others = note: required for `&str` to implement `Into` -note: required by a bound in `decrypt_from_with_context` +note: required by a bound in `stack_encrypt::target::legacy::DecryptFrom::decrypt_from_with_context` --> src/target/operations.rs | | fn decrypt_from_with_context<'a, S, K: 'static>( @@ -111,9 +111,9 @@ note: required by a bound in `decrypt_from_with_context` | ^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied - --> tests/ui/bare_context.rs:53:49 + --> tests/ui/bare_context.rs:54:49 | -53 | let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); +54 | let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); | ------------ ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `AeadContext` | | | required by a bound introduced by this call diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs index 4e867f61d..d1b04389d 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.rs +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -2,7 +2,8 @@ //! either all need a `NonEmpty<_>` context: the context-free `encrypt_into` //! / `decrypt_from` pass `()`, which no leaf accepts. use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{DecryptFrom, EncryptInto}; + +use stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}; use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; use stack_kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index 7e167bc2a..b408e8c03 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -1,10 +1,10 @@ error[E0277]: the trait bound `CallerContext: Default` is not satisfied - --> tests/ui/leaf_without_context.rs:17:39 + --> tests/ui/leaf_without_context.rs:18:39 | -17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); +18 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` | -note: required by a bound in `encrypt_into` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into` --> src/target/operations.rs | | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> @@ -14,12 +14,12 @@ note: required by a bound in `encrypt_into` | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` error[E0277]: the trait bound `CallerContext: Default` is not satisfied - --> tests/ui/leaf_without_context.rs:18:39 + --> tests/ui/leaf_without_context.rs:19:39 | -18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); +19 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` | -note: required by a bound in `encrypt_into` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into` --> src/target/operations.rs | | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> @@ -29,14 +29,14 @@ note: required by a bound in `encrypt_into` | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` error[E0277]: the trait bound `AeadContext: Default` is not satisfied - --> tests/ui/leaf_without_context.rs:19:41 + --> tests/ui/leaf_without_context.rs:20:41 | -19 | let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); +20 | let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^ ------------ required by a bound introduced by this call | | | the trait `Default` is not implemented for `AeadContext` | -note: required by a bound in `encrypt_into` +note: required by a bound in `stack_encrypt::target::legacy::EncryptInto::encrypt_into` --> src/target/operations.rs | | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> @@ -46,14 +46,14 @@ note: required by a bound in `encrypt_into` | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` error[E0277]: the trait bound `CallerContext: Default` is not satisfied - --> tests/ui/leaf_without_context.rs:23:34 + --> tests/ui/leaf_without_context.rs:24:34 | -23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); +24 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); | ----------------- ^^^^^^ the trait `Default` is not implemented for `CallerContext` | | | required by a bound introduced by this call | -note: required by a bound in `decrypt_from` +note: required by a bound in `stack_encrypt::target::legacy::DecryptFrom::decrypt_from` --> src/target/operations.rs | | fn decrypt_from<'a, S, K: 'static>( @@ -63,9 +63,9 @@ note: required by a bound in `decrypt_from` | ^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied - --> tests/ui/leaf_without_context.rs:27:49 + --> tests/ui/leaf_without_context.rs:28:49 | -27 | let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); +28 | let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); | ------------ ^^ the trait `From<()>` is not implemented for `CallerContext` | | | required by a bound introduced by this call diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs index 0401ec89f..bf016d458 100644 --- a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -4,7 +4,8 @@ //! record then accepts exactly what the canonical `StackCipherText` path //! accepts. `tests/ui/aead_context_with_term.rs` pins the record such a //! context cannot declare. -use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; +use stack_encrypt::target::{AeadContext}; +use stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}; use stack_encrypt::{ContextPiece, DecryptInto, EncryptFrom, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs index adf60790b..32defc8fc 100644 --- a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -2,7 +2,8 @@ //! must compile; `tests/ui/leaf_without_context.rs` and //! `tests/ui/bare_context.rs` pin the lines that must not. use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{DecryptFrom, EncryptInto}; + +use stack_encrypt::target::legacy::{EncryptInto, DecryptFrom}; use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, KeysetCipher, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs new file mode 100644 index 000000000..b44585ce1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.rs @@ -0,0 +1,6 @@ +fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>) { + let _ = keyset.encrypt(vec![1i32, 2]); + let _ = keyset.encrypt(vec![1u8, 2]); + let _ = keyset.encrypt("borrowed plaintext"); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr new file mode 100644 index 000000000..f8bde2374 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_collections_and_borrows.stderr @@ -0,0 +1,70 @@ +error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied + --> tests/ui/scalar_collections_and_borrows.rs:2:28 + | +2 | let _ = keyset.encrypt(vec![1i32, 2]); + | ------- ^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + +error[E0277]: the trait bound `Vec: OpaqueEncode` is not satisfied + --> tests/ui/scalar_collections_and_borrows.rs:3:28 + | +3 | let _ = keyset.encrypt(vec![1u8, 2]); + | ------- ^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Vec` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + +error[E0277]: the trait bound `&str: OpaqueEncode` is not satisfied + --> tests/ui/scalar_collections_and_borrows.rs:4:28 + | +4 | let _ = keyset.encrypt("borrowed plaintext"); + | ------- ^^^^^^^^^^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `&str` + | | + | required by a bound introduced by this call + | +help: the trait `OpaqueEncode` is implemented for `std::string::String` + --> src/scalar.rs + | + | impl OpaqueEncode for $ty {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^ +... + | opaque!(bool, i8, i16, i32, i64, i128, u16, u32, u64, u128, f32, f64, String); + | ----------------------------------------------------------------------------- in this macro invocation +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + = note: this error originates in the macro `opaque` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.rs b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.rs new file mode 100644 index 000000000..a7a653397 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.rs @@ -0,0 +1,20 @@ +use stack_encrypt::{DecryptFrom, EncryptInto}; +use stack_encrypt::target::{DecryptSource, Decryption, EncryptTarget, Encryption, NoCallerContext}; +struct Fixed; +impl EncryptTarget for Fixed { + type ContextInput = NoCallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + Encryption::failed(stack_encrypt::Error::Aead) + } +} +impl DecryptSource for Fixed { + type ContextInput = NoCallerContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + Decryption::failed(stack_encrypt::Error::Aead) + } +} +fn extra(keyset: &stack_encrypt::KeysetCipher<'_, K>) { + let _ = 42i32.encrypt_into_with_context::(keyset, ()); + let _ = i32::decrypt_from_with_context(Fixed, keyset, ()); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr new file mode 100644 index 000000000..4bd377c0c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_no_caller_context.stderr @@ -0,0 +1,39 @@ +error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied + --> tests/ui/scalar_no_caller_context.rs:17:19 + | +17 | let _ = 42i32.encrypt_into_with_context::(keyset, ()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AcceptsContext` is not implemented for `NoCallerContext` + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl AcceptsContext for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` +... + | impl AcceptsContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` + +error[E0277]: the trait bound `NoCallerContext: AcceptsContext` is not satisfied + --> tests/ui/scalar_no_caller_context.rs:18:44 + | +18 | let _ = i32::decrypt_from_with_context(Fixed, keyset, ()); + | ------------------------------ ^^^^^ the trait `AcceptsContext` is not implemented for `NoCallerContext` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AcceptsContext` + --> src/target/input.rs + | + | impl AcceptsContext for RequiredContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RequiredContext` +... + | impl AcceptsContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from_with_context` + --> src/target/conversion.rs + | + | fn decrypt_from_with_context<'a, S, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | S::ContextInput: AcceptsContext, + | ^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` diff --git a/packages/stack-encrypt/tests/ui/scalar_nullable.rs b/packages/stack-encrypt/tests/ui/scalar_nullable.rs new file mode 100644 index 000000000..4f08a35f0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_nullable.rs @@ -0,0 +1,5 @@ +fn unsupported(keyset: &stack_encrypt::KeysetCipher<'_, K>) { + let _ = keyset.encrypt(Some(42i32)); + let _ = keyset.encrypt(None::); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_nullable.stderr b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr new file mode 100644 index 000000000..c4647de38 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_nullable.stderr @@ -0,0 +1,47 @@ +error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied + --> tests/ui/scalar_nullable.rs:2:28 + | +2 | let _ = keyset.encrypt(Some(42i32)); + | ------- ^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` + +error[E0277]: the trait bound `Option: OpaqueEncode` is not satisfied + --> tests/ui/scalar_nullable.rs:3:28 + | +3 | let _ = keyset.encrypt(None::); + | ------- ^^^^^^^^^^^ the trait `OpaqueEncode` is not implemented for `Option` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `OpaqueEncode`: + bool + f32 + f64 + i128 + i16 + i32 + i64 + i8 + and $N others +note: required by a bound in `stack_encrypt::scalar::>::encrypt` + --> src/scalar.rs + | + | pub fn encrypt(&self, value: S) -> Pending<'_, Ciphertext, K> { + | ^^^^^^^^^^^^ required by this bound in `stack_encrypt::scalar::>::encrypt` diff --git a/packages/stack-encrypt/tests/ui/scalar_required_context.rs b/packages/stack-encrypt/tests/ui/scalar_required_context.rs new file mode 100644 index 000000000..8f9d87ea2 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_required_context.rs @@ -0,0 +1,22 @@ +use stack_encrypt::{Ciphertext, DecryptFrom, EncryptInto}; +use stack_encrypt::target::{DecryptSource, Decryption, EncryptTarget, Encryption, RequiredContext}; + +struct Required(Ciphertext); +impl EncryptTarget for Required { + type ContextInput = RequiredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, i32, Self, K, Self::ContextInput> { + stack_encrypt::target::scalar_ciphertext().map(Self).accepting() + } +} +impl DecryptSource for Required { + type ContextInput = RequiredContext; + fn decryption(self, _: Self::ContextInput) -> Decryption { + Decryption::failed(stack_encrypt::Error::Aead) + } +} +fn missing(keyset: &stack_encrypt::KeysetCipher<'_, K>, encrypted: Required) { + // u64: Default does not grant permission to omit a required context. + let _ = 42i32.encrypt_into::(keyset); + let _ = i32::decrypt_from(encrypted, keyset); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_required_context.stderr b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr new file mode 100644 index 000000000..0705bdec4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_required_context.stderr @@ -0,0 +1,47 @@ +error[E0277]: the trait bound `RequiredContext: AllowsNoContext` is not satisfied + --> tests/ui/scalar_required_context.rs:19:19 + | +19 | let _ = 42i32.encrypt_into::(keyset); + | ^^^^^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext` + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl AllowsNoContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::EncryptInto::encrypt_into` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `RequiredContext: AllowsNoContext` is not satisfied + --> tests/ui/scalar_required_context.rs:20:31 + | +20 | let _ = i32::decrypt_from(encrypted, keyset); + | ----------------- ^^^^^^^^^ the trait `AllowsNoContext` is not implemented for `RequiredContext` + | | + | required by a bound introduced by this call + | +help: the following other types implement trait `AllowsNoContext` + --> src/target/input.rs + | + | impl AllowsNoContext for NoCallerContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `NoCallerContext` +... + | impl AllowsNoContext for OptionalContext { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `OptionalContext` +note: required by a bound in `stack_encrypt::DecryptFrom::decrypt_from` + --> src/target/conversion.rs + | + | fn decrypt_from<'a, S, K: 'static>( + | ------------ required by a bound in this associated function +... + | S::ContextInput: AllowsNoContext, + | ^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` diff --git a/packages/stack-encrypt/tests/ui/scalar_source_consumed.rs b/packages/stack-encrypt/tests/ui/scalar_source_consumed.rs new file mode 100644 index 000000000..ea1376d81 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_source_consumed.rs @@ -0,0 +1,7 @@ +use stack_encrypt::{Ciphertext, EncryptInto}; +fn moved(keyset: &stack_encrypt::KeysetCipher<'_, K>) { + let value = String::from("secret"); + let _ = value.encrypt_into::(keyset); + drop(value); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/scalar_source_consumed.stderr b/packages/stack-encrypt/tests/ui/scalar_source_consumed.stderr new file mode 100644 index 000000000..7f2e1789d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/scalar_source_consumed.stderr @@ -0,0 +1,19 @@ +error[E0382]: use of moved value: `value` + --> tests/ui/scalar_source_consumed.rs:5:10 + | +3 | let value = String::from("secret"); + | ----- move occurs because `value` has type `std::string::String`, which does not implement the `Copy` trait +4 | let _ = value.encrypt_into::(keyset); + | ------------------------------------- `value` moved due to this method call +5 | drop(value); + | ^^^^^ value used here after move + | +note: `stack_encrypt::EncryptInto::encrypt_into` takes ownership of the receiver `self`, which moves `value` + --> src/target/conversion.rs + | + | fn encrypt_into<'a, T, K: 'static>(self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ^^^^ +help: you can `clone` the value and consume it, but this might not be your desired behavior + | +4 | let _ = value.clone().encrypt_into::(keyset); + | ++++++++