From bd0fe7b2b856f971f6312b0d49b7ab3ab8d1a731 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 18 Oct 2026 17:16:59 +1100 Subject: [PATCH 1/3] fix(stack-encrypt)!: report a ciphertext that opens as the wrong type as OpenedType, not Aead A typed decrypt collapsed every failure vitaminc reports into Error::Aead, the error for a wrong key, a wrong context or a tampered ciphertext. That included a leaf that authenticated and only held another type than the target reads: an integer opened as String, say. The Go guest then reported a value sealed as another type as STATUS_AUTH, i.e. tampering. vitaminc's DecryptError already tells the sink's refusal apart from the cipher's, so open_keyed now maps DecryptError::Sink to the new Error::OpenedType, naming only the type the target reads (never the map key a DecodeError can carry). Every other failure stays Aead. Telling a wrong-type reader apart reveals nothing, because the sink runs only after every leaf has authenticated. The guest maps OpenedType to STATUS_ENCODING, as it already maps a typed record field that opens to another kind. Closes #1118. --- languages/golang/encrypt/guest/src/status.rs | 8 ++ packages/stack-encrypt/CHANGELOG.md | 9 +++ packages/stack-encrypt/src/cipher.rs | 80 +++++++++++++++++--- packages/stack-encrypt/src/codes.rs | 5 ++ packages/stack-encrypt/src/dynamic/record.rs | 7 +- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 649ecddc8..2215ac536 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -137,6 +137,10 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { // statement about the caller's input, raised by the engine the // record exports run their plans through. stack_encrypt::Error::Plan(_) => STATUS_ENCODING, + // A ciphertext that authenticated but holds another type than the + // one it was opened as: sealed as that type, not tampered with, so + // the caller's input and not STATUS_AUTH. + stack_encrypt::Error::OpenedType { .. } => STATUS_ENCODING, _ => STATUS_INTERNAL, } } @@ -282,6 +286,10 @@ mod tests { #[test] fn aead_and_composition_errors_map_to_the_vitaminc_codes() { assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); + assert_eq!( + status_for_error(&stack_encrypt::Error::OpenedType { expected: "i32" }), + STATUS_ENCODING + ); assert_eq!( status_for_error(&stack_encrypt::Error::DescriptorTooLong { len: 513 }), STATUS_ENCODING diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a5782ca5d..310c3e00a 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -179,6 +179,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **A ciphertext that opens as the wrong type is `Error::OpenedType`, not + `Error::Aead`** (#1118). A typed decrypt of a leaf that authenticates but + holds another type (an integer opened as `String`, a value too wide for + the target) was reported as `Aead`, the error for a wrong key or a + tampered ciphertext, and the Go guest reported it as `STATUS_AUTH`. The new + variant names only the type the target reads (`expected`). The Go guest + maps it to `STATUS_ENCODING`, as it does a typed record field that opens to + another kind. A wrong key, a wrong context and a tampered ciphertext are + still `Aead`. - **An 8- or 16-bit integer in a dynamic value keeps its width** (`dynamic` feature; #1144). The value model used to widen it to 32 bits before it was sealed; now it is sealed as itself, so a field declared `int16` seals diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 8d48b95d5..c94116df1 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -105,8 +105,8 @@ use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore use uuid::Uuid; use vitaminc_aead::seal::seal_all; use vitaminc_aead::{ - Cipher, CipherText, Context, Decrypt, Encrypt, IntoAad, IntoContext, LocalCipherText, Node, - Pending, Unspecified, + Cipher, CipherText, Context, DecodeError, Decrypt, DecryptError, Encrypt, IntoAad, IntoContext, + LocalCipherText, Node, Pending, Unspecified, }; use vitaminc_encrypt::{Aes256Cipher, Key as AesKey}; use vitaminc_protected::Protected; @@ -141,15 +141,31 @@ pub enum Error { #[error(transparent)] #[diagnostic(transparent)] Kms(#[from] stack_kms::Error), - /// AEAD sealing/opening failed, or the ciphertext shape did not match the - /// requested type. On decrypt this is the expected outcome for a wrong key, - /// wrong AAD, or tampered ciphertext. + /// AEAD sealing/opening failed, or the ciphertext tree is malformed. On + /// decrypt this is the expected outcome for a wrong key, wrong AAD, or + /// tampered ciphertext. A ciphertext that opens but holds a value of + /// another type is [`OpenedType`](Self::OpenedType), not this. #[error("AEAD operation failed (wrong key, AAD mismatch, or malformed ciphertext)")] #[diagnostic( code(stack_encrypt::aead), help("Open the value under the context it was sealed with. If the context is right, the ciphertext is damaged or was altered.") )] Aead, + /// A ciphertext opened, so it is authentic and unaltered, but it holds a + /// value of another type than the one it was opened as: text opened as + /// an integer, an integer too wide for the target, a map without a field + /// the target needs. The value was sealed as another type, by this or + /// another binding, and is not tampered with. + /// + /// `expected` names what the target reads (a vitaminc kind such as + /// `i32`, or the Rust type). The message never names what the value + /// held, and never a map key. + #[error("the ciphertext opened, but it does not hold a value of the type {expected}")] + #[diagnostic( + code(stack_encrypt::opened_type), + help("Open the value as the type it was sealed as. The ciphertext is authentic: it was sealed as another type, not altered.") + )] + OpenedType { expected: &'static str }, /// ZeroKMS returned a different number of keys than were requested. #[error("expected {expected} data keys from ZeroKMS but received {received}")] #[diagnostic(code(stack_encrypt::key_count_mismatch))] @@ -320,6 +336,7 @@ impl crate::ErrorPayload for Error { ("expected", (*expected).into()), ("received", (*received).into()), ]), + Self::OpenedType { expected } => payload([("expected", (*expected).into())]), Self::DescriptorTooLong { len } => payload([ ("len", (*len).into()), ("limit", Descriptor::MAX_LEN.into()), @@ -1509,21 +1526,64 @@ impl Cipher for BoundKeys { } } -/// Open a key-bound tree into `T` under `aad`. Every failure is -/// [`Error::Aead`]: a wrong key or AAD, a tampered leaf or marker, a -/// malformed tree, and a value that is not a `T` all read the same to the -/// caller, as they did before vitaminc split them. +/// Open a key-bound tree into `T` under `aad`. A wrong key or AAD, a +/// tampered leaf or marker, and a malformed tree are [`Error::Aead`]. A tree +/// that opens but holds a value that is not a `T` is [`Error::OpenedType`]: +/// every leaf authenticated, so it was sealed as another type, not altered. pub(crate) fn open_keyed<'a, T: Decrypt>( keyed: KeyedCipherText, aad: impl IntoAad<'a>, ) -> Result { - vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(|_| Error::Aead) + vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(opened_error::) +} + +/// What a failed open is to the caller. Only the sink's refusal is told +/// apart, and only by the type it wanted: the `DecodeError` itself can name +/// a map key, which is not repeated. +fn opened_error(error: DecryptError) -> Error { + match error { + DecryptError::Sink(DecodeError::Expected(expected)) => Error::OpenedType { expected }, + DecryptError::Sink(_) => Error::OpenedType { + expected: std::any::type_name::(), + }, + _ => Error::Aead, + } } #[cfg(test)] mod tests { use super::*; + /// A failed open is `Aead` unless the leaves authenticated and only the + /// sink refused: then it is `OpenedType`, naming the kind the sink + /// wanted, or the target type when the refusal names no kind (and never + /// the map key a refusal can carry). + #[test] + fn only_a_sink_refusal_is_told_apart_from_aead() { + let opened = |error| opened_error::(error); + assert!(matches!( + opened(DecryptError::Sink(DecodeError::Expected("i32"))), + Error::OpenedType { expected: "i32" } + )); + for refusal in [ + DecodeError::MissingField("age"), + DecodeError::unknown_key("ssn"), + DecodeError::duplicate_key("ssn"), + ] { + assert!(matches!( + opened(DecryptError::Sink(refusal)), + Error::OpenedType { expected: "u32" } + )); + } + for failure in [ + DecryptError::Cipher(Unspecified), + DecryptError::Auth, + DecryptError::Malformed, + ] { + assert!(matches!(opened(failure), Error::Aead)); + } + } + /// A cipher's `Debug` — and a keyset handle's — names the keyset and the /// source's type and stops there: the cache behind a cipher holds every /// loaded keyset's index key, a handle carries its keyset's, and the diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 6388c2e15..00cb4116d 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -48,6 +48,7 @@ mod tests { errors.extend(boxed(variants![ Error::Kms(_) => Error::Kms(crate::kms::Error::Unexpected("kms".into())), Error::Aead => Error::Aead, + Error::OpenedType { .. } => Error::OpenedType { expected: "i32" }, Error::KeyCountMismatch { .. } => Error::KeyCountMismatch { expected: 2, received: 1, @@ -377,6 +378,10 @@ mod tests { json!({ "left": a.to_string(), "right": b.to_string() }), ), (Box::new(Error::Aead), json!({})), + ( + Box::new(Error::OpenedType { expected: "i32" }), + json!({ "expected": "i32" }), + ), ( Box::new(LeafBytesError::UnknownVersion(9)), json!({ "version": 9 }), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index de356bff8..950c30748 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -6231,8 +6231,13 @@ mod tests { let mut age = map(node(&mut fields, "age")); let as_string: Result = cipher.decrypt(node(&mut age, "c"), age_label.clone()).await; + // The leaf authenticates, so a reader of another kind is + // told the type, not that the leaf was tampered with. assert!( - matches!(as_string, Err(crate::Error::Aead)), + matches!( + as_string, + Err(crate::Error::OpenedType { expected: "string" }) + ), "a uint32 leaf is not text, type {ty:?}: {as_string:?}" ); } From 8e904f8be2468880e826b06dff2ad7f2391bd94c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 18 Oct 2026 17:30:50 +1100 Subject: [PATCH 2/3] fix(stack-encrypt): tell only a single leaf of another kind apart from Aead A container's leaves authenticate one by one, but its membership does not: a map entry's AAD binds only its key, and a sequence element's binds no index. So an entry removed or repeated, or elements reordered or dropped, still authenticate, and the target's refusal of the result (MissingField, DuplicateKey, or Expected on a reshaped container) can be tampering. open_keyed now reports OpenedType only when the tree is one leaf and the target refused its kind. Every other failure, every refusal of a container's value included, stays Aead. Found by review on #1256. --- packages/stack-encrypt/CHANGELOG.md | 20 +++-- packages/stack-encrypt/src/cipher.rs | 123 +++++++++++++++++---------- 2 files changed, 91 insertions(+), 52 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 310c3e00a..41ae83957 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -179,15 +179,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- **A ciphertext that opens as the wrong type is `Error::OpenedType`, not - `Error::Aead`** (#1118). A typed decrypt of a leaf that authenticates but - holds another type (an integer opened as `String`, a value too wide for - the target) was reported as `Aead`, the error for a wrong key or a - tampered ciphertext, and the Go guest reported it as `STATUS_AUTH`. The new - variant names only the type the target reads (`expected`). The Go guest - maps it to `STATUS_ENCODING`, as it does a typed record field that opens to - another kind. A wrong key, a wrong context and a tampered ciphertext are - still `Aead`. +- **A leaf that opens as the wrong kind is `Error::OpenedType`, not + `Error::Aead`** (#1118). A typed decrypt of a single leaf that + authenticates but holds another kind (an integer opened as `String`, a + value too wide for the target) was reported as `Aead`, the error for a + wrong key or a tampered ciphertext, and the Go guest reported it as + `STATUS_AUTH`. The new variant names only the kind the target reads + (`expected`). The Go guest maps it to `STATUS_ENCODING`, as it does a + typed record field that opens to another kind. A wrong key, a wrong + context and a tampered ciphertext are still `Aead`, and so is any refusal + of a container's value: a container's entries and elements are not + authenticated as a set, so a missing or repeated entry may be tampering. - **An 8- or 16-bit integer in a dynamic value keeps its width** (`dynamic` feature; #1144). The value model used to widen it to 32 bits before it was sealed; now it is sealed as itself, so a field declared `int16` seals diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index c94116df1..1851478e9 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -151,15 +151,18 @@ pub enum Error { help("Open the value under the context it was sealed with. If the context is right, the ciphertext is damaged or was altered.") )] Aead, - /// A ciphertext opened, so it is authentic and unaltered, but it holds a - /// value of another type than the one it was opened as: text opened as - /// an integer, an integer too wide for the target, a map without a field - /// the target needs. The value was sealed as another type, by this or - /// another binding, and is not tampered with. + /// A ciphertext of one leaf opened, so it is authentic and unaltered, + /// but it holds a value of another kind than the one it was opened as: + /// text opened as an integer, or an integer too wide for the target. The + /// value was sealed as that kind, by this or another binding, and is not + /// tampered with. /// - /// `expected` names what the target reads (a vitaminc kind such as - /// `i32`, or the Rust type). The message never names what the value - /// held, and never a map key. + /// Only a single leaf is told apart this way. A container whose value + /// the target refuses is [`Aead`](Self::Aead): its leaves authenticate + /// one by one, but which entries and elements it holds does not. + /// + /// `expected` names the kind the target reads, such as `i32`. The + /// message never names what the value held. #[error("the ciphertext opened, but it does not hold a value of the type {expected}")] #[diagnostic( code(stack_encrypt::opened_type), @@ -1527,25 +1530,33 @@ impl Cipher for BoundKeys { } /// Open a key-bound tree into `T` under `aad`. A wrong key or AAD, a -/// tampered leaf or marker, and a malformed tree are [`Error::Aead`]. A tree -/// that opens but holds a value that is not a `T` is [`Error::OpenedType`]: -/// every leaf authenticated, so it was sealed as another type, not altered. +/// tampered leaf or marker, and a malformed tree are [`Error::Aead`]. A +/// single leaf that opens but holds a value of another kind than `T` reads +/// is [`Error::OpenedType`]: the leaf authenticated, so it was sealed as +/// that kind, not altered. +/// +/// A container is never told apart. Its leaves authenticate one by one, but +/// its membership does not: a map entry's AAD binds its key and a sequence +/// element's binds no index, so an entry removed or repeated, or elements +/// reordered or dropped, still authenticate, and the target's refusal of +/// the result is tampering as far as the caller can know. pub(crate) fn open_keyed<'a, T: Decrypt>( keyed: KeyedCipherText, aad: impl IntoAad<'a>, ) -> Result { - vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(opened_error::) + let single = matches!(keyed, CipherText::Single(_)); + vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(|error| opened_error(single, error)) } -/// What a failed open is to the caller. Only the sink's refusal is told -/// apart, and only by the type it wanted: the `DecodeError` itself can name -/// a map key, which is not repeated. -fn opened_error(error: DecryptError) -> Error { +/// What a failed open of a tree is to the caller: [`Error::OpenedType`] only +/// when the tree is one leaf and the target refused its kind, naming the +/// kind it wanted. A `DecodeError`'s other refusals can name a map key, +/// which is not repeated, and are [`Error::Aead`] with every other failure. +fn opened_error(single: bool, error: DecryptError) -> Error { match error { - DecryptError::Sink(DecodeError::Expected(expected)) => Error::OpenedType { expected }, - DecryptError::Sink(_) => Error::OpenedType { - expected: std::any::type_name::(), - }, + DecryptError::Sink(DecodeError::Expected(expected)) if single => { + Error::OpenedType { expected } + } _ => Error::Aead, } } @@ -1554,36 +1565,62 @@ fn opened_error(error: DecryptError) -> Error { mod tests { use super::*; - /// A failed open is `Aead` unless the leaves authenticated and only the - /// sink refused: then it is `OpenedType`, naming the kind the sink - /// wanted, or the target type when the refusal names no kind (and never - /// the map key a refusal can carry). + /// A failed open is `Aead` unless the tree is one leaf, which + /// authenticated, and the target refused its kind: then it is + /// `OpenedType`, naming the kind the target wanted. A container's + /// refusal is `Aead`, because its membership is not authenticated. #[test] - fn only_a_sink_refusal_is_told_apart_from_aead() { - let opened = |error| opened_error::(error); + fn only_a_single_leaf_of_another_kind_is_told_apart_from_aead() { assert!(matches!( - opened(DecryptError::Sink(DecodeError::Expected("i32"))), + opened_error(true, DecryptError::Sink(DecodeError::Expected("i32"))), Error::OpenedType { expected: "i32" } )); - for refusal in [ - DecodeError::MissingField("age"), - DecodeError::unknown_key("ssn"), - DecodeError::duplicate_key("ssn"), - ] { - assert!(matches!( - opened(DecryptError::Sink(refusal)), - Error::OpenedType { expected: "u32" } - )); - } - for failure in [ - DecryptError::Cipher(Unspecified), - DecryptError::Auth, - DecryptError::Malformed, - ] { - assert!(matches!(opened(failure), Error::Aead)); + assert!(matches!( + opened_error(false, DecryptError::Sink(DecodeError::Expected("i32"))), + Error::Aead + )); + for single in [true, false] { + for refusal in [ + DecodeError::MissingField("age"), + DecodeError::unknown_key("ssn"), + DecodeError::duplicate_key("ssn"), + ] { + assert!(matches!( + opened_error(single, DecryptError::Sink(refusal)), + Error::Aead + )); + } + for failure in [ + DecryptError::Cipher(Unspecified), + DecryptError::Auth, + DecryptError::Malformed, + ] { + assert!(matches!(opened_error(single, failure), Error::Aead)); + } } } + /// Through a real open: one leaf of another kind is `OpenedType`, and a + /// container the target refuses is `Aead`, whatever the refusal. + #[tokio::test] + async fn a_leaf_of_another_kind_is_opened_type_and_a_container_is_aead() { + let cipher = StackCipher::builder() + .kms(stack_kms::FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let leaf = keyset.encrypt(7u32, "users/age").await.unwrap(); + let opened: Result = cipher.decrypt(leaf, "users/age").await; + assert!( + matches!(opened, Err(Error::OpenedType { expected: "string" })), + "{opened:?}" + ); + let seq = keyset.encrypt(vec![7u32, 8], "users/age").await.unwrap(); + let opened: Result = cipher.decrypt(seq, "users/age").await; + assert!(matches!(opened, Err(Error::Aead)), "{opened:?}"); + } + /// A cipher's `Debug` — and a keyset handle's — names the keyset and the /// source's type and stops there: the cache behind a cipher holds every /// loaded keyset's index key, a handle carries its keyset's, and the From 07fa82669f8a276ebead66b98eac6d5581e0cbd8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 18 Oct 2026 17:42:49 +1100 Subject: [PATCH 3/3] Revert "fix(stack-encrypt): tell only a single leaf of another kind apart from Aead", and say why This reverts commit 8e904f8be. A container's membership and order are deliberately not authenticated: a stored sequence is a set of rows and a stored map a row's columns, and a database returns rows in any order and a query selects some columns, not all. Authenticating position or presence would break exactly those reads. So a target refusing a container's shape is a shape mismatch, OpenedType, not tampering. The rationale now sits on open_keyed, on Error::OpenedType and in the CHANGELOG, and a test opens a Vec as u32 and expects OpenedType, so a later review that reads the missing membership check as a defect meets the reason first. --- packages/stack-encrypt/CHANGELOG.md | 23 ++--- packages/stack-encrypt/src/cipher.rs | 126 ++++++++++++++------------- 2 files changed, 77 insertions(+), 72 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 41ae83957..fe2276ef0 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -179,17 +179,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- **A leaf that opens as the wrong kind is `Error::OpenedType`, not - `Error::Aead`** (#1118). A typed decrypt of a single leaf that - authenticates but holds another kind (an integer opened as `String`, a - value too wide for the target) was reported as `Aead`, the error for a - wrong key or a tampered ciphertext, and the Go guest reported it as - `STATUS_AUTH`. The new variant names only the kind the target reads - (`expected`). The Go guest maps it to `STATUS_ENCODING`, as it does a - typed record field that opens to another kind. A wrong key, a wrong - context and a tampered ciphertext are still `Aead`, and so is any refusal - of a container's value: a container's entries and elements are not - authenticated as a set, so a missing or repeated entry may be tampering. +- **A ciphertext that opens as the wrong type is `Error::OpenedType`, not + `Error::Aead`** (#1118). A typed decrypt of a leaf that authenticates but + holds another type (an integer opened as `String`, a value too wide for + the target) was reported as `Aead`, the error for a wrong key or a + tampered ciphertext, and the Go guest reported it as `STATUS_AUTH`. The new + variant names only the type the target reads (`expected`). The Go guest + maps it to `STATUS_ENCODING`, as it does a typed record field that opens to + another kind. A wrong key, a wrong context and a tampered ciphertext are + still `Aead`. A container whose shape the target refuses (a map missing a + field) is `OpenedType` too: a container's membership and order are not + authenticated, by design, since stored values map to rows and columns a + database returns in any order and a query selects in part. - **An 8- or 16-bit integer in a dynamic value keeps its width** (`dynamic` feature; #1144). The value model used to widen it to 32 bits before it was sealed; now it is sealed as itself, so a field declared `int16` seals diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 1851478e9..092dcf737 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -151,18 +151,22 @@ pub enum Error { help("Open the value under the context it was sealed with. If the context is right, the ciphertext is damaged or was altered.") )] Aead, - /// A ciphertext of one leaf opened, so it is authentic and unaltered, - /// but it holds a value of another kind than the one it was opened as: - /// text opened as an integer, or an integer too wide for the target. The - /// value was sealed as that kind, by this or another binding, and is not + /// A ciphertext opened, so every leaf in it is authentic and unaltered, + /// but it holds a value of another type or shape than the one it was + /// opened as: text opened as an integer, an integer too wide for the + /// target, a map without a field the target needs. The value was sealed + /// as another type or shape, or arrived as part of one, and is not /// tampered with. /// - /// Only a single leaf is told apart this way. A container whose value - /// the target refuses is [`Aead`](Self::Aead): its leaves authenticate - /// one by one, but which entries and elements it holds does not. + /// A container's membership and order are not authenticated, by design: + /// a stored sequence is a set of rows and a stored map a row's columns, + /// and a database returns rows in any order and a query selects some + /// columns, not all. So a map missing a field, or a sequence of another + /// length, is a shape the target refuses, not a failed authentication. /// - /// `expected` names the kind the target reads, such as `i32`. The - /// message never names what the value held. + /// `expected` names what the target reads (a vitaminc kind such as + /// `i32`, or the Rust type). The message never names what the value + /// held, and never a map key. #[error("the ciphertext opened, but it does not hold a value of the type {expected}")] #[diagnostic( code(stack_encrypt::opened_type), @@ -1530,33 +1534,33 @@ impl Cipher for BoundKeys { } /// Open a key-bound tree into `T` under `aad`. A wrong key or AAD, a -/// tampered leaf or marker, and a malformed tree are [`Error::Aead`]. A -/// single leaf that opens but holds a value of another kind than `T` reads -/// is [`Error::OpenedType`]: the leaf authenticated, so it was sealed as -/// that kind, not altered. +/// tampered leaf or marker, and a malformed tree are [`Error::Aead`]. A tree +/// that opens but holds a value that is not a `T` is [`Error::OpenedType`]: +/// every leaf authenticated, so it was sealed as another type, not altered. /// -/// A container is never told apart. Its leaves authenticate one by one, but -/// its membership does not: a map entry's AAD binds its key and a sequence -/// element's binds no index, so an entry removed or repeated, or elements -/// reordered or dropped, still authenticate, and the target's refusal of -/// the result is tampering as far as the caller can know. +/// Each leaf authenticates on its own, under its context and its place's +/// AAD (a map entry's key). Which entries a map holds and the order of a +/// sequence are deliberately not authenticated: stored values map to rows +/// and columns that a database returns in any order and a query selects in +/// part. A refusal of a container's shape is therefore `OpenedType` like any +/// other, not `Aead`. Authenticating membership may suit other uses later; +/// it is not what this crate does. pub(crate) fn open_keyed<'a, T: Decrypt>( keyed: KeyedCipherText, aad: impl IntoAad<'a>, ) -> Result { - let single = matches!(keyed, CipherText::Single(_)); - vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(|error| opened_error(single, error)) + vitaminc_aead::decrypt(&BoundKeys, keyed, aad).map_err(opened_error::) } -/// What a failed open of a tree is to the caller: [`Error::OpenedType`] only -/// when the tree is one leaf and the target refused its kind, naming the -/// kind it wanted. A `DecodeError`'s other refusals can name a map key, -/// which is not repeated, and are [`Error::Aead`] with every other failure. -fn opened_error(single: bool, error: DecryptError) -> Error { +/// What a failed open is to the caller. Only the sink's refusal is told +/// apart, and only by the type it wanted: the `DecodeError` itself can name +/// a map key, which is not repeated. +fn opened_error(error: DecryptError) -> Error { match error { - DecryptError::Sink(DecodeError::Expected(expected)) if single => { - Error::OpenedType { expected } - } + DecryptError::Sink(DecodeError::Expected(expected)) => Error::OpenedType { expected }, + DecryptError::Sink(_) => Error::OpenedType { + expected: std::any::type_name::(), + }, _ => Error::Aead, } } @@ -1565,45 +1569,42 @@ fn opened_error(single: bool, error: DecryptError) -> mod tests { use super::*; - /// A failed open is `Aead` unless the tree is one leaf, which - /// authenticated, and the target refused its kind: then it is - /// `OpenedType`, naming the kind the target wanted. A container's - /// refusal is `Aead`, because its membership is not authenticated. + /// A failed open is `Aead` unless the leaves authenticated and only the + /// sink refused: then it is `OpenedType`, naming the kind the sink + /// wanted, or the target type when the refusal names no kind (and never + /// the map key a refusal can carry). #[test] - fn only_a_single_leaf_of_another_kind_is_told_apart_from_aead() { + fn only_a_sink_refusal_is_told_apart_from_aead() { + let opened = |error| opened_error::(error); assert!(matches!( - opened_error(true, DecryptError::Sink(DecodeError::Expected("i32"))), + opened(DecryptError::Sink(DecodeError::Expected("i32"))), Error::OpenedType { expected: "i32" } )); - assert!(matches!( - opened_error(false, DecryptError::Sink(DecodeError::Expected("i32"))), - Error::Aead - )); - for single in [true, false] { - for refusal in [ - DecodeError::MissingField("age"), - DecodeError::unknown_key("ssn"), - DecodeError::duplicate_key("ssn"), - ] { - assert!(matches!( - opened_error(single, DecryptError::Sink(refusal)), - Error::Aead - )); - } - for failure in [ - DecryptError::Cipher(Unspecified), - DecryptError::Auth, - DecryptError::Malformed, - ] { - assert!(matches!(opened_error(single, failure), Error::Aead)); - } + for refusal in [ + DecodeError::MissingField("age"), + DecodeError::unknown_key("ssn"), + DecodeError::duplicate_key("ssn"), + ] { + assert!(matches!( + opened(DecryptError::Sink(refusal)), + Error::OpenedType { expected: "u32" } + )); + } + for failure in [ + DecryptError::Cipher(Unspecified), + DecryptError::Auth, + DecryptError::Malformed, + ] { + assert!(matches!(opened(failure), Error::Aead)); } } - /// Through a real open: one leaf of another kind is `OpenedType`, and a - /// container the target refuses is `Aead`, whatever the refusal. + /// Through a real open: a leaf of another kind, and a container whose + /// shape the target refuses, are both `OpenedType`. Membership and order + /// are not authenticated, by design (see `open_keyed`), so a container + /// the target cannot read is a shape mismatch, not tampering. #[tokio::test] - async fn a_leaf_of_another_kind_is_opened_type_and_a_container_is_aead() { + async fn a_leaf_or_a_container_of_another_shape_is_opened_type() { let cipher = StackCipher::builder() .kms(stack_kms::FakeDataKeySource::new()) .init() @@ -1613,12 +1614,15 @@ mod tests { let leaf = keyset.encrypt(7u32, "users/age").await.unwrap(); let opened: Result = cipher.decrypt(leaf, "users/age").await; assert!( - matches!(opened, Err(Error::OpenedType { expected: "string" })), + matches!(opened, Err(Error::OpenedType { .. })), "{opened:?}" ); let seq = keyset.encrypt(vec![7u32, 8], "users/age").await.unwrap(); let opened: Result = cipher.decrypt(seq, "users/age").await; - assert!(matches!(opened, Err(Error::Aead)), "{opened:?}"); + assert!( + matches!(opened, Err(Error::OpenedType { .. })), + "{opened:?}" + ); } /// A cipher's `Debug` — and a keyset handle's — names the keyset and the