Skip to content

Commit a7a4e15

Browse files
committed
feat(stack-encrypt): ValueFrame compares in constant time, held in an Equatable
Three doc examples compared two frames by reading both out with risky_ref and comparing the slices with ordinary ==. ValueFrame now holds its frame in an Equatable<Protected<Vec<u8>>>, and implements PartialEq, so the examples write assert_eq!(opened, frame). Two frames are equal when their bytes are: 34u32 and 34u64 are not, nor are two maps with their keys in a different order. Frames of different lengths are unequal at once, which reveals only their lengths. Equatable would compare the frame itself, but vitaminc-protected 0.9.1 has no ConstantTimeEq for Vec<u8> (cipherstash/vitaminc#602). Until it does, PartialEq is written by hand and compares the bytes through TimingSafeEq for [u8], which uses subtle. Once #602 is released, the impl becomes #[derive(PartialEq)]. into_inner keeps its signature: the Vec moves out of the Equatable into a Protected with no copy.
1 parent 51e19eb commit a7a4e15

4 files changed

Lines changed: 89 additions & 10 deletions

File tree

‎packages/stack-encrypt/CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9494
`Scalar` and the type implements `Zeroize`.
9595
- `u8` alone, and a `Vec` of any type other than `u8`, still do not encrypt
9696
into one `Ciphertext`.
97+
- **`ValueFrame` implements `PartialEq`, comparing frames in constant time.**
98+
Two frames are equal when their bytes are. So `34u32` and `34u64` are not
99+
equal. Two maps with their keys in a different order are not equal either. Frames
100+
of different lengths are unequal at once. The frame is now held in an
101+
`Equatable<Protected<Vec<u8>>>`. Until vitaminc-protected implements
102+
`ConstantTimeEq` for `Vec<u8>` (cipherstash/vitaminc#602), the bytes are
103+
compared through `TimingSafeEq`.
97104

98105
## [0.3.0] - 2026-10-27
99106

‎packages/stack-encrypt/src/dynamic/kind.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ fn scalar_of(kind: ValueKind) -> Option<(Option<Logical>, Physical<'static>)> {
173173
/// // A JavaScript number is a float; against a `uint64` field it is 34.
174174
/// let value = read(ValueKind::UInt64, 34.0f64)?;
175175
/// assert_eq!(value.kind(), Some(ValueKind::UInt64));
176-
/// assert_eq!(value.risky_ref(), ValueFrame::of(34u64)?.risky_ref());
176+
/// assert_eq!(value, ValueFrame::of(34u64)?);
177177
/// assert!(read(ValueKind::UInt64, 34.5f64).is_err());
178178
/// // Against an `int8` field, 34 is the 8-bit integer; 300 does not fit.
179179
/// assert_eq!(read(ValueKind::Int8, 34i64)?.kind(), Some(ValueKind::Int8));

‎packages/stack-encrypt/src/dynamic/record.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1617,7 +1617,7 @@ fn plan_refusal(error: &vitaminc_aead_frame::serde::Error, frame: &[u8]) -> Erro
16171617
/// // Only the ciphertext comes back, as the record's frame; the term is
16181618
/// // one-way.
16191619
/// let opened = record::decrypt(Scope::Client(&cipher), sealed, &plan, None)?.await?;
1620-
/// assert_eq!(opened.risky_ref(), frame.risky_ref());
1620+
/// assert_eq!(opened, frame);
16211621
/// # Ok::<(), Box<dyn std::error::Error>>(())
16221622
/// # }).unwrap();
16231623
/// ```

‎packages/stack-encrypt/src/dynamic/value_frame.rs‎

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
//!
2323
//! // The same value, as a binding would hand it over.
2424
//! let from_host = ValueFrame::from_bytes(frame.as_protected_ref())?;
25-
//! assert_eq!(from_host.risky_ref(), frame.risky_ref());
25+
//! assert_eq!(from_host, frame);
2626
//! # Ok(())
2727
//! # }
2828
//! ```
@@ -39,7 +39,8 @@ use vitaminc_aead::plaintext::{
3939
};
4040
use vitaminc_aead::{DecodeError, Decrypt, Encrypt};
4141
use vitaminc_protected::{
42-
AsProtectedRef, Buffer, Controlled, OpaqueDebug, Protected, ProtectedRef,
42+
AsProtectedRef, Buffer, Controlled, Equatable, OpaqueDebug, Protected, ProtectedRef,
43+
TimingSafeEq,
4344
};
4445

4546
use super::ValueKind;
@@ -61,6 +62,9 @@ use crate::{Label, LabelError};
6162
/// `Debug` ([`OpaqueDebug`]) names the type and masks the frame, which is
6263
/// plaintext.
6364
///
65+
/// Two frames compare with `==` in constant time. See the [`PartialEq`
66+
/// impl](#impl-PartialEq-for-ValueFrame) for what equal means.
67+
///
6468
/// # The value model
6569
///
6670
/// A frame can carry any of vitaminc's scalars. The engine admits a subset,
@@ -75,7 +79,7 @@ use crate::{Label, LabelError};
7579
/// sink adapter that holds these rules, so a frame from a host and a frame
7680
/// opened from a ciphertext are read the same way.
7781
#[derive(OpaqueDebug)]
78-
pub struct ValueFrame(Protected<Vec<u8>>);
82+
pub struct ValueFrame(Equatable<Protected<Vec<u8>>>);
7983

8084
/// Why [`ValueFrame::from_bytes`] or [`ValueFrame::check`] refused its bytes.
8185
#[derive(Debug, thiserror::Error)]
@@ -170,12 +174,12 @@ impl ValueFrame {
170174
/// move into the frame's [`Protected`] without being copied. While the
171175
/// writer wrote, the `Buffer` wiped each allocation it outgrew.
172176
fn from_buffer(buffer: Buffer) -> Self {
173-
Self(buffer.into())
177+
Self(Equatable::from(Protected::from(buffer)))
174178
}
175179

176180
/// The null, as a frame: what an absent value opens to.
177181
pub(super) fn null() -> Self {
178-
Self(Protected::new(vec![vitaminc_aead::tags::NULL]))
182+
Self(Equatable::new(vec![vitaminc_aead::tags::NULL]))
179183
}
180184

181185
/// A typed null of `ty`, as a frame: the leaf alone. Walked into the
@@ -184,7 +188,7 @@ impl ValueFrame {
184188
/// typed null of its own types only ([`model_null`]), so `ty` is one of
185189
/// those a [`ValueKind`] names.
186190
pub(super) fn typed_null(ty: FixedType) -> Self {
187-
let mut leaf = Protected::new(Vec::new());
191+
let mut leaf = Equatable::new(Vec::new());
188192
vitaminc_aead::leaf::write_typed_null(ty, leaf.risky_inner_mut());
189193
Self(leaf)
190194
}
@@ -209,7 +213,7 @@ impl ValueFrame {
209213

210214
/// The frame bytes, still protected.
211215
pub fn into_inner(self) -> Protected<Vec<u8>> {
212-
self.0
216+
Protected::new(self.0.risky_unwrap())
213217
}
214218

215219
/// The frame, for walking.
@@ -362,7 +366,35 @@ impl Decrypt for ValueFrame {
362366
/// original does.
363367
impl Clone for ValueFrame {
364368
fn clone(&self) -> Self {
365-
Self(Protected::new(self.0.risky_ref().to_vec()))
369+
Self(Equatable::new(self.0.risky_ref().to_vec()))
370+
}
371+
}
372+
373+
/// Two frames are equal when their bytes are. The bytes are compared in
374+
/// constant time, so the comparison does not reveal where two frames first
375+
/// differ. Frames of different lengths are unequal at once, which reveals
376+
/// only their lengths. A ciphertext's length already shows those.
377+
///
378+
/// This compares frames, not values. A frame records each scalar's width and
379+
/// type, so `34u32` and `34u64` are not equal, and neither are `0.0` and
380+
/// `-0.0`, or a typed null and a plain one. A map is equal to another only
381+
/// when its keys are written in the same order.
382+
///
383+
/// ```
384+
/// use stack_encrypt::dynamic::ValueFrame;
385+
///
386+
/// assert_eq!(ValueFrame::of(34u64)?, ValueFrame::of(34u64)?);
387+
/// assert_ne!(ValueFrame::of(34u64)?, ValueFrame::of(34u32)?);
388+
/// # Ok::<(), Box<dyn std::error::Error>>(())
389+
/// ```
390+
impl PartialEq for ValueFrame {
391+
fn eq(&self, other: &Self) -> bool {
392+
// The frame is held in an `Equatable`, which would compare it itself,
393+
// but vitaminc-protected 0.9.1 has no `ConstantTimeEq` for `Vec<u8>`.
394+
// Until it does, compare the bytes through `TimingSafeEq`, which uses
395+
// `subtle`, and derive `PartialEq` once the impl is released.
396+
let (ours, theirs) = (self.0.risky_ref(), other.0.risky_ref());
397+
TimingSafeEq::ts_eq(ours.as_slice(), theirs.as_slice()).into()
366398
}
367399
}
368400

@@ -1295,6 +1327,46 @@ mod tests {
12951327
use super::*;
12961328
use crate::dynamic::test_frame::Tree;
12971329

1330+
mod equality {
1331+
use super::*;
1332+
1333+
#[test]
1334+
fn frames_with_the_same_bytes_are_equal() {
1335+
let frame = ValueFrame::of("alice").unwrap();
1336+
assert_eq!(frame, frame.clone());
1337+
assert_eq!(
1338+
frame,
1339+
ValueFrame::from_bytes(frame.as_protected_ref()).unwrap()
1340+
);
1341+
}
1342+
1343+
#[test]
1344+
fn frames_with_different_bytes_of_the_same_length_are_not_equal() {
1345+
let (a, b) = (
1346+
ValueFrame::of("alice").unwrap(),
1347+
ValueFrame::of("alicf").unwrap(),
1348+
);
1349+
assert_eq!(a.risky_ref().len(), b.risky_ref().len());
1350+
assert_ne!(a, b);
1351+
}
1352+
1353+
#[test]
1354+
fn frames_of_different_lengths_are_not_equal() {
1355+
assert_ne!(
1356+
ValueFrame::of("alice").unwrap(),
1357+
ValueFrame::of("alice!").unwrap()
1358+
);
1359+
}
1360+
1361+
#[test]
1362+
fn the_same_number_at_two_widths_is_not_equal() {
1363+
assert_ne!(
1364+
ValueFrame::of(34u32).unwrap(),
1365+
ValueFrame::of(34u64).unwrap()
1366+
);
1367+
}
1368+
}
1369+
12981370
fn s(value: &str) -> Tree {
12991371
Tree::String(value.into())
13001372
}

0 commit comments

Comments
 (0)