diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 84eec229..d90b29d2 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -55,10 +55,11 @@ exclude_re = [ # and the port is checked byte for byte against the frozen crate besides. "replace \\| with \\^ in (orderable|strip_trailing_zeros)$", # Unreachable, not untested: `Nothing` (vitaminc-aead typed.rs) is an - # uninhabited enum, so no value exists to call `scalar` or `passthrough` - # on and replacing either body with `Ok(())` cannot be observed. Its - # `seq` and `map` mutants are unviable for the same reason. - "replace ::(scalar|passthrough) ", + # uninhabited enum, so no value exists to call `scalar`, `typed_null` or + # `passthrough` on and replacing any of those bodies with `Ok(())` cannot + # be observed. Its `seq` and `map` mutants are unviable for the same + # reason. + "replace ::(scalar|typed_null|passthrough) ", # Equivalent mutant: `max` (vitaminc-aead plaintext/depth.rs) picks the # larger of two depths, and when they are equal `a > b` and `a >= b` # return the same number, so no input can tell them apart. diff --git a/packages/aead-derive/src/encrypt.rs b/packages/aead-derive/src/encrypt.rs index f7a9412f..bc1687df 100644 --- a/packages/aead-derive/src/encrypt.rs +++ b/packages/aead-derive/src/encrypt.rs @@ -77,11 +77,13 @@ pub(crate) fn derive(input: DeriveInput) -> Result { item_check, visit_check, } = depth(krate, &input, &shape, attrs.recursive); + let (fixed, ref_fixed) = fixed(krate, &input, &shape, attrs.recursive); Ok(quote! { #[automatically_derived] impl #impl_generics #krate::Encrypt for #name #ty_generics #where_clause { #depth + #fixed fn visit<__S>(#receiver, __sink: __S) -> ::core::result::Result<__S::Ok, __S::Error> where @@ -95,6 +97,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { #[automatically_derived] impl #ref_impl_generics #krate::Encrypt for &'__r #name #ty_generics #ref_where_clause { #ref_depth + #ref_fixed fn visit<__S>(self, __sink: __S) -> ::core::result::Result<__S::Ok, __S::Error> where @@ -215,6 +218,37 @@ fn depth(krate: &syn::Path, input: &DeriveInput, shape: &Shape, recursive: bool) } } +/// The `FIXED` constants for both impls: what an absent value of the type +/// is sealed as. A newtype is transparent, so it has its inner type's (by +/// value and by reference, as `DEPTH` does). A fieldless enum is always a +/// `u32` leaf. A struct is a map, which has none, and a recursive newtype +/// keeps the default rather than name its own constant in a cycle. +fn fixed( + krate: &syn::Path, + input: &DeriveInput, + shape: &Shape, + recursive: bool, +) -> (TokenStream, TokenStream) { + let decl = |value: TokenStream| quote!(const FIXED: ::core::option::Option<#krate::FixedType> = #value;); + match shape { + Shape::Newtype(_) if recursive || names_itself(&input.ident, shape) => { + (TokenStream::new(), TokenStream::new()) + } + Shape::Newtype(field) => { + let ty = &field.ty; + ( + decl(quote!(<#ty as #krate::Encrypt>::FIXED)), + decl(quote!(<&'__r #ty as #krate::Encrypt>::FIXED)), + ) + } + Shape::Enum(_) => { + let u32 = decl(quote!(#krate::FixedType::plain(#krate::Width::U32))); + (u32.clone(), u32) + } + Shape::Map(_) | Shape::Empty => (TokenStream::new(), TokenStream::new()), + } +} + /// Whether a walked field's type names the type being derived, directly or /// as `Self`: a recursive type, whose depth depends on the value. Mutual /// recursion through another type cannot be seen from here; it needs @@ -709,6 +743,68 @@ mod tests { assert_lacks(&out, quote!(::vitaminc_aead::Physical::Text)); } + /// What an absent value of the type is sealed as: a newtype's inner + /// type's, by value and by reference; a `u32` leaf for a fieldless + /// enum; nothing for a struct, which is a map. + #[test] + fn fixed_follows_the_shape() { + let out = expand(parse_quote!( + struct Age(u32); + )); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + ::FIXED; + ), + ); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + <&'__r u32 as ::vitaminc_aead::Encrypt>::FIXED; + ), + ); + + let out = expand(parse_quote! { + enum Status { + Active, + } + }); + assert_contains( + &out, + quote!( + const FIXED: ::core::option::Option<::vitaminc_aead::FixedType> = + ::vitaminc_aead::FixedType::plain(::vitaminc_aead::Width::U32); + ), + ); + + let out = expand(parse_quote! { + struct User { + id: u32, + } + }); + assert_lacks(&out, quote!(const FIXED)); + } + + /// A recursive newtype keeps the default `FIXED` rather than name its + /// own constant in a cycle, whether it names itself or says so with + /// `#[aead(recursive)]`. + #[test] + fn a_recursive_newtype_declares_no_fixed() { + for input in [ + parse_quote!( + struct Wrapped(Option>); + ), + parse_quote!( + #[aead(recursive)] + struct Even(Option>); + ), + ] { + assert_lacks(&expand(input), quote!(const FIXED)); + } + } + /// Serde-style: bounding the parameter rather than each field's type lets a /// field of type `Vec` pick up `Vec: Encrypt` from the blanket /// impl. The by-reference impl bounds the parameter by reference, and its diff --git a/packages/aead-derive/src/typed_decrypt.rs b/packages/aead-derive/src/typed_decrypt.rs index 2b5d2319..a1a23abb 100644 --- a/packages/aead-derive/src/typed_decrypt.rs +++ b/packages/aead-derive/src/typed_decrypt.rs @@ -68,6 +68,9 @@ pub(crate) fn derive(input: DeriveInput) -> Result { <#inner as #krate::typed::Decrypt>::Sink, #wrap #ty_generics, >; + fn reads_null(__ty: #krate::FixedType) -> bool { + <#inner as #krate::typed::Decrypt>::reads_null(__ty) + } fn sink() -> Self::Sink { #krate::typed::Wrapped::new(<#inner as #krate::typed::Decrypt>::sink()) } @@ -211,6 +214,13 @@ fn map_target( ::core::result::Result::Err(#krate::DecodeError::Expected(#expected)) } + fn typed_null( + self, + _: #krate::FixedType, + ) -> ::core::result::Result { + ::core::result::Result::Err(#krate::DecodeError::Expected(#expected)) + } + fn seq( self, _: ::core::option::Option, @@ -298,6 +308,9 @@ fn enum_target(krate: &syn::Path, input: &DeriveInput, variants: &[VariantInfo]) #[automatically_derived] impl #impl_generics #krate::FromScalar for #name #ty_generics #where_clause { const EXPECTED: &'static str = #expected; + // Its own `None`, even with no variant at zero to read. + const FIXED: ::core::option::Option<#krate::FixedType> = + #krate::FixedType::plain(#krate::Width::U32); fn from_scalar( __ty: ::core::option::Option<#krate::Logical>, diff --git a/packages/aead-frame/src/serde.rs b/packages/aead-frame/src/serde.rs index aa594531..905310f9 100644 --- a/packages/aead-frame/src/serde.rs +++ b/packages/aead-frame/src/serde.rs @@ -21,6 +21,11 @@ //! | bool | bool | //! | null, and JavaScript's `undefined` | unit, and `None` | //! +//! A typed null ([`tags::TYPED_NULL`](vitaminc_aead::tags::TYPED_NULL)) is +//! refused as malformed. It is how plaintext marks an absent fixed-width +//! value so its sealed length matches a present one, which is no concern of +//! data that is not sealed, and this format never writes one. +//! //! Each kind reads only as itself: a struct or a map reads an object, never //! an array by position, and a string reads text, never bytes that happen //! to be UTF-8, nor bytes text. @@ -1490,4 +1495,20 @@ mod tests { ); } } + + /// A typed null is plaintext's, not this format's: refused wherever it + /// appears, an option included, rather than read as a null. + #[test] + fn a_typed_null_is_refused() { + let null_u32 = [ + vitaminc_aead::tags::TYPED_NULL, + vitaminc_aead::tags::UINT32, + 0, + 0, + 0, + ]; + assert!(from_slice::>(&null_u32).is_err()); + assert!(from_slice::<()>(&null_u32).is_err()); + assert!(from_slice::(&null_u32).is_err()); + } } diff --git a/packages/aead-napi/src/js_builder.rs b/packages/aead-napi/src/js_builder.rs index 16b784e6..5a36da09 100644 --- a/packages/aead-napi/src/js_builder.rs +++ b/packages/aead-napi/src/js_builder.rs @@ -15,7 +15,9 @@ use napi::bindgen_prelude::{BigInt, Buffer, JsValue, Null, Object, ToNapiValue, Unknown}; use napi::{Env, Error, Status}; -use vitaminc_aead::{Encrypt, Float, Frame, Int, Logical, MapSink, Physical, SeqSink, Sink}; +use vitaminc_aead::{ + Encrypt, FixedType, Float, Frame, Int, Logical, MapSink, Physical, SeqSink, Sink, +}; use crate::error::JsFault; use crate::js::{ @@ -124,6 +126,11 @@ impl<'e, O: Out<'e>> Sink for JsBuilder<'e, O> { Ok(self.out.put(built)) } + /// An absent value is JavaScript's `null`, whatever its type. + fn typed_null(self, _: FixedType) -> Result { + self.scalar(None, Physical::Null) + } + fn seq(self, len: Option) -> Result, JsFault> { self.check_depth()?; Ok(JsSeq { diff --git a/packages/aead/README.md b/packages/aead/README.md index 1b48e3c4..d422a673 100644 --- a/packages/aead/README.md +++ b/packages/aead/README.md @@ -131,7 +131,10 @@ let mut map = BTreeMap::new(); map.insert("name".to_string(), "Ada".to_string()); assert!(matches!(encrypt(map, &cipher, ())?, CipherText::Map(entries) if entries[0].0 == "name")); -// An empty container is an authenticated marker; `None` is a null leaf. +// An empty container is an authenticated marker; `None` is a null leaf. A +// `None` of a fixed-width type (an int, a float, a date, a timestamp, a +// decimal, a uuid) is a typed null, padded to the length of a `Some`, so the +// ciphertext's length does not show which values are absent. assert!(matches!(encrypt(Vec::::new(), &cipher, ())?, CipherText::EmptySequence(_))); assert!(matches!(encrypt(None::, &cipher, ())?, CipherText::Single(_))); # Ok::<(), Box>(()) diff --git a/packages/aead/src/container.rs b/packages/aead/src/container.rs index c6843c1a..7bafafbe 100644 --- a/packages/aead/src/container.rs +++ b/packages/aead/src/container.rs @@ -9,7 +9,9 @@ /// [`EmptyMap`](CipherText::EmptyMap), since they contain no element /// ciphertexts to authenticate the supplied AAD. Nested structures are /// represented recursively. An absent value (`Option::None`, a host null) -/// is a null leaf, a `Single` like any other scalar. +/// is a null leaf, a `Single` like any other scalar: the plain null, or a +/// typed null padded to its type's length (see +/// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL)). /// /// The two parameters: /// diff --git a/packages/aead/src/encrypt_sink.rs b/packages/aead/src/encrypt_sink.rs index 7a215690..f60426ea 100644 --- a/packages/aead/src/encrypt_sink.rs +++ b/packages/aead/src/encrypt_sink.rs @@ -36,7 +36,7 @@ use vitaminc_protected::{Controlled, Protected}; use crate::frame::PassthroughFrame; use crate::leaf::{self, ScalarError}; use crate::plaintext::{ - Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Step, + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, Step, }; use crate::seal::{ seal_all, seal_all_async, AsyncCipher, Cipher, LeafCount, Node, Pending, SealError, @@ -271,6 +271,18 @@ impl EncryptSink { aad: self.aad, }))) } + + /// Plan an absent value of a fixed-width type as one leaf: a typed + /// null, as long as a leaf holding a value of the type. + fn typed_null_leaf(self, ty: FixedType) -> Result> { + shape::scalar(self.depth, None, &Physical::Null, &self.out)?; + let mut plaintext = Protected::new(Vec::with_capacity(leaf::typed_null_len(ty))); + leaf::write_typed_null(ty, plaintext.risky_inner_mut()); + Ok(self.out.put(CipherText::Single(Pending { + plaintext, + aad: self.aad, + }))) + } } fn marker(aad: Context<'static>) -> Pending { @@ -290,6 +302,10 @@ impl Sink for EncryptSink { self.leaf(ty, v) } + fn typed_null(self, ty: FixedType) -> Result { + self.typed_null_leaf(ty) + } + fn seq(self, len: Option) -> Result { shape::container(self.depth, &self.out)?; Ok(EncryptSeq { @@ -473,13 +489,40 @@ mod tests { #[test] fn none_is_a_null_leaf_not_a_marker() { - let node = planned(None::).expect("plans"); + let node = planned(None::).expect("plans"); assert!(matches!(node, CipherText::Single(_))); let leaves = leaves_with_aad(node, &"ctx".into_aad()); assert_eq!(leaves[0].0.plaintext.risky_ref(), &[0x00]); assert_eq!(leaves[0].1, "ctx".into_aad()); } + #[test] + fn a_fixed_width_none_is_a_typed_null_as_long_as_a_some() { + let node = planned(None::).expect("plans"); + assert!(matches!(node, CipherText::Single(_))); + let leaves = leaves_with_aad(node, &"ctx".into_aad()); + assert_eq!(leaves[0].0.plaintext.risky_ref(), &[0x15, 0x06, 0, 0, 0]); + assert_eq!(leaves[0].1, "ctx".into_aad()); + let some = leaves_with_aad(planned(Some(34u32)).expect("plans"), &"ctx".into_aad()); + assert_eq!(some[0].0.plaintext.risky_ref().len(), 5); + } + + #[test] + fn a_typed_null_too_deep_is_refused_like_a_scalar() { + use crate::plaintext::{FixedType, Width}; + let sink = EncryptSink:: { + aad: "ctx".into_aad().into_owned(), + out: Root, + depth: crate::frame::MAX_DEPTH + 1, + _error: PhantomData, + }; + let err = sink + .typed_null(FixedType::plain(Width::I8).expect("fixed")) + .err() + .expect("too deep"); + assert!(matches!(err, EncryptError::Shape(_)), "{err:?}"); + } + #[test] fn empty_containers_are_markers() { let node = planned(Vec::::new()).expect("plans"); @@ -575,6 +618,10 @@ mod tests { *self.0 += 1; Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), E> { + *self.0 += 1; + Ok(()) + } fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/src/frame.rs b/packages/aead/src/frame.rs index 24f7857d..a9d5484c 100644 --- a/packages/aead/src/frame.rs +++ b/packages/aead/src/frame.rs @@ -21,8 +21,10 @@ use std::collections::HashSet; use vitaminc_protected::{Controlled, OpaqueDebug, Protected, TimingSafeEq}; use zeroize::Zeroize; -use crate::leaf::{self, Form, ScalarError}; -use crate::plaintext::{Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError}; +use crate::leaf::{self, Form, Item, ScalarError}; +use crate::plaintext::{ + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, +}; /// Framing tag for a sequence: `ARRAY`, `u32` count, then the elements. pub const ARRAY: u8 = 0xF0; @@ -117,11 +119,11 @@ impl<'a> Cursor<'a> { core::str::from_utf8(self.take(len)?).map_err(|_| FrameError) } - fn scalar(&mut self) -> Result<(Option, Physical<'a>), FrameError> { - let (scalar, used) = - leaf::read(&self.buf[self.pos..], Form::Frame).map_err(|_| FrameError)?; + fn scalar(&mut self) -> Result, FrameError> { + let (item, used) = + leaf::read_item(&self.buf[self.pos..], Form::Frame).map_err(|_| FrameError)?; self.pos += used; - Ok(scalar) + Ok(item) } /// The next whole value node, as its own frame. @@ -221,10 +223,10 @@ fn visit_node(c: &mut Cursor<'_>, sink: S) -> Result { c.byte().expect(VALIDATED); sink.passthrough(c.node(0).expect(VALIDATED)) } - _ => { - let (ty, v) = c.scalar().expect(VALIDATED); - sink.scalar(ty, v) - } + _ => match c.scalar().expect(VALIDATED) { + Item::Scalar(ty, v) => sink.scalar(ty, v), + Item::TypedNull(ty) => sink.typed_null(ty), + }, } } @@ -454,6 +456,17 @@ impl<'o> Sink for FrameWriter<'o> { }) } + /// Written as the plain null. A frame is plaintext, never sealed, so + /// padding hides nothing in it: what a frame writer produces goes to a + /// host (the frame [`decrypt_to_frame`](crate::decrypt_to_frame) + /// returns) or into the clear (a passthrough subtree), where the plain + /// null is what every reader already knows. A frame a host sends may + /// still carry a typed null, which [`Frame`] reads, so the host's + /// absent values are padded when sealed. + fn typed_null(self, _: FixedType) -> Result<(), FrameWriteError> { + self.scalar(None, Physical::Null) + } + fn seq(self, len: Option) -> Result, FrameWriteError> { Ok(FrameSeq { count: self.container(ARRAY)?, diff --git a/packages/aead/src/hlist/cipher.rs b/packages/aead/src/hlist/cipher.rs index 707403dd..5fd73fc5 100644 --- a/packages/aead/src/hlist/cipher.rs +++ b/packages/aead/src/hlist/cipher.rs @@ -41,7 +41,8 @@ pub trait StaticCipher: Sized + Copy { A: IntoAad<'a>; /// Produce an authenticated "no value" marker bound to `aad`: the null - /// leaf, `[tags::NULL]`, the same leaf the sink path seals for `None`. + /// leaf, `[tags::NULL]`, the same leaf the sink path seals for a `None` + /// of bytes. Bytes are variable width, so it is not padded. fn encrypt_none<'a, A>(self, aad: A) -> Result where A: IntoAad<'a>; diff --git a/packages/aead/src/hlist/types.rs b/packages/aead/src/hlist/types.rs index bca7c3fa..0a4dcf9c 100644 --- a/packages/aead/src/hlist/types.rs +++ b/packages/aead/src/hlist/types.rs @@ -30,7 +30,9 @@ impl Encrypted { } /// An authenticated "no value" marker: the null leaf (`[tags::NULL]`) -/// sealed under the slot's AAD, the same leaf the sink path seals for `None`. +/// sealed under the slot's AAD, the same leaf the sink path seals for the +/// `None` of the bytes an [`Encrypted`] holds. (A `None` of a fixed-width +/// type seals there as a typed null instead, which is not an `Absent`.) /// /// Inner field is private for the same reason as [`Encrypted`]. #[derive(Debug)] diff --git a/packages/aead/src/leaf.rs b/packages/aead/src/leaf.rs index 4f1e86b4..1d789848 100644 --- a/packages/aead/src/leaf.rs +++ b/packages/aead/src/leaf.rs @@ -29,8 +29,17 @@ //! leap second. It has no count in any unit and is refused. //! - A `DECIMAL` with the sign bit set on a zero mantissa reads as zero, //! and zero is always written without the sign. - -use crate::plaintext::kinds::{Float, Int, Logical, Physical, TimeUnit, MAX_DECIMAL_SCALE}; +//! +//! An absent value of a fixed-width type is a **typed null** +//! ([`tags::TYPED_NULL`]), padded to the length of a value of the type so +//! the two cannot be told apart by length once sealed. It is not a scalar: +//! [`write_typed_null`] writes one, [`read_item`] and [`read_leaf_item`] +//! read one, and [`read`] and [`read_leaf`] refuse one, so a reader that +//! does not know about typed nulls fails closed. + +use crate::plaintext::kinds::{ + FixedType, Float, Int, Logical, Physical, TimeUnit, Width, MAX_DECIMAL_SCALE, +}; use crate::tags; /// Which wire form a scalar is in. @@ -167,6 +176,84 @@ pub fn write_leaf( write(ty, v, Form::Leaf, out) } +/// Write a typed null of `ty`: [`tags::TYPED_NULL`], the header a value of +/// `ty` starts with, then zeros, to the length a value of `ty` takes. The +/// same bytes in either form, since a fixed-width scalar has no length +/// prefix. Cannot fail: a [`FixedType`] is valid by construction. +/// +/// # Examples +/// +/// A null date is as long as a date. +/// +/// ``` +/// use vitaminc_aead::leaf::{read_leaf_item, write_typed_null, Item, ScalarError}; +/// use vitaminc_aead::plaintext::{FixedType, Logical, Width}; +/// use vitaminc_aead::tags; +/// +/// let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); +/// let mut out = Vec::new(); +/// write_typed_null(date, &mut out); +/// assert_eq!(out, [tags::TYPED_NULL, tags::DATE, 0, 0, 0]); +/// assert_eq!(read_leaf_item(&out)?, Item::TypedNull(date)); +/// # Ok::<(), ScalarError>(()) +/// ``` +pub fn write_typed_null(ty: FixedType, out: &mut Vec) { + let (zero, header) = zero_leaf(ty); + out.push(tags::TYPED_NULL); + out.extend_from_slice(&zero[..header]); + // One byte of the value's length went to `TYPED_NULL`. Every payload + // here is at least one byte, so nothing is lost to the saturation. + out.resize(out.len() + zero.len().saturating_sub(header + 1), 0); +} + +/// The leaf of a zero of `ty`, and how many of its bytes are the header: +/// the tag, or the envelope's `LOGICAL, id, param, tag`. Written by the +/// codec every value goes through, so a typed null cannot drift from the +/// value it stands in for. +fn zero_leaf(ty: FixedType) -> (Vec, usize) { + let zero = ty.width().zero(); + let mut leaf = Vec::with_capacity(max_len(&zero)); + // A `FixedType` admits its own zero (a test pins every kind), so this + // cannot fail. + let written = write(ty.logical(), zero, Form::Leaf, &mut leaf); + debug_assert!(written.is_ok(), "{ty:?} refuses its zero"); + let header = if leaf.first() == Some(&tags::LOGICAL) { + 4 + } else { + 1 + }; + let header = header.min(leaf.len()); + (leaf, header) +} + +/// The width a plain tag is written for, or `None` for a tag no +/// [`FixedType`] is written with. Bytes are sixteen wide only under a +/// uuid, which [`FixedType::new`] checks. +fn tag_width(tag: u8) -> Option { + Some(match tag { + tags::INT8 => Width::I8, + tags::INT16 => Width::I16, + tags::INT32 => Width::I32, + tags::INT64 => Width::I64, + tags::INT128 => Width::I128, + tags::UINT8 => Width::U8, + tags::UINT16 => Width::U16, + tags::UINT32 => Width::U32, + tags::UINT64 => Width::U64, + tags::UINT128 => Width::U128, + tags::FLOAT32 => Width::F32, + tags::FLOAT64 => Width::F64, + tags::BYTES => Width::Bytes16, + _ => return None, + }) +} + +/// The length of a typed null of `ty`, which is the length of a leaf +/// holding a value of `ty`. +pub fn typed_null_len(ty: FixedType) -> usize { + zero_leaf(ty).0.len() +} + /// An upper bound on the bytes [`write()`] appends for `v` in either form: /// an envelope, a tag, a length and the payload. Lets a caller size a /// `Protected` buffer once rather than let it regrow, since a regrowing @@ -256,8 +343,19 @@ fn write_int(i: Int, out: &mut Vec) { /// One scalar as read from the wire. pub type Scalar<'a> = (Option, Physical<'a>); +/// One leaf or frame scalar node as read from the wire: a scalar, or a +/// typed null. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Item<'a> { + /// A value, or the plain null. + Scalar(Option, Physical<'a>), + /// An absent value of a fixed-width type. + TypedNull(FixedType), +} + /// Read one scalar in `form` from the front of `bytes`, validating it. -/// Returns the scalar and how many bytes it occupied. +/// Returns the scalar and how many bytes it occupied. A typed null is not +/// a scalar and is refused; [`read_item`] reads either. /// /// # Examples /// @@ -283,7 +381,8 @@ pub fn read(bytes: &[u8], form: Form) -> Result<(Scalar<'_>, usize), ScalarError Ok((scalar, c.pos)) } -/// Read one sealed leaf's plaintext, which must be exactly one scalar. +/// Read one sealed leaf's plaintext, which must be exactly one scalar. A +/// typed null is refused; [`read_leaf_item`] reads either. pub fn read_leaf(bytes: &[u8]) -> Result, ScalarError> { let (scalar, used) = read(bytes, Form::Leaf)?; if used != bytes.len() { @@ -292,6 +391,59 @@ pub fn read_leaf(bytes: &[u8]) -> Result, ScalarError> { Ok(scalar) } +/// Read one scalar or typed null in `form` from the front of `bytes`, +/// validating it. Returns it and how many bytes it occupied. +pub fn read_item(bytes: &[u8], form: Form) -> Result<(Item<'_>, usize), ScalarError> { + if bytes.first() == Some(&tags::TYPED_NULL) { + let mut c = Cursor { buf: bytes, pos: 1 }; + let ty = read_typed_null(&mut c)?; + return Ok((Item::TypedNull(ty), c.pos)); + } + let ((ty, v), used) = read(bytes, form)?; + Ok((Item::Scalar(ty, v), used)) +} + +/// Read one sealed leaf's plaintext, which must be exactly one scalar or +/// typed null. +pub fn read_leaf_item(bytes: &[u8]) -> Result, ScalarError> { + let (item, used) = read_item(bytes, Form::Leaf)?; + if used != bytes.len() { + return Err(ScalarError::Malformed); + } + Ok(item) +} + +/// A typed null after its `TYPED_NULL` tag. Strict, so that every absence +/// has one encoding: the header names a [`FixedType`], and the whole typed +/// null, header and padding, must be byte for byte what +/// [`write_typed_null`] writes for it. +fn read_typed_null(c: &mut Cursor<'_>) -> Result { + let start = c.pos - 1; + let (ty, width) = match c.byte()? { + tags::DATE => (Some(Logical::Date), Width::I32), + tags::TIMESTAMP => (Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128), + tags::DECIMAL => (Some(Logical::Decimal { scale: 0 }), Width::I128), + tags::LOGICAL => { + let ty = Logical::from_wire(c.byte()?, c.byte()?).ok_or(ScalarError::Malformed)?; + let width = tag_width(c.byte()?).ok_or(ScalarError::Malformed)?; + (Some(ty), width) + } + tag => (None, tag_width(tag).ok_or(ScalarError::Malformed)?), + }; + // A logical type over a physical type it cannot ride on (a uuid over + // an int, a timestamp over a float) has no typed null. + let fixed = FixedType::new(ty, width).ok_or(ScalarError::Malformed)?; + // An envelope where a shorthand tag exists, or padding that is not + // zero, is not what the writer writes. + let mut expected = Vec::new(); + write_typed_null(fixed, &mut expected); + c.pos = start; + if c.take(expected.len())? != expected { + return Err(ScalarError::Malformed); + } + Ok(fixed) +} + struct Cursor<'a> { buf: &'a [u8], pos: usize, @@ -737,7 +889,7 @@ mod tests { assert_eq!(read_leaf(&[0x06, 1, 2]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0x00, 0x00]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0x0A, 0xFF]), Err(ScalarError::Malformed)); - assert_eq!(read_leaf(&[0x15]), Err(ScalarError::Malformed)); + assert_eq!(read_leaf(&[0x16]), Err(ScalarError::Malformed)); assert_eq!(read_leaf(&[0xF0]), Err(ScalarError::Malformed)); assert_eq!( read(&[0x0A, 5, 0, 0, 0, b'x'], Form::Frame), @@ -1052,9 +1204,13 @@ mod tests { read_leaf(&[0xF3, 200, 0, 0x12, 0x44, 0x24, 0x0B, 0x00]), Err(ScalarError::Malformed) ); - // An unknown tag, and no tag at all. + // An unknown tag, a typed null, and no tag at all. assert_eq!( - read_leaf(&[0xF3, 200, 0, 0x15]), + read_leaf(&[0xF3, 200, 0, 0x16]), + Err(ScalarError::Malformed) + ); + assert_eq!( + read_leaf_item(&[0xF3, 200, 0, tags::TYPED_NULL, tags::INT8]), Err(ScalarError::Malformed) ); assert_eq!(read_leaf(&[0xF3, 200, 0]), Err(ScalarError::Malformed)); @@ -1075,6 +1231,290 @@ mod tests { assert_eq!(read_leaf(&[tags::BYTES]), Ok((None, Physical::Bytes(&[])))); } + fn typed_null(ty: FixedType) -> Vec { + let mut out = Vec::new(); + write_typed_null(ty, &mut out); + out + } + + fn fixed(ty: Option, width: Width) -> FixedType { + FixedType::new(ty, width).expect("fixed width") + } + + /// Every fixed-width type's typed null, pinned, beside a value of the + /// type: the cross-language wire commitment, and the same length. + #[test] + fn typed_null_known_answers() { + use tags::TYPED_NULL as T; + let ms = Logical::Timestamp(TimeUnit::Millis); + let unknown = Logical::from_wire(200, 9); + let cases: Vec<(FixedType, Vec, Physical<'_>)> = vec![ + ( + FixedType::plain(Width::I8).expect("fixed"), + vec![T, 0x0C], + Physical::Int(Int::I8(-1)), + ), + ( + FixedType::plain(Width::U8).expect("fixed"), + vec![T, 0x0D], + Physical::Int(Int::U8(1)), + ), + ( + FixedType::plain(Width::I16).expect("fixed"), + vec![T, 0x0E, 0], + Physical::Int(Int::I16(1)), + ), + ( + FixedType::plain(Width::U16).expect("fixed"), + vec![T, 0x0F, 0], + Physical::Int(Int::U16(1)), + ), + ( + FixedType::plain(Width::I32).expect("fixed"), + vec![T, 0x04, 0, 0, 0], + Physical::Int(Int::I32(1)), + ), + ( + FixedType::plain(Width::U32).expect("fixed"), + vec![T, 0x06, 0, 0, 0], + Physical::Int(Int::U32(1)), + ), + ( + FixedType::plain(Width::F32).expect("fixed"), + vec![T, 0x08, 0, 0, 0], + Physical::Float(Float::F32(1.5)), + ), + ( + FixedType::plain(Width::I64).expect("fixed"), + vec![T, 0x05, 0, 0, 0, 0, 0, 0, 0], + Physical::Int(Int::I64(1)), + ), + ( + FixedType::plain(Width::U64).expect("fixed"), + vec![T, 0x07, 0, 0, 0, 0, 0, 0, 0], + Physical::Int(Int::U64(1)), + ), + ( + FixedType::plain(Width::F64).expect("fixed"), + vec![T, 0x09, 0, 0, 0, 0, 0, 0, 0], + Physical::Float(Float::F64(1.5)), + ), + ( + FixedType::plain(Width::I128).expect("fixed"), + [&[T, 0x10][..], &[0; 15]].concat(), + Physical::Int(Int::I128(1)), + ), + ( + FixedType::plain(Width::U128).expect("fixed"), + [&[T, 0x11][..], &[0; 15]].concat(), + Physical::Int(Int::U128(1)), + ), + ( + fixed(Some(Logical::Date), Width::I32), + vec![T, 0x12, 0, 0, 0], + Physical::Int(Int::I32(11_017)), + ), + ( + fixed(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128), + [&[T, 0x13][..], &[0; 11]].concat(), + Physical::Int(Int::I128(1_000_000_005)), + ), + ( + fixed(Some(Logical::Decimal { scale: 0 }), Width::I128), + [&[T, 0x14][..], &[0; 15]].concat(), + Physical::Int(Int::I128(150)), + ), + // Through the envelope: the envelope's header, then the + // physical tag, then the padding. + ( + fixed(Some(ms), Width::I64), + [&[T, 0xF3, 3, 1, 0x05][..], &[0; 7]].concat(), + Physical::Int(Int::I64(1_700_000_000_000)), + ), + // A uuid's sixteen bytes are fixed by its type: no length. + ( + fixed(Some(Logical::Uuid), Width::Bytes16), + [&[T, 0xF3, 5, 0, 0x0B][..], &[0; 15]].concat(), + Physical::Bytes(&[0xAB; 16]), + ), + ( + fixed(unknown, Width::F32), + vec![T, 0xF3, 200, 9, 0x08, 0, 0, 0], + Physical::Float(Float::F32(1.5)), + ), + ]; + for (ty, expected, value) in cases { + assert_eq!(typed_null(ty), expected, "{ty:?}"); + assert_eq!(typed_null_len(ty), expected.len(), "{ty:?}"); + assert_eq!( + read_leaf_item(&expected).expect("reads"), + Item::TypedNull(ty), + "{ty:?}" + ); + // A frame carries the same bytes, and reads to where they end. + let framed = [&expected[..], &[tags::NULL]].concat(); + assert_eq!( + read_item(&framed, Form::Frame).expect("reads"), + (Item::TypedNull(ty), expected.len()), + "{ty:?}" + ); + // As long as a value of the type: the point of it. + let present = leaf(ty.logical(), value); + assert_eq!(present.len(), expected.len(), "{ty:?}"); + assert_eq!(FixedType::of(ty.logical(), &value), Some(ty), "{ty:?}"); + } + } + + /// `write_typed_null` writes from a zero of the type, so every + /// [`FixedType`] must admit its zero. + #[test] + fn every_fixed_type_admits_its_zero() { + let widths = [ + Width::I8, + Width::I16, + Width::I32, + Width::I64, + Width::I128, + Width::U8, + Width::U16, + Width::U32, + Width::U64, + Width::U128, + Width::F32, + Width::F64, + Width::Bytes16, + ]; + let types = [ + None, + Some(Logical::Undefined), + Some(Logical::Date), + Some(Logical::Timestamp(TimeUnit::Seconds)), + Some(Logical::Timestamp(TimeUnit::Millis)), + Some(Logical::Timestamp(TimeUnit::Micros)), + Some(Logical::Timestamp(TimeUnit::Nanos)), + Some(Logical::Decimal { scale: 28 }), + Some(Logical::Uuid), + Logical::from_wire(200, 9), + ]; + let mut checked = 0; + for ty in types { + for width in widths { + let Some(fixed) = FixedType::new(ty, width) else { + continue; + }; + let mut out = Vec::new(); + assert!( + write_leaf(fixed.logical(), width.zero(), &mut out).is_ok(), + "{fixed:?}" + ); + assert_eq!(typed_null(fixed).len(), out.len(), "{fixed:?}"); + assert_eq!( + read_leaf_item(&typed_null(fixed)), + Ok(Item::TypedNull(fixed)) + ); + checked += 1; + } + } + assert!(checked > 40, "only {checked}"); + } + + #[test] + fn a_typed_null_is_not_a_scalar() { + let null_date = typed_null(fixed(Some(Logical::Date), Width::I32)); + assert_eq!(read_leaf(&null_date), Err(ScalarError::Malformed)); + assert_eq!(read(&null_date, Form::Frame), Err(ScalarError::Malformed)); + // And a scalar reads through `read_item` as a scalar. + assert_eq!( + read_leaf_item(&[0x06, 34, 0, 0, 0]), + Ok(Item::Scalar(None, Physical::Int(Int::U32(34)))) + ); + assert_eq!( + read_leaf_item(&[0x00]), + Ok(Item::Scalar(None, Physical::Null)) + ); + } + + /// Every absence has one encoding, so a key-holding writer cannot pass + /// another plaintext off as one. + #[test] + fn a_typed_null_is_read_strictly() { + use tags::TYPED_NULL as T; + let refused: &[&[u8]] = &[ + // No header, and a header with no padding. + &[T], + &[T, 0x06], + // Padding that is not zero, short, or followed by more. + &[T, 0x06, 0, 1, 0], + &[T, 0x06, 0, 0], + &[T, 0x06, 0, 0, 0, 0], + &[T, 0x12, 0, 0, 0, 0], + // A tag with no typed null: null, undefined, bool, text, bytes, + // another typed null, an unknown tag. + &[T, 0x00], + &[T, 0x01], + &[T, 0x02], + &[T, 0x03], + &[T, 0x0A], + &[T, 0x0B], + &[T, T, 0x06, 0, 0, 0], + &[T, 0x16], + // An envelope where a shorthand tag exists (a date), with no + // logical type (id 0), and over the reserved long envelope. + &[T, 0xF3, 2, 0, 0x04, 0, 0, 0], + &[T, 0xF3, 0, 0, 0x04, 0, 0, 0], + &[T, 0xF4, 200, 0, 0x04, 0, 0, 0], + // A logical type over a physical type it cannot ride on: a + // uuid over an int, a millisecond timestamp over a float, an + // unknown type over a bool, text or bytes; and plain bytes. + &[T, 0xF3, 5, 0, 0x04, 0, 0, 0], + &[T, 0xF3, 3, 1, 0x09, 0, 0, 0, 0, 0, 0, 0], + &[T, 0xF3, 200, 0, 0x02], + &[T, 0xF3, 200, 0, 0x0A], + &[ + T, 0xF3, 200, 0, 0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ], + &[T, 0x0B, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + // A uuid's typed null with a length, as a frame gives bytes. + &[T, 0xF3, 5, 0, 0x0B, 16, 0, 0, 0], + ]; + for bytes in refused { + assert_eq!( + read_leaf_item(bytes), + Err(ScalarError::Malformed), + "{bytes:?}" + ); + } + } + + /// Whatever `read_item` accepts after a typed null tag writes back to + /// the bytes it read: one encoding per absence. + #[quickcheck] + fn any_typed_null_input_reads_without_panic_and_has_one_encoding( + tag: u8, + id: u8, + param: u8, + rest: Vec, + ) -> bool { + let tag = tag % 0x16; + // Mostly zero padding, so the accepting path is exercised too. + let rest: Vec = rest.iter().map(|&b| if b < 0xF0 { 0 } else { b }).collect(); + let plain = [&[tags::TYPED_NULL, tag][..], &rest[..]].concat(); + let envelope = [ + &[tags::TYPED_NULL, tags::LOGICAL, id % 8, param % 32, tag][..], + &rest, + ] + .concat(); + [plain, envelope].iter().all(|bytes| { + [Form::Leaf, Form::Frame] + .into_iter() + .all(|form| match read_item(bytes, form) { + Err(_) => true, + Ok((Item::TypedNull(ty), used)) => typed_null(ty) == bytes[..used], + Ok((Item::Scalar(..), _)) => false, + }) + }) + } + #[test] fn scalar_errors_display_and_are_errors() { fn is_error(_: &E) {} diff --git a/packages/aead/src/lib.rs b/packages/aead/src/lib.rs index c1f96c43..c6403726 100644 --- a/packages/aead/src/lib.rs +++ b/packages/aead/src/lib.rs @@ -23,8 +23,8 @@ mod walk; // The data model: sources, sinks and the scalar kinds. #[doc(inline)] pub use plaintext::{ - Bytes, Encrypt, Float, Int, Kind, KnownLogical, Logical, MapSink, Physical, Raw, SeqSink, Sink, - SourceError, Tee, TimeUnit, UnknownLogical, UnknownLogicalId, + Bytes, Encrypt, FixedType, Float, Int, Kind, KnownLogical, Logical, MapSink, Physical, Raw, + SeqSink, Sink, SourceError, Tee, TimeUnit, UnknownLogical, UnknownLogicalId, Width, }; /// Derive [`Encrypt`] for a struct or a fieldless enum. A struct walks as /// a map keyed by field name; a fieldless enum as an int leaf holding the diff --git a/packages/aead/src/plaintext/kinds.rs b/packages/aead/src/plaintext/kinds.rs index 4fb4bed2..efac8f2e 100644 --- a/packages/aead/src/plaintext/kinds.rs +++ b/packages/aead/src/plaintext/kinds.rs @@ -250,6 +250,163 @@ pub enum Float { F64(f64), } +/// The physical type of a fixed-width scalar whose leaf has a payload: an +/// int or a float, with its width, or sixteen bytes. What a [`FixedType`] +/// rides on. +/// +/// `bool` is fixed width too, but its leaf is the tag alone, one byte, the +/// same length as the plain null leaf, so it has no width here. Bytes have +/// a fixed width only where a logical type fixes it: [`Width::Bytes16`] is +/// for a uuid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Width { + I8, + I16, + I32, + I64, + I128, + U8, + U16, + U32, + U64, + U128, + F32, + F64, + /// Sixteen bytes. + Bytes16, +} + +impl Width { + /// The physical kind: [`Kind::Int`], [`Kind::Float`] or [`Kind::Bytes`]. + pub const fn kind(self) -> Kind { + match self { + Width::F32 | Width::F64 => Kind::Float, + Width::Bytes16 => Kind::Bytes, + _ => Kind::Int, + } + } + + /// A zero of this physical type: a value whose leaf has the header and + /// the length every value of the type has. + pub const fn zero(self) -> Physical<'static> { + match self { + Width::I8 => Physical::Int(Int::I8(0)), + Width::I16 => Physical::Int(Int::I16(0)), + Width::I32 => Physical::Int(Int::I32(0)), + Width::I64 => Physical::Int(Int::I64(0)), + Width::I128 => Physical::Int(Int::I128(0)), + Width::U8 => Physical::Int(Int::U8(0)), + Width::U16 => Physical::Int(Int::U16(0)), + Width::U32 => Physical::Int(Int::U32(0)), + Width::U64 => Physical::Int(Int::U64(0)), + Width::U128 => Physical::Int(Int::U128(0)), + Width::F32 => Physical::Float(Float::F32(0.0)), + Width::F64 => Physical::Float(Float::F64(0.0)), + Width::Bytes16 => Physical::Bytes(&[0; 16]), + } + } + + /// The width of a value of this physical type, or `None` for a value + /// with no [`Width`]: null, bool, text, and bytes of any length but + /// sixteen. + pub fn of(v: &Physical<'_>) -> Option { + Some(match v { + Physical::Int(Int::I8(_)) => Width::I8, + Physical::Int(Int::I16(_)) => Width::I16, + Physical::Int(Int::I32(_)) => Width::I32, + Physical::Int(Int::I64(_)) => Width::I64, + Physical::Int(Int::I128(_)) => Width::I128, + Physical::Int(Int::U8(_)) => Width::U8, + Physical::Int(Int::U16(_)) => Width::U16, + Physical::Int(Int::U32(_)) => Width::U32, + Physical::Int(Int::U64(_)) => Width::U64, + Physical::Int(Int::U128(_)) => Width::U128, + Physical::Float(Float::F32(_)) => Width::F32, + Physical::Float(Float::F64(_)) => Width::F64, + Physical::Bytes(b) if b.len() == 16 => Width::Bytes16, + Physical::Null | Physical::Bool(_) | Physical::Text(_) | Physical::Bytes(_) => { + return None + } + }) + } +} + +/// A fixed-width scalar type, named without a value: what an absent value +/// of that type stands in for. +/// +/// AES-GCM keeps the plaintext's length, so a plain null leaf (one byte) +/// sealed in place of a date (five) shows which values are absent to +/// anyone who can see the ciphertexts. A null of a `FixedType` is sealed as +/// a **typed null** padded to the type's own leaf length, so a sealed +/// `None` and a sealed `Some` are the same length. See +/// [`Sink::typed_null`](crate::plaintext::Sink::typed_null) and +/// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL). +/// +/// A `FixedType` is normalised to how its leaf is written, so two are equal +/// exactly when their typed nulls are the same bytes. A logical type with a +/// tag of its own fixes its width: a date is written as an `i32` day count +/// and a nanosecond timestamp and a decimal as 128-bit words, whatever +/// width the value came with. A decimal's scale travels inside its payload +/// rather than in its tag, so every decimal has the one typed null, of +/// scale 0. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FixedType { + ty: Option, + width: Width, +} + +impl FixedType { + /// A plain int or float, with no logical type, or `None` for + /// [`Width::Bytes16`]: plain bytes are variable width. + pub const fn plain(width: Width) -> Option { + Self::new(None, width) + } + + /// `ty` over `width`, normalised, or `None` if the pair has no fixed + /// width: undefined (over null), a date, timestamp or decimal over a + /// float, a uuid over anything but sixteen bytes, and sixteen bytes + /// under anything but a uuid (plain or unknown bytes are variable + /// width, so a reader could not tell where their typed null ends). + pub const fn new(ty: Option, width: Width) -> Option { + let float = matches!(width, Width::F32 | Width::F64); + let bytes = matches!(width, Width::Bytes16); + let (ty, width) = match ty { + Some(Logical::Uuid) if bytes => (Some(Logical::Uuid), width), + Some(Logical::Uuid) => return None, + _ if bytes => return None, + None => (None, width), + Some(Logical::Unknown(u)) => (Some(Logical::Unknown(u)), width), + Some(Logical::Undefined) => return None, + Some(_) if float => return None, + Some(Logical::Date) => (Some(Logical::Date), Width::I32), + Some(Logical::Timestamp(TimeUnit::Nanos)) => { + (Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128) + } + Some(Logical::Decimal { .. }) => (Some(Logical::Decimal { scale: 0 }), Width::I128), + Some(Logical::Timestamp(unit)) => (Some(Logical::Timestamp(unit)), width), + }; + Some(Self { ty, width }) + } + + /// The type of `v` under `ty`, or `None` if it has none (see + /// [`new`](FixedType::new)): what a host that knows the value's type + /// but holds no value passes to + /// [`Sink::typed_null`](crate::plaintext::Sink::typed_null). + pub fn of(ty: Option, v: &Physical<'_>) -> Option { + Self::new(ty, Width::of(v)?) + } + + /// The logical type, if any. + pub const fn logical(self) -> Option { + self.ty + } + + /// The physical width. + pub const fn width(self) -> Width { + self.width + } +} + // `Physical` and `Float` derive their constant-time equality: the derive // compares same-variant payloads with `ts_eq` and treats different // variants as unequal, which is the structural equality wanted here. The @@ -748,6 +905,71 @@ impl fmt::Display for Logical { mod tests { use super::*; + #[test] + fn a_fixed_type_is_normalised_to_how_its_leaf_is_written() { + let fixed = |ty, width| FixedType::new(ty, width).expect("fixed width"); + // A tag of its own fixes the width, and a decimal's scale. + assert_eq!( + fixed(Some(Logical::Date), Width::I64), + fixed(Some(Logical::Date), Width::I32) + ); + assert_eq!(fixed(Some(Logical::Date), Width::U8).width(), Width::I32); + let ns = Some(Logical::Timestamp(TimeUnit::Nanos)); + assert_eq!(fixed(ns, Width::I64).width(), Width::I128); + assert_eq!( + fixed(Some(Logical::Decimal { scale: 2 }), Width::I64), + fixed(Some(Logical::Decimal { scale: 0 }), Width::I128) + ); + // Through the envelope, the width is the value's own. + let ms = Some(Logical::Timestamp(TimeUnit::Millis)); + assert_eq!(fixed(ms, Width::I64).width(), Width::I64); + assert_ne!(fixed(ms, Width::I64), fixed(ms, Width::I32)); + let unknown = Logical::from_wire(200, 9); + assert_eq!(fixed(unknown, Width::F32).logical(), unknown); + assert_eq!( + FixedType::plain(Width::U8).map(FixedType::logical), + Some(None) + ); + // Plain bytes, even sixteen of them, are variable width. + assert_eq!(FixedType::plain(Width::Bytes16), None); + assert_eq!( + fixed(Some(Logical::Uuid), Width::Bytes16).width(), + Width::Bytes16 + ); + } + + #[test] + fn a_type_not_over_a_fixed_width_physical_type_has_no_fixed_type() { + assert_eq!(FixedType::new(Some(Logical::Uuid), Width::U128), None); + let unknown = Logical::from_wire(200, 0); + assert_eq!(FixedType::new(unknown, Width::Bytes16), None); + assert_eq!(FixedType::new(Some(Logical::Date), Width::Bytes16), None); + assert_eq!(FixedType::new(Some(Logical::Undefined), Width::U8), None); + assert_eq!(FixedType::new(Some(Logical::Date), Width::F64), None); + assert_eq!( + FixedType::new(Some(Logical::Timestamp(TimeUnit::Millis)), Width::F64), + None + ); + assert_eq!( + FixedType::new(Some(Logical::Decimal { scale: 2 }), Width::F32), + None + ); + assert_eq!(Width::of(&Physical::Bytes(&[0; 16])), Some(Width::Bytes16)); + assert_eq!(Width::Bytes16.kind(), Kind::Bytes); + for v in [ + Physical::Null, + Physical::Bool(true), + Physical::Text("x"), + Physical::Bytes(&[1]), + ] { + assert_eq!(Width::of(&v), None); + assert_eq!(FixedType::of(None, &v), None); + } + assert_eq!(Width::of(&Physical::Int(Int::U16(1))), Some(Width::U16)); + assert_eq!(Width::F32.kind(), Kind::Float); + assert_eq!(Width::I128.kind(), Kind::Int); + } + #[test] fn every_known_logical_type_round_trips_through_the_wire() { let known = [ diff --git a/packages/aead/src/plaintext/mod.rs b/packages/aead/src/plaintext/mod.rs index f75751d9..92289c80 100644 --- a/packages/aead/src/plaintext/mod.rs +++ b/packages/aead/src/plaintext/mod.rs @@ -26,7 +26,8 @@ mod sources; mod tee; pub use kinds::{ - Float, Int, Kind, KnownLogical, Logical, Physical, TimeUnit, UnknownLogical, UnknownLogicalId, + FixedType, Float, Int, Kind, KnownLogical, Logical, Physical, TimeUnit, UnknownLogical, + UnknownLogicalId, Width, }; #[cfg(any(test, feature = "test-support"))] #[doc(hidden)] @@ -89,6 +90,21 @@ pub trait Encrypt { /// value of the type can nest, or a type that is fine fails to compile. const DEPTH: Option = None; + /// The fixed-width scalar type every value of this type has, if it has + /// one: an int, a float, a date, a timestamp, a decimal. `None`, the + /// default, for everything else: a bool (whose leaf is already as short + /// as a null's), text, bytes, a container, a value whose type depends + /// on the value. + /// + /// `Option` walks its `None` as a [`Sink::typed_null`] of this + /// type, so the encrypting sink seals it padded to the length of a + /// `Some`, and [`Decrypt`](crate::Decrypt) reads that typed null back + /// only into an `Option` of the same type. It must be the type + /// [`visit`](Encrypt::visit) always walks, normalised as + /// [`FixedType`] normalises it, or a `None` and a `Some` seal at + /// different lengths. + const FIXED: Option = None; + /// Describe `self` to `sink`, consuming it. fn visit(self, sink: S) -> Result; } @@ -176,6 +192,21 @@ pub trait Sink: Sized { /// ``` fn scalar(self, ty: Option, value: Physical<'_>) -> Result; + /// An absent value of a fixed-width type: a null that says what it + /// stands in for. `Option` walks its `None` here when `T` has a + /// [`FIXED`](Encrypt::FIXED) type, and a host that knows a missing + /// value's type does the same. + /// + /// It means what `scalar(None, Physical::Null)` means; the type is + /// there so its length can be hidden. The encrypting sink seals it as a + /// typed null padded to the type's leaf length (see + /// [`tags::TYPED_NULL`](crate::tags::TYPED_NULL)), the frame carries + /// it, and a typed decode target reads it only as an `Option` of the + /// same type. A sink that only needs to know a value is absent treats + /// it as a null. A sink that forwards to other sinks must forward it as + /// it is, or the padding is lost and absent values show again. + fn typed_null(self, ty: FixedType) -> Result; + /// Start a sequence. `len` is a hint; a source that does not know its /// length passes `None`. fn seq(self, len: Option) -> Result; @@ -281,6 +312,7 @@ pub(crate) mod record { #[derive(Debug, Clone, PartialEq)] pub enum Event { Scalar(Option, Owned), + TypedNull(FixedType), Seq(Option), Element, EndSeq, @@ -338,6 +370,11 @@ pub(crate) mod record { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), RecordError> { + self.0.push(Event::TypedNull(ty)); + Ok(()) + } + fn seq(self, len: Option) -> Result { self.0.push(Event::Seq(len)); Ok(self) diff --git a/packages/aead/src/plaintext/sources.rs b/packages/aead/src/plaintext/sources.rs index 1f248f0a..9ae2f1c6 100644 --- a/packages/aead/src/plaintext/sources.rs +++ b/packages/aead/src/plaintext/sources.rs @@ -12,22 +12,23 @@ use std::collections::{BTreeMap, HashMap}; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroize; -use super::kinds::{Float, Logical, Physical}; +use super::kinds::{FixedType, Float, Logical, Physical, Width}; use super::{depth, visit_seq, Encrypt, MapSink, SeqSink, Sink}; macro_rules! int { - ($($ty:ty),* $(,)?) => { + ($($ty:ty => $width:ident),* $(,)?) => { $( impl Encrypt for $ty { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = FixedType::plain(Width::$width); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Int(self.into())) } } impl Encrypt for &$ty { - const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <$ty as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -36,7 +37,10 @@ macro_rules! int { }; } -int!(i8, i16, i32, i64, i128, u16, u32, u64, u128); +int! { + i8 => I8, i16 => I16, i32 => I32, i64 => I64, i128 => I128, + u16 => U16, u32 => U32, u64 => U64, u128 => U128, +} impl Encrypt for bool { const DEPTH: Option = depth::SCALAR; @@ -47,6 +51,7 @@ impl Encrypt for bool { impl Encrypt for f32 { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = FixedType::plain(Width::F32); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F32(self))) } @@ -54,6 +59,7 @@ impl Encrypt for f32 { impl Encrypt for f64 { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = FixedType::plain(Width::F64); fn visit(self, sink: S) -> Result { sink.scalar(None, Physical::Float(Float::F64(self))) } @@ -66,6 +72,7 @@ macro_rules! by_ref { $( impl Encrypt for &$ty { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <$ty as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -188,19 +195,26 @@ where &'b T: Encrypt, { const DEPTH: Option = <&'b T as Encrypt>::DEPTH; + const FIXED: Option = <&'b T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } } -/// `None` is a null. There is no separate "absent" in the data model, so a -/// host null and a Rust `None` are the same leaf. +/// `None` is a null. There is no separate "absent" in the data model: a +/// Rust `None` and a host null mean the same thing. +/// +/// When `T` has a [`FIXED`](Encrypt::FIXED) type, `None` is a typed null +/// of it, which the encrypting sink pads to the length of a `Some`. +/// Otherwise it is the plain null, the same leaf as a host null. An `Option>` has `T`'s type +/// too, so its two `None`s seal alike, as they always have. impl Encrypt for Option { const DEPTH: Option = T::DEPTH; + const FIXED: Option = T::FIXED; fn visit(self, sink: S) -> Result { match self { Some(v) => v.visit(sink), - None => sink.scalar(None, Physical::Null), + None => null::(sink), } } } @@ -210,14 +224,23 @@ where &'a T: Encrypt, { const DEPTH: Option = <&'a T as Encrypt>::DEPTH; + const FIXED: Option = <&'a T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { match self { Some(v) => v.visit(sink), - None => sink.scalar(None, Physical::Null), + None => null::<&'a T, S>(sink), } } } +/// The null an absent `T` walks as: typed if `T` is fixed width. +fn null(sink: S) -> Result { + match T::FIXED { + Some(ty) => sink.typed_null(ty), + None => sink.scalar(None, Physical::Null), + } +} + /// A protected value walks as a borrow of what it wraps, so nothing leaves /// the wrapper during the walk, and everything in it, visited or not, is /// zeroized when the wrapper drops at the end of it. @@ -226,6 +249,7 @@ where for<'a> &'a T: Encrypt, { const DEPTH: Option = <&T as Encrypt>::DEPTH; + const FIXED: Option = <&T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { self.risky_ref().visit(sink) // `self` drops here and zeroizes the whole value. @@ -239,6 +263,7 @@ where &'a T: Encrypt, { const DEPTH: Option = <&'a T as Encrypt>::DEPTH; + const FIXED: Option = <&'a T as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { self.risky_ref().visit(sink) } @@ -316,6 +341,10 @@ impl Sink for Discard { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), Discarded> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(Discard) } @@ -442,6 +471,7 @@ mod chrono_sources { impl Encrypt for NaiveDate { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = FixedType::new(Some(Logical::Date), Width::I32); fn visit(self, sink: S) -> Result { sink.scalar( Some(Logical::Date), @@ -455,6 +485,8 @@ mod chrono_sources { /// field refuses it when the value is built instead. impl Encrypt for DateTime { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = + FixedType::new(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128); fn visit(self, sink: S) -> Result { let nanos = conv::timestamp_nanos(self).ok_or_else(|| { S::Error::source(SourceError::msg( @@ -470,6 +502,7 @@ mod chrono_sources { impl Encrypt for &NaiveDate { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = NaiveDate::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -477,6 +510,7 @@ mod chrono_sources { impl Encrypt for &DateTime { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -486,6 +520,9 @@ mod chrono_sources { #[cfg(feature = "rust_decimal")] impl Encrypt for rust_decimal::Decimal { const DEPTH: Option = depth::SCALAR; + /// Every scale shares the one typed null: the scale is in the payload. + const FIXED: Option = + FixedType::new(Some(Logical::Decimal { scale: 0 }), Width::I128); fn visit(self, sink: S) -> Result { let (scale, mantissa) = super::kinds::decimal::parts(self); sink.scalar( @@ -498,6 +535,7 @@ impl Encrypt for rust_decimal::Decimal { #[cfg(feature = "rust_decimal")] impl Encrypt for &rust_decimal::Decimal { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = ::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -506,6 +544,9 @@ impl Encrypt for &rust_decimal::Decimal { #[cfg(feature = "uuid")] impl Encrypt for uuid::Uuid { const DEPTH: Option = depth::SCALAR; + /// Sixteen bytes under the uuid type: the one bytes leaf whose width is + /// fixed. + const FIXED: Option = FixedType::new(Some(Logical::Uuid), Width::Bytes16); fn visit(self, sink: S) -> Result { sink.scalar(Some(Logical::Uuid), Physical::Bytes(self.as_bytes())) } @@ -514,6 +555,7 @@ impl Encrypt for uuid::Uuid { #[cfg(feature = "uuid")] impl Encrypt for &uuid::Uuid { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = ::FIXED; fn visit(self, sink: S) -> Result { (*self).visit(sink) } @@ -742,6 +784,10 @@ mod tests { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } @@ -863,7 +909,13 @@ mod tests { record(&Some(1.5f64)).expect("ok"), record(Some(1.5f64)).expect("ok") ); - assert_eq!(record(&None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!( + record(&None::).expect("ok"), + [Event::TypedNull( + FixedType::plain(Width::I8).expect("fixed") + )] + ); + assert_eq!(record(&None::<&str>).expect("ok"), [scalar(Owned::Null)]); // A borrowed `Vec` is still a byte leaf, not a sequence. assert_eq!( record(&vec![1u8, 2]).expect("ok"), @@ -886,13 +938,34 @@ mod tests { #[test] fn option_none_is_null() { - assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::).expect("ok"), [scalar(Owned::Null)]); + assert_eq!(record(None::>).expect("ok"), [scalar(Owned::Null)]); assert_eq!( record(Some(1u32)).expect("ok"), [scalar(Owned::Int(Int::U32(1)))] ); } + /// A fixed-width `None` names its type, at every layer that wraps it. + // The borrows are the point: each one picks the reference impl. + #[allow(clippy::needless_borrows_for_generic_args)] + #[test] + fn a_fixed_width_none_is_a_typed_null() { + let typed = |w| [Event::TypedNull(FixedType::plain(w).expect("fixed"))]; + assert_eq!(record(None::).expect("ok"), typed(Width::U32)); + assert_eq!(record(None::).expect("ok"), typed(Width::I128)); + assert_eq!(record(None::).expect("ok"), typed(Width::F32)); + assert_eq!(record(&None::).expect("ok"), typed(Width::F64)); + assert_eq!(record(None::>).expect("ok"), typed(Width::I16)); + assert_eq!(record(Some(None::)).expect("ok"), typed(Width::I16)); + assert_eq!( + record(Protected::new(None::)).expect("ok"), + typed(Width::U64) + ); + assert_eq!(record(&&None::).expect("ok"), typed(Width::I64)); + } + #[test] fn containers_push_each_child_into_its_own_sink() { let mut map = BTreeMap::new(); diff --git a/packages/aead/src/plaintext/tee.rs b/packages/aead/src/plaintext/tee.rs index 8db2505a..3d654319 100644 --- a/packages/aead/src/plaintext/tee.rs +++ b/packages/aead/src/plaintext/tee.rs @@ -4,7 +4,7 @@ //! source as one sink, so both consumers receive every call in the same //! order. Any sinks with the same error type compose, at any depth. -use super::kinds::{Logical, Physical}; +use super::kinds::{FixedType, Logical, Physical}; use super::{Encrypt, MapSink, SeqSink, Sink}; /// A pair of sinks that forwards every call to both and pairs the results. @@ -41,6 +41,12 @@ impl> Sink for Tee { .and_then(|a| b.scalar(ty, value).map(|b| (a, b))) } + fn typed_null(self, ty: FixedType) -> Result { + let (a, b) = (self.0, self.1); + a.typed_null(ty) + .and_then(|a| b.typed_null(ty).map(|b| (a, b))) + } + fn seq(self, len: Option) -> Result { let (a, b) = (self.0, self.1); a.seq(len).and_then(|a| b.seq(len).map(|b| Tee(a, b))) @@ -159,6 +165,23 @@ mod tests { assert!(matches!(left[0], Event::Scalar(Some(t), _) if t.to_wire() == (200, 9))); } + #[test] + fn a_typed_null_reaches_both_sides_as_a_typed_null() { + use crate::plaintext::kinds::Width; + use crate::plaintext::record::Event; + let (mut left, mut right) = (Vec::new(), Vec::new()); + None:: + .visit(Tee(Record(&mut left), Record(&mut right))) + .expect("ok"); + assert_eq!(left, right); + assert_eq!( + left, + [Event::TypedNull( + FixedType::plain(Width::I32).expect("fixed") + )] + ); + } + #[test] fn visit_map_walks_each_entry_into_its_own_sink() { use crate::plaintext::record::{Event, Owned}; @@ -217,6 +240,9 @@ mod tests { fn scalar(self, _: Option, _: Physical<'_>) -> Result<(), RecordError> { Err(RecordError("refused".into())) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Err(RecordError("refused".into())) + } fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/src/seal.rs b/packages/aead/src/seal.rs index b2e145d8..92278509 100644 --- a/packages/aead/src/seal.rs +++ b/packages/aead/src/seal.rs @@ -512,9 +512,11 @@ mod tests { leaves[4].1, "ctx".into_aad().for_map_entry("c").for_sequence_element() ); - // The marker is empty; the null leaf is a NULL tag. + // The marker is empty; the absent `u32` is a typed null, as long + // as the `u32` leaves beside it. assert!(leaves[3].0.plaintext.risky_ref().is_empty()); - assert_eq!(leaves[1].0.plaintext.risky_ref(), &[0x00]); + assert_eq!(leaves[1].0.plaintext.risky_ref(), &[0x15, 0x06, 0, 0, 0]); + assert_eq!(leaves[0].0.plaintext.risky_ref().len(), 5); // And the plan's AAD is what the sealed leaf is bound under. let sealed = encrypt(sample(), &Mock, "ctx").expect("encrypts"); let sealed = leaves_with_aad(sealed, &"ctx".into_aad()); diff --git a/packages/aead/src/tags.rs b/packages/aead/src/tags.rs index 8df1ea8d..ce04a2e8 100644 --- a/packages/aead/src/tags.rs +++ b/packages/aead/src/tags.rs @@ -89,6 +89,23 @@ pub const TIMESTAMP: u8 = 0x13; /// [`Logical::Decimal`](crate::Logical) over an int holding the mantissa, /// so a negative zero reads as zero (see [`crate::leaf`]). pub const DECIMAL: u8 = 0x14; +/// Typed null: an absent value of a fixed-width type, padded to the length +/// of that type's leaf so a sealed absence cannot be told from a sealed +/// value by its length. `TYPED_NULL`, then the header a value of the type +/// starts with (its own tag, or the [`LOGICAL`] envelope's `LOGICAL, id, +/// param, tag`), then zero bytes for all but one byte of its payload. A +/// null date is `[TYPED_NULL, DATE, 0, 0, 0]`, five bytes like +/// `[DATE, d0, d1, d2, d3]`. +/// +/// Only an int or a float, plain or under a logical type, and a uuid have a +/// typed null (see [`FixedType`](crate::FixedType)). A bool's leaf is the +/// tag alone, already as short as [`NULL`]; other text and bytes are +/// variable width. A uuid's typed null has no length before its padding, +/// even in a frame: its type fixes the length. A +/// reader refuses a typed null of any other tag, an envelope where a +/// shorthand tag exists, and padding that is not zero, so every absence +/// has one encoding. [`NULL`] stays a valid absence of any type. +pub const TYPED_NULL: u8 = 0x15; /// The generic logical-type envelope: `LOGICAL, id, param`, then the plain /// physical scalar. Carries any logical type that has no tag of its own. diff --git a/packages/aead/src/timestamp.rs b/packages/aead/src/timestamp.rs index faa09bed..794c777b 100644 --- a/packages/aead/src/timestamp.rs +++ b/packages/aead/src/timestamp.rs @@ -4,7 +4,7 @@ use ::chrono::{DateTime, Utc}; use vitaminc_protected::{OpaqueDebug, TimingSafeEq}; use zeroize::{Zeroize, ZeroizeOnDrop}; -use crate::plaintext::{depth, Encrypt, Int, Logical, Physical, Sink, TimeUnit}; +use crate::plaintext::{depth, Encrypt, FixedType, Int, Logical, Physical, Sink, TimeUnit, Width}; use crate::typed::FromScalar; /// A UTC instant that is never a leap second, so it can always be encrypted. @@ -110,6 +110,7 @@ impl From for DateTime { /// sake. impl Encrypt for Timestamp { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = <&Timestamp as Encrypt>::FIXED; fn visit(self, sink: S) -> Result { (&self).visit(sink) } @@ -117,6 +118,8 @@ impl Encrypt for Timestamp { impl Encrypt for &Timestamp { const DEPTH: Option = depth::SCALAR; + const FIXED: Option = + FixedType::new(Some(Logical::Timestamp(TimeUnit::Nanos)), Width::I128); fn visit(self, sink: S) -> Result { sink.scalar( Some(Logical::Timestamp(TimeUnit::Nanos)), diff --git a/packages/aead/src/typed.rs b/packages/aead/src/typed.rs index 6f7532e3..1327d159 100644 --- a/packages/aead/src/typed.rs +++ b/packages/aead/src/typed.rs @@ -9,7 +9,10 @@ //! Integer targets read the value, not the width: a `u32` accepts an `i8` //! leaf holding 34 and refuses one holding -1. The width is a host-fidelity //! hint. `Option` reads null and `undefined` as `None` and defers -//! anything else to `T`. `HashMap` and `BTreeMap` +//! anything else to `T`. A typed null (see [`Sink::typed_null`]) is `None` +//! for an `Option` whose `T` reads values of its type +//! ([`Decrypt::reads_null`]); any other target refuses it as the wrong +//! kind, and never reads it as a zero. `HashMap` and `BTreeMap` //! read a map, each value through `T`'s sink. //! //! `Protected` is a target for every `T` that implements @@ -39,7 +42,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroize; use crate::plaintext::{ - Encrypt, Error, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, + Encrypt, Error, FixedType, Float, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; use crate::seal::{AsyncCipher, Cipher, Node}; use crate::walk::{decrypt_into, decrypt_into_async, DecryptError}; @@ -96,7 +99,17 @@ impl Error for DecodeError { /// A type that can be decrypted: it names the sink that builds it. pub trait Decrypt: Sized { type Sink: Sink; + fn sink() -> Self::Sink; + + /// Whether an `Option` reads a typed null of `ty` as `None`: for + /// a scalar target, whether it reads a value of `ty` (see + /// [`FromScalar::FIXED`]), so a `None` reads back wherever a `Some` + /// does, a widened field included. `false`, the default, for a target + /// that is not a scalar, whose `Option` then refuses every typed null. + fn reads_null(_: FixedType) -> bool { + false + } } /// Decrypt into `T`. @@ -132,6 +145,17 @@ where pub trait FromScalar: Sized { /// What the type is called in a refusal. const EXPECTED: &'static str; + /// A fixed type whose typed null an `Option` reads as `None` + /// even though `from_scalar` refuses that type's zero. + /// + /// An `Option` reads a typed null of any type whose zero + /// `from_scalar` accepts ([`Width::zero`](crate::Width::zero)): every + /// type it reads a value of, for the built-in targets, so a `u64` + /// field reads a `u32` field's `None` as it reads its values. Only a + /// target that refuses zero needs this, to read its own `None`: a + /// fieldless enum with no variant at zero sets it to its + /// [`Encrypt::FIXED`]. + const FIXED: Option = None; fn from_scalar(ty: Option, value: Physical<'_>) -> Option; /// Decode straight into `Protected`: the seam `Protected`'s target @@ -160,6 +184,11 @@ impl Sink for ScalarSink { T::from_scalar(ty, v).ok_or(DecodeError::Expected(T::EXPECTED)) } + /// An absent value is not a value, whatever its type. + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected(T::EXPECTED)) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected(T::EXPECTED)) } @@ -178,6 +207,10 @@ impl Decrypt for T { fn sink() -> ScalarSink { ScalarSink(PhantomData) } + + fn reads_null(ty: FixedType) -> bool { + T::FIXED == Some(ty) || T::from_scalar(ty.logical(), ty.width().zero()).is_some() + } } /// A sink nothing is ever walked into: the element and entry type of a @@ -195,6 +228,10 @@ impl Sink for Nothing { match self {} } + fn typed_null(self, _: FixedType) -> Result<(), DecodeError> { + match self {} + } + fn seq(self, _: Option) -> Result, DecodeError> { match self {} } @@ -463,6 +500,11 @@ impl> Sink for Forward { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), DecodeError> { + self.1.put(self.0.typed_null(ty)?); + Ok(()) + } + fn seq(self, len: Option) -> Result { Ok(ForwardEnd(self.0.seq(len)?, self.1, PhantomData)) } @@ -541,6 +583,13 @@ impl> Sink for Either { } } + fn typed_null(self, ty: FixedType) -> Result { + match self { + Either::Left(l) => l.typed_null(ty).map(Either::Left), + Either::Right(r) => r.typed_null(ty).map(Either::Right), + } + } + fn seq(self, len: Option) -> Result { match self { Either::Left(l) => l.seq(len).map(Either::Left), @@ -633,6 +682,10 @@ impl> Sink for Clear { Err(DecodeError::Expected("passthrough")) } + fn typed_null(self, _: FixedType) -> Result<(), DecodeError> { + Err(DecodeError::Expected("passthrough")) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected("passthrough")) } @@ -676,6 +729,10 @@ impl, W: Rewrap> Sink for Wrapped { self.0.scalar(ty, v).and_then(W::rewrap) } + fn typed_null(self, ty: FixedType) -> Result { + self.0.typed_null(ty).and_then(W::rewrap) + } + fn seq(self, len: Option) -> Result { Ok(WrappedEnd(self.0.seq(len)?, PhantomData)) } @@ -739,6 +796,10 @@ impl Sink for List { Err(DecodeError::Expected("sequence")) } + fn typed_null(self, _: FixedType) -> Result, DecodeError> { + Err(DecodeError::Expected("sequence")) + } + fn seq(self, len: Option) -> Result, DecodeError> { Ok(ListSeq(Vec::with_capacity(len.unwrap_or(0).min(1024)))) } @@ -798,6 +859,10 @@ macro_rules! dict_target { Err(DecodeError::Expected("map")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("map")) + } + fn seq(self, _: Option) -> Result { Err(DecodeError::Expected("map")) } @@ -841,6 +906,9 @@ macro_rules! dict_target { dict_target!(HashMap, BTreeMap); /// `Option`: null and `undefined` are `None`; anything else is `T`. +/// A typed null is `None` if `T` reads values of its type +/// ([`Decrypt::reads_null`]), and otherwise refused by `T`'s sink as the +/// wrong kind. /// /// Since `None` is written as null, `Option>` cannot tell /// `Some(None)` from `None`: both read back as `None`. @@ -859,6 +927,13 @@ impl Sink for Optional { } } + fn typed_null(self, ty: FixedType) -> Result, DecodeError> { + if T::reads_null(ty) { + return Ok(None); + } + self.0.typed_null(ty).map(Some) + } + fn seq(self, len: Option) -> Result { Ok(Some_(self.0.seq(len)?, PhantomData)) } @@ -914,6 +989,10 @@ impl Decrypt for Option { fn sink() -> Optional { Optional(T::sink()) } + + fn reads_null(ty: FixedType) -> bool { + T::reads_null(ty) + } } /// A type that can be decrypted straight into `Protected`: it names @@ -934,6 +1013,10 @@ impl Decrypt for Protected { fn sink() -> T::ProtectedSink { T::protected_sink() } + + fn reads_null(ty: FixedType) -> bool { + T::reads_null(ty) + } } /// Any scalar target that can be wiped, read into `Protected`. A heap @@ -962,6 +1045,10 @@ impl Sink for ScalarProtected { T::from_scalar_protected(ty, v).ok_or(DecodeError::Expected(T::EXPECTED)) } + fn typed_null(self, _: FixedType) -> Result, DecodeError> { + Err(DecodeError::Expected(T::EXPECTED)) + } + fn seq(self, _: Option) -> Result { Err(DecodeError::Expected(T::EXPECTED)) } @@ -1001,6 +1088,10 @@ impl Sink for ProtectedList { Err(DecodeError::Expected("sequence")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("sequence")) + } + fn seq(self, len: Option) -> Result, DecodeError> { let items = Vec::with_capacity(len.unwrap_or(0).min(1024)); Ok(ProtectedListSeq(Protected::new(items))) @@ -1082,6 +1173,13 @@ impl Sink for ProtectedOptional { } } + fn typed_null(self, ty: FixedType) -> Result { + if T::reads_null(ty) { + return Ok(Protected::new(None)); + } + self.0.typed_null(ty).map(some) + } + fn seq(self, len: Option) -> Result { Ok(SomeProtected(self.0.seq(len)?, PhantomData)) } @@ -1124,6 +1222,10 @@ where self.0.scalar(ty, v).map(Protected::new) } + fn typed_null(self, ty: FixedType) -> Result { + self.0.typed_null(ty).map(Protected::new) + } + fn seq(self, len: Option) -> Result { Ok(WrapEnd(self.0.seq(len)?)) } @@ -1231,7 +1333,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::plaintext::{Int, Raw}; + use crate::plaintext::{Int, Raw, Width}; use crate::seal::mock::{Mock, Remote}; use crate::{encrypt, encrypt_async}; use futures_executor::block_on; @@ -1329,6 +1431,132 @@ mod tests { ); } + /// A typed null as a host would describe it. + struct Null(FixedType); + + impl Encrypt for Null { + fn visit(self, sink: S) -> Result { + sink.typed_null(self.0) + } + } + + fn plain(width: Width) -> FixedType { + FixedType::plain(width).expect("fixed") + } + + #[test] + fn a_typed_null_reads_as_none_for_an_option_of_a_type_that_reads_its_values() { + // Sealed by `Option`'s own `None`, and read back by it. + assert_eq!(round_trip::>(None::).expect("u32"), None); + assert_eq!(round_trip::>(None::).expect("i8"), None); + assert_eq!(round_trip::>(None::).expect("f32"), None); + assert_eq!( + round_trip::>>(None::).expect("nested"), + None + ); + assert_eq!( + round_trip::>>(vec![None, Some(3u16)]).expect("elements"), + [None, Some(3)] + ); + // A widened field reads its old `None`s as it reads its old values. + assert_eq!(round_trip::>(None::).expect("wider"), None); + assert_eq!( + round_trip::>(None::).expect("narrower"), + None + ); + assert_eq!(round_trip::>(None::).expect("f64"), None); + } + + #[test] + fn a_typed_null_of_a_type_the_target_does_not_read_is_the_wrong_kind() { + let u32_null = || Null(plain(Width::U32)); + assert_eq!(expected(round_trip::>(u32_null())), "f32"); + assert_eq!(expected(round_trip::>(u32_null())), "string"); + assert_eq!(expected(round_trip::>>(u32_null())), "bytes"); + assert_eq!( + expected(round_trip::>>(u32_null())), + "sequence" + ); + assert_eq!(expected(round_trip::>(None::)), "f32"); + assert_eq!(expected(round_trip::>(None::)), "u32"); + // A logical type over the same int is not that int. + let date = FixedType::new(Some(Logical::Date), Width::I32).expect("fixed"); + assert_eq!(expected(round_trip::>(Null(date))), "i32"); + } + + #[test] + fn a_typed_null_is_not_a_value() { + assert_eq!(expected(round_trip::(None::)), "u32"); + assert_eq!(expected(round_trip::(None::)), "f64"); + assert_eq!(expected(round_trip::>(None::)), "sequence"); + assert_eq!( + expected(round_trip::>(None::)), + "map" + ); + assert_eq!(expected(round_trip::>(None::)), "u32"); + } + + #[test] + fn the_plain_null_still_reads_as_none_for_any_option() { + // What every writer sealed before typed nulls, and still seals for + // a type with no fixed width. + assert_eq!( + round_trip::>(Raw(None, Physical::Null)).expect("u32"), + None + ); + assert_eq!( + round_trip::>>(Raw(None, Physical::Null)) + .expect("protected") + .risky_ref(), + &None + ); + } + + #[test] + fn a_protected_option_reads_its_typed_null() { + assert_eq!( + round_trip::>>(None::) + .expect("none") + .risky_ref(), + &None + ); + assert_eq!( + expected(round_trip::>>(None::)), + "f32" + ); + } + + /// What every built-in source seals for `None`, its target reads back. + #[test] + fn every_target_reads_its_own_sources_none() { + fn own() { + let ty = ::FIXED.expect("fixed width"); + assert!(::reads_null(ty), "{ty:?}"); + assert!( as Decrypt>::reads_null(ty), "{ty:?}"); + } + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + own::(); + // And a type with no fixed width reads none. + assert!(!::reads_null(plain(Width::U32))); + assert!(! as Decrypt>::reads_null(plain(Width::U32))); + assert!(!::reads_null(plain(Width::U8))); + // `Option` and `Protected` read whatever their inner target reads. + assert!(! as Decrypt>::reads_null(plain(Width::U32))); + assert!( as Decrypt>::reads_null(plain(Width::U32))); + assert!(! as Decrypt>::reads_null(plain( + Width::U32 + ))); + } + #[test] fn option_reads_null_and_undefined_as_none() { assert_eq!(round_trip::>(None::).expect("none"), None); @@ -1497,6 +1725,7 @@ mod tests { }; let mut held: Option = None; refused(Clear::::new(Slot(&mut held)).scalar(None, Physical::Null)); + refused(Clear::::new(Slot(&mut held)).typed_null(plain(Width::U16))); refused(Clear::::new(Slot(&mut held)).seq(None).map(|_| ())); refused(Clear::::new(Slot(&mut held)).map(None).map(|_| ())); assert!(matches!( @@ -1912,6 +2141,10 @@ mod tests { Err(DecodeError::Expected("point")) } + fn typed_null(self, _: FixedType) -> Result { + Err(DecodeError::Expected("point")) + } + fn seq(self, _: Option) -> Result, DecodeError> { Err(DecodeError::Expected("point")) } diff --git a/packages/aead/src/walk.rs b/packages/aead/src/walk.rs index 31cbe226..50f91960 100644 --- a/packages/aead/src/walk.rs +++ b/packages/aead/src/walk.rs @@ -19,7 +19,7 @@ use std::collections::HashSet; use vitaminc_protected::{Controlled, Protected}; -use crate::leaf; +use crate::leaf::{self, Item}; use crate::plaintext::{Error, MapSink, SeqSink, Sink, SourceError}; use crate::seal::{open_all, open_all_async, AsyncCipher, Cipher, Node, OpenError, Opened}; use crate::{CipherText, IntoAad}; @@ -139,9 +139,12 @@ pub fn walk(node: Node, sink: S) -> Result { - let (ty, v) = - leaf::read_leaf(plaintext.risky_ref()).map_err(|_| DecryptError::Malformed)?; - Ok(sink.scalar(ty, v)?) + let item = + leaf::read_leaf_item(plaintext.risky_ref()).map_err(|_| DecryptError::Malformed)?; + Ok(match item { + Item::Scalar(ty, v) => sink.scalar(ty, v)?, + Item::TypedNull(ty) => sink.typed_null(ty)?, + }) // `plaintext` drops and wipes here. } CipherText::Sequence(items) => walk_seq(items, sink), diff --git a/packages/aead/tests/derive_sink.rs b/packages/aead/tests/derive_sink.rs index b02f2432..d08a2b4a 100644 --- a/packages/aead/tests/derive_sink.rs +++ b/packages/aead/tests/derive_sink.rs @@ -6,8 +6,8 @@ use std::collections::BTreeMap; use vitaminc_aead::{ - decrypt, encrypt, Bytes, Context, DecodeError, Decrypt, DecryptError, Encrypt, Int, IntoAad, - Logical, MapSink, Physical, Raw, SeqSink, Sink, SourceError, + decrypt, encrypt, Bytes, Context, DecodeError, Decrypt, DecryptError, Encrypt, FixedType, Int, + IntoAad, Logical, MapSink, Physical, Raw, SeqSink, Sink, SourceError, }; use vitaminc_protected::{Controlled, Protected}; use zeroize::ZeroizeOnDrop; @@ -18,6 +18,7 @@ use zeroize::ZeroizeOnDrop; #[derive(Debug, Clone, PartialEq)] enum Event { Scalar(Option, String), + TypedNull(FixedType), Seq(Option), Element, EndSeq, @@ -57,6 +58,11 @@ impl Sink for Record<'_> { Ok(()) } + fn typed_null(self, ty: FixedType) -> Result<(), RecordError> { + self.0.push(Event::TypedNull(ty)); + Ok(()) + } + fn seq(self, len: Option) -> Result { self.0.push(Event::Seq(len)); Ok(self) @@ -834,6 +840,10 @@ impl Sink for StopAt { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), RecordError> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/tests/protected_vec_wipe.rs b/packages/aead/tests/protected_vec_wipe.rs index b9ed8dc3..64307c9c 100644 --- a/packages/aead/tests/protected_vec_wipe.rs +++ b/packages/aead/tests/protected_vec_wipe.rs @@ -11,7 +11,7 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use vitaminc_aead::plaintext::{ - Encrypt, Error, Logical, MapSink, Physical, SeqSink, Sink, SourceError, + Encrypt, Error, FixedType, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; use vitaminc_protected::Protected; @@ -70,6 +70,10 @@ impl Sink for Quiet { Ok(()) } + fn typed_null(self, _: FixedType) -> Result<(), Stopped> { + Ok(()) + } + fn seq(self, _: Option) -> Result { Ok(self) } diff --git a/packages/aead/tests/typed_null.rs b/packages/aead/tests/typed_null.rs new file mode 100644 index 00000000..cc3ca0c1 --- /dev/null +++ b/packages/aead/tests/typed_null.rs @@ -0,0 +1,289 @@ +//! Typed nulls end to end: a sealed `None` of a fixed-width type is as long +//! as a sealed `Some`, so ciphertext length does not show which values are +//! absent, and it reads back only as an `Option` of the same type. Sealing +//! goes through a cipher that keeps the plaintext in the clear, so a +//! leaf's length is its plaintext's; AES-GCM adds the same nonce and tag +//! to every leaf. + +use vitaminc_aead::{ + decrypt, decrypt_to_frame, encrypt, tags, to_frame, CipherText, Context, DecodeError, Decrypt, + DecryptError, Encrypt, FixedType, Frame, IntoAad, Node, Width, +}; +use vitaminc_protected::{Controlled, Protected}; + +#[derive(Debug)] +struct Leaf { + aad: Vec, + plaintext: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Refused; + +struct Mock; + +impl vitaminc_aead::seal::Cipher for Mock { + type Leaf = Leaf; + type Error = Refused; + + fn seal(&self, plaintext: Protected>, aad: &Context<'_>) -> Result { + Ok(Leaf { + aad: aad.as_bytes().to_vec(), + plaintext: plaintext.risky_unwrap(), + }) + } + + fn open(&self, leaf: Leaf, aad: &Context<'_>) -> Result>, Refused> { + if leaf.aad != aad.as_bytes() { + return Err(Refused); + } + Ok(Protected::new(leaf.plaintext)) + } +} + +/// The plaintext of the one leaf `value` seals as. +fn sealed(value: P) -> Vec { + match encrypt(value, &Mock, "ctx").expect("encrypts") { + CipherText::Single(leaf) => leaf.plaintext, + other => panic!("not one leaf: {other:?}"), + } +} + +/// `None` and `Some(value)` seal to leaves of the same length, the `None` +/// as a typed null, and the `None` reads back as `None`. +fn hides_absence(value: T) +where + T: Encrypt + Decrypt + PartialEq + core::fmt::Debug, +{ + let none = sealed(None::); + let some = sealed(Some(value)); + assert_eq!(none.first(), Some(&tags::TYPED_NULL), "{none:?}"); + assert_eq!(none.len(), some.len(), "{none:?} vs {some:?}"); + let read: Option = decrypt( + &Mock, + encrypt(None::, &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, None); +} + +#[test] +fn every_fixed_width_primitive_hides_absence() { + hides_absence(-1i8); + hides_absence(-1i16); + hides_absence(-1i32); + hides_absence(-1i64); + hides_absence(-1i128); + hides_absence(1u16); + hides_absence(1u32); + hides_absence(1u64); + hides_absence(1u128); + hides_absence(1.5f32); + hides_absence(1.5f64); +} + +/// A bool's leaf is the tag alone, as short as the plain null, so its +/// `None` stays the plain null and hides nothing it does not already. +#[test] +fn a_bool_none_is_the_plain_null_and_as_long_as_a_bool() { + assert_eq!(sealed(None::), [tags::NULL]); + assert_eq!(sealed(Some(true)).len(), 1); +} + +/// Text and bytes are variable width: out of scope, and unchanged. +#[test] +fn a_variable_width_none_is_the_plain_null() { + assert_eq!(sealed(None::), [tags::NULL]); + assert_eq!(sealed(None::>), [tags::NULL]); +} + +#[cfg(feature = "chrono")] +#[test] +fn chrono_types_and_timestamp_hide_absence() { + use chrono::{DateTime, NaiveDate, Utc}; + hides_absence(NaiveDate::from_ymd_opt(1999, 12, 31).expect("date")); + hides_absence(DateTime::::from_timestamp(1_700_000_000, 5).expect("instant")); + hides_absence( + vitaminc_aead::Timestamp::new(DateTime::::from_timestamp(7, 0).expect("instant")) + .expect("not a leap second"), + ); + // A `Timestamp` and a `DateTime` are the one leaf, absent or not. + assert_eq!( + sealed(None::), + sealed(None::>) + ); +} + +#[cfg(feature = "rust_decimal")] +#[test] +fn a_decimal_hides_absence_whatever_its_scale() { + use rust_decimal::Decimal; + for s in ["0", "-1234.5678", "79228162514264337593543950335"] { + hides_absence(s.parse::().expect("decimal")); + } +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone, Copy)] +enum Status { + Active, + Suspended = 7, +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone, Copy)] +struct Age(u32); + +#[test] +fn a_derived_enum_and_newtype_hide_absence() { + hides_absence(Status::Suspended); + hides_absence(Age(42)); + let u32 = FixedType::plain(Width::U32); + assert_eq!(::FIXED, u32); + assert_eq!(<&Age as Encrypt>::FIXED, u32); + assert_eq!(::FIXED, u32); + assert!(::reads_null(u32.expect("fixed"))); + assert!(::reads_null(u32.expect("fixed"))); + // An absent enum is not one of its variants. + let read: Result = decrypt( + &Mock, + encrypt(None::, &Mock, "ctx").expect("encrypts"), + "ctx", + ); + assert!(matches!( + read, + Err(DecryptError::Sink(DecodeError::Expected("Status"))) + )); +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone)] +struct Profile { + name: String, + born: Option, + score: Option, + status: Option, +} + +/// The lengths of a sealed record's leaves, entry by entry. +fn leaf_lengths(node: Node) -> Vec<(String, usize)> { + match node { + CipherText::Map(entries) => entries + .into_iter() + .map(|(key, value)| match value { + CipherText::Single(leaf) => (key, leaf.plaintext.len()), + other => panic!("{key}: not one leaf: {other:?}"), + }) + .collect(), + other => panic!("not a map: {other:?}"), + } +} + +#[test] +fn a_struct_with_optional_fields_seals_the_same_lengths_either_way() { + let present = Profile { + name: "Ada".into(), + born: Some(1815), + score: Some(9.5), + status: Some(Status::Active), + }; + let absent = Profile { + name: "Ada".into(), + born: None, + score: None, + status: None, + }; + let lengths = |p: &Profile| leaf_lengths(encrypt(p.clone(), &Mock, "ctx").expect("encrypts")); + assert_eq!(lengths(&present), lengths(&absent)); + for p in [present, absent] { + let read: Profile = decrypt( + &Mock, + encrypt(p.clone(), &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, p); + } +} + +/// A frame a host sends may carry a typed null, which is sealed padded. A +/// frame written here is plaintext for a host, so it carries the plain +/// null every host decoder reads: padding hides nothing unsealed. +#[test] +fn a_frame_reads_a_typed_null_and_writes_the_plain_null() { + let from_host = [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]; + let parsed = Frame::parse(&from_host).expect("parses"); + assert_eq!(sealed(parsed), from_host); + + assert_eq!( + to_frame(None::).expect("frames").risky_ref(), + &[tags::NULL] + ); + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let back = decrypt_to_frame(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(back.risky_ref(), &[tags::NULL]); +} + +#[derive(Encrypt, Decrypt, Debug, PartialEq, Clone)] +struct Row { + #[aead(passthrough)] + rank: Option, + secret: Option, +} + +/// A passthrough field is stored in the clear for other readers, so its +/// absence is the plain null, unchanged; only the sealed field pads. +#[test] +fn a_passthrough_none_stays_the_plain_null() { + let row = Row { + rank: None, + secret: None, + }; + let CipherText::Map(entries) = encrypt(row.clone(), &Mock, "ctx").expect("encrypts") else { + panic!("not a map"); + }; + for (key, value) in entries { + match (key.as_str(), value) { + ("rank", CipherText::Passthrough(frame)) => assert_eq!(frame.0, [tags::NULL]), + ("secret", CipherText::Single(leaf)) => { + assert_eq!(leaf.plaintext, [tags::TYPED_NULL, tags::UINT32, 0, 0, 0]) + } + (key, other) => panic!("{key}: {other:?}"), + } + } + let read: Row = decrypt( + &Mock, + encrypt(row.clone(), &Mock, "ctx").expect("encrypts"), + "ctx", + ) + .expect("decrypts"); + assert_eq!(read, row); +} + +#[cfg(feature = "uuid")] +#[test] +fn a_uuid_hides_absence() { + hides_absence(uuid::Uuid::from_bytes([0xAB; 16])); +} + +/// A field widened after data was written reads its old `None`s as it +/// reads its old values. +#[test] +fn a_widened_field_reads_its_old_nones() { + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let read: Option = decrypt(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(read, None); + let node = encrypt(None::, &Mock, "ctx").expect("encrypts"); + let read: Option = decrypt(&Mock, node, "ctx").expect("decrypts"); + assert_eq!(read, None); +} + +/// A typed null whose padding is not zero is not an absence: a writer with +/// the key cannot pass other plaintext off as one. +#[test] +fn a_typed_null_with_nonzero_padding_is_refused() { + let forged = Leaf { + aad: "ctx".into_aad().as_bytes().to_vec(), + plaintext: vec![tags::TYPED_NULL, tags::UINT32, 0, 0, 1], + }; + let read: Result, _> = decrypt(&Mock, CipherText::Single(forged), "ctx"); + assert!(matches!(read, Err(DecryptError::Malformed)), "{read:?}"); +} diff --git a/packages/encrypt/src/cipher_static.rs b/packages/encrypt/src/cipher_static.rs index 1aa64962..2f20749f 100644 --- a/packages/encrypt/src/cipher_static.rs +++ b/packages/encrypt/src/cipher_static.rs @@ -8,9 +8,10 @@ use vitaminc_aead::{tags, IntoAad, Unspecified}; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; -/// What an [`Absent`] seals: the null leaf, as `None` is everywhere else -/// (ADR 0001). No separate absence derivation is needed, because the leaf -/// itself says what it is. +/// What an [`Absent`] seals: the null leaf, as a `None` of bytes is +/// everywhere else (ADR 0001). No separate absence derivation is needed, +/// because the leaf itself says what it is. An [`Encrypted`] holds bytes, +/// which are variable width, so its absence is not padded. const NULL_LEAF: [u8; 1] = [tags::NULL]; /// What an [`Encrypted`] seals: the caller's bytes as a tagged bytes leaf, @@ -71,8 +72,8 @@ impl StaticCipher for &Aes256Cipher { A: IntoAad<'a>, { // The marker is a sealed null leaf under the slot's own AAD: the same - // bytes `encrypt` seals for a `None` there, so the static and the - // sink-based paths agree on what absence is. An `Encrypted` is a + // bytes `encrypt` seals for a `None::>` there, so the static + // and the sink-based paths agree on what an absent byte string is. An `Encrypted` is a // tagged bytes leaf, so neither can stand in for the other: // `verify_absent` requires the null leaf and `open_encrypted` the // bytes tag. @@ -490,7 +491,7 @@ mod test { } // An `Absent` is a sealed null leaf, which is what `encrypt` seals for - // a `None` too, so the two paths agree on absence. An `Encrypted` is a + // a `None::>` too, so the two paths agree on absence. An `Encrypted` is a // tagged bytes leaf, which is what `encrypt` seals for a `Vec`. #[test] @@ -500,8 +501,10 @@ mod test { let absent = (&cipher).encrypt_none(aad).unwrap(); let plaintext = open_local(&cipher, absent.into_local(), aad).unwrap(); assert_eq!(plaintext.risky_ref(), &NULL_LEAF); - // And what the sink-based path seals for `None` verifies as absent. - let node = vitaminc_aead::encrypt(None::, &cipher, aad).unwrap(); + // And what the sink-based path seals for the `None` of the bytes an + // `Encrypted` holds verifies as absent. (A fixed-width `None` seals + // as a typed null instead, which is not an `Absent`.) + let node = vitaminc_aead::encrypt(None::>, &cipher, aad).unwrap(); let vitaminc_aead::CipherText::Single(leaf) = node else { panic!("a None is one leaf"); }; diff --git a/packages/encrypt/tests/sink_cipher.rs b/packages/encrypt/tests/sink_cipher.rs index d25b1ce2..0241317c 100644 --- a/packages/encrypt/tests/sink_cipher.rs +++ b/packages/encrypt/tests/sink_cipher.rs @@ -153,3 +153,49 @@ mod given_protected_bytes { assert_eq!(got.risky_unwrap(), vec![0xDE, 0xAD, 0xBE, 0xEF]); } } + +mod given_an_absent_fixed_width_value { + use super::*; + + /// The ciphertext length of the one leaf `value` seals as. + fn sealed_len(value: P) -> usize { + match encrypt(value, &cipher(), "ctx").expect("encrypt") { + CipherText::Single(leaf) => leaf.as_ref().len(), + other => panic!("not one leaf: {other:?}"), + } + } + + fn same_length(value: T) { + assert_eq!( + sealed_len(None::), + sealed_len(Some(value)), + "{}", + core::any::type_name::() + ); + } + + #[test] + fn seals_to_the_length_of_a_present_one() { + same_length(-1i8); + same_length(-1i16); + same_length(-1i32); + same_length(-1i64); + same_length(-1i128); + same_length(1u16); + same_length(1u32); + same_length(1u64); + same_length(1u128); + same_length(1.5f32); + same_length(1.5f64); + // A bool's plain null is already as long as a bool. + same_length(true); + } + + #[test] + fn round_trips_through_aes_as_none() { + let cipher = cipher(); + let node = encrypt(None::, &cipher, "ctx").expect("encrypt"); + let got: Option = decrypt(&cipher, node, "ctx").expect("decrypt"); + assert_eq!(got, None); + } +} diff --git a/packages/normalize/src/terms.rs b/packages/normalize/src/terms.rs index a2dd6ae3..eb45fa06 100644 --- a/packages/normalize/src/terms.rs +++ b/packages/normalize/src/terms.rs @@ -20,8 +20,8 @@ use vitaminc_aead::plaintext::{Error, Path, Step}; use vitaminc_aead::seal::{seal_all, seal_all_async, AsyncCipher, Cipher}; use vitaminc_aead::shape::{self, Locate, MapRules, SeqRules, Settle}; use vitaminc_aead::{ - Encrypt, EncryptError, EncryptSink, IntoAad, Logical, MapSink, Node, Pending, Physical, - Refusal, SeqSink, Sink, SourceError, Tee, + Encrypt, EncryptError, EncryptSink, FixedType, IntoAad, Logical, MapSink, Node, Pending, + Physical, Refusal, SeqSink, Sink, SourceError, Tee, }; use vitaminc_protected::OpaqueDebug; @@ -444,6 +444,13 @@ impl<'t, E: Error, N: Normalize> Sink for Terms<'t, E, N> { Ok(()) } + /// Indexed as a plain null, which has no term: whether, and how, an + /// absent value gets one is still to be decided + /// (cipherstash/vitaminc#493). + fn typed_null(self, _: FixedType) -> Result<(), E> { + self.scalar(None, Physical::Null) + } + fn seq(self, _: Option) -> Result { let rules = match self.rules { Some(node) => { diff --git a/packages/normalize/tests/normalizer.rs b/packages/normalize/tests/normalizer.rs index 6abdae34..774e8379 100644 --- a/packages/normalize/tests/normalizer.rs +++ b/packages/normalize/tests/normalizer.rs @@ -96,3 +96,22 @@ fn two_normalizers_share_one_walk() { [".a"] ); } + +/// A fixed-width `None` reaches the sink as a typed null. It is indexed +/// as a plain null, which has no term, so an absent field gains no term +/// and does not stop the walk (cipherstash/vitaminc#493). +#[test] +fn a_fixed_width_none_has_no_term_and_does_not_stop_the_walk() { + let mut doc = BTreeMap::new(); + doc.insert("gone", None::); + doc.insert("here", Some(5i32)); + let found = terms(doc).expect("a typed null does not stop the walk"); + assert_eq!( + found.iter().map(|t| render(&t.path)).collect::>(), + [".here"], + "only the present field has a term" + ); + let by_name = terms_by(LowByte, BTreeMap::from([("gone", None::)])) + .expect("a typed null does not stop a normalizer of the caller's own"); + assert!(by_name.is_empty(), "a typed null has no term under LowByte"); +} diff --git a/packages/ore/src/scheme.rs b/packages/ore/src/scheme.rs index 43323519..502e909d 100644 --- a/packages/ore/src/scheme.rs +++ b/packages/ore/src/scheme.rs @@ -15,7 +15,7 @@ use core::fmt; use std::sync::OnceLock; use vitaminc_aead::plaintext::{self, SourceError}; -use vitaminc_aead::{Encrypt, Logical, MapSink, Physical, SeqSink, Sink}; +use vitaminc_aead::{Encrypt, FixedType, Logical, MapSink, Physical, SeqSink, Sink}; use vitaminc_context::{Context, IntoContext}; use vitaminc_normalize::NormalizeError; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; @@ -790,6 +790,12 @@ impl Sink for Scalar { order(self.0, ty, value).map_err(ScalarError::Term) } + /// The same as a plain null for now: whether, and how, an absent value + /// gets an order term is still to be decided (cipherstash/vitaminc#493). + fn typed_null(self, _: FixedType) -> Result { + self.scalar(None, Physical::Null) + } + fn seq(self, _: Option) -> Result { Err(ScalarError::NotScalar) } @@ -962,4 +968,24 @@ mod tests { Err(ScalarError::Term(NormalizeError::NoDomain)) )); } + + /// A fixed-width `None` reaches the sink as a typed null, which gets + /// no order term, the same as a plain null (cipherstash/vitaminc#493). + #[test] + fn the_scalar_sink_gives_a_typed_null_no_term_like_a_plain_null() { + assert!( + matches!( + None::.visit(Scalar(OrderTarget::U32)), + Err(ScalarError::Term(NormalizeError::NoDomain)) + ), + "a typed null must have no order term" + ); + assert!( + matches!( + None::.visit(Scalar(OrderTarget::Text)), + Err(ScalarError::Term(NormalizeError::NoDomain)) + ), + "the target does not give a typed null a term either" + ); + } }