Skip to content

Commit faff233

Browse files
committed
test(stack-encrypt): widen the leakage corpus to the clauses it never exercised
Several clauses the positive control should hold were never tested. No ordered path held a null, a boolean or a container, so "op only on number and string leaves" was never checked. No ordered number was past 2^53, where 9007199254740992 and 9007199254740993 share a term under two value selectors. The random columns held only lower-case ASCII strings and never ordered strings, so folding and multi-byte lengths showed up in one handcrafted column alone. The random generator now holds big integers, floats, and strings that fold or are wider in bytes than in characters. It keeps non-scalars at the ordered paths, and orders numbers or strings by a coin toss (one kind per world, as the profile requires). It runs 256 cases instead of 48, in about three seconds. A small handcrafted column orders numbers of both signs and many magnitudes, the two big integers, and leaves that carry no term, which also gives the shared-prefix clause something to bite on. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu
1 parent 7ca5b0d commit faff233

3 files changed

Lines changed: 161 additions & 50 deletions

File tree

‎packages/stack-encrypt/tests/json_leakage/calibration.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,15 @@ mod given_the_full_profile {
106106
}
107107
}
108108

109-
/// The positive control on random columns. Deterministic: the runner's
110-
/// own generator is seeded, and so is every simulation.
109+
/// The positive control on 256 random columns, about three seconds.
110+
/// Deterministic: the runner's own generator is seeded, and so is every
111+
/// simulation.
111112
#[test]
112113
fn no_distinguisher_separates_random_worlds() {
113114
let runtime = runtime();
114115
let cipher = runtime.block_on(stack_cipher());
115116
let config = Config {
116-
cases: 48,
117+
cases: 256,
117118
failure_persistence: None,
118119
rng_algorithm: RngAlgorithm::ChaCha,
119120
..Config::default()

‎packages/stack-encrypt/tests/json_leakage/corpus.rs‎

Lines changed: 156 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,43 @@ fn names() -> World {
124124
}
125125
}
126126

127+
/// A column that orders numbers of both signs and many magnitudes, so the
128+
/// terms' shared prefixes (sign, then exponent) have something to show; two
129+
/// integers past 2^53 that round to one `f64`, so one term under two value
130+
/// selectors; and leaves at the ordered path that carry no term (a null, a
131+
/// boolean, an empty object, an array, which holds its own leaf below the
132+
/// ordered path).
133+
fn numbers() -> World {
134+
World {
135+
name: "ordered numbers",
136+
column: Column {
137+
sealed_root: true,
138+
ordered: ordered(&["$.v"]),
139+
},
140+
docs: vec![
141+
json!({"v": 1}),
142+
json!({"v": -2}),
143+
json!({"v": 3}),
144+
json!({"v": 9_007_199_254_740_993u64}),
145+
json!({"v": 9_007_199_254_740_992u64}),
146+
json!({"v": 2.5}),
147+
json!({"v": 1e300}),
148+
json!({"v": -1e300}),
149+
json!({"v": 0.001}),
150+
json!({"v": null}),
151+
json!({"v": true}),
152+
json!({"v": {}}),
153+
json!({"v": [3]}),
154+
],
155+
queries: vec![
156+
Query::Order("$.v".into(), json!(0)),
157+
Query::Order("$.v".into(), json!(9_007_199_254_740_992u64)),
158+
Query::Order("$.v".into(), json!(-1)),
159+
Query::subtree("$.v"),
160+
],
161+
}
162+
}
163+
127164
/// A column with three wide top-level objects, each read whole, so that
128165
/// whether members' ordinals follow their keys' order is decidable rather
129166
/// than a coin toss (see `distinguishers::member_ordinals_follow_key_order`).
@@ -158,8 +195,8 @@ fn wide() -> World {
158195
}
159196
}
160197

161-
/// The handcrafted worlds: the shapes under each root option, and a column
162-
/// of ordered strings.
198+
/// The handcrafted worlds: the shapes under each root option, a column of
199+
/// ordered strings, one of ordered numbers, and one of wide objects.
163200
pub fn handcrafted() -> Vec<World> {
164201
vec![
165202
World {
@@ -181,6 +218,7 @@ pub fn handcrafted() -> Vec<World> {
181218
queries: shape_queries(false),
182219
},
183220
names(),
221+
numbers(),
184222
wide(),
185223
]
186224
}
@@ -191,12 +229,26 @@ pub fn handcrafted() -> Vec<World> {
191229

192230
const KEYS: &[&str] = &["a", "b", "c", "list", "n", "name", "tags", "zz"];
193231

232+
/// The strings the random columns hold: short, sharing prefixes, and some
233+
/// that fold to one another (`"X"` and `"x"`, `"é"` and `"e"`) or are wider
234+
/// in bytes than in characters.
235+
const STRINGS: &[&str] = &["x", "yy", "x", "zzz", "ab", "", "X", "é", "e", "Ab"];
236+
194237
fn leaf() -> impl Strategy<Value = Value> {
195238
prop_oneof![
196-
Just(Value::Null),
197-
any::<bool>().prop_map(Value::Bool),
198-
(-3i64..6).prop_map(Value::from),
199-
prop::sample::select(vec!["x", "yy", "x", "zzz", "ab", ""]).prop_map(Value::from),
239+
1 => Just(Value::Null),
240+
1 => any::<bool>().prop_map(Value::Bool),
241+
4 => (-3i64..6).prop_map(Value::from),
242+
// Past 2^53, where two integers can round to one f64 and so share
243+
// an ordering term while their value selectors differ; and floats.
244+
1 => prop::sample::select(vec![
245+
json!(9_007_199_254_740_992u64),
246+
json!(9_007_199_254_740_993u64),
247+
json!(2.5),
248+
json!(-0.5),
249+
json!(1e300),
250+
]),
251+
4 => prop::sample::select(STRINGS).prop_map(Value::from),
200252
]
201253
}
202254

@@ -223,37 +275,92 @@ fn value() -> impl Strategy<Value = Value> {
223275
})
224276
}
225277

226-
/// Every value under the key `n` made a number, since the random columns
227-
/// order `$.n` and `$.list[*].n` and a world orders one kind of leaf.
228-
fn numberize(value: Value) -> Value {
229-
match value {
230-
Value::Object(map) => Value::Object(
231-
map.into_iter()
232-
.map(|(k, v)| {
233-
let v = if k == "n" && !v.is_number() {
234-
Value::from(i64::try_from(v.to_string().len() % 7).unwrap())
235-
} else {
236-
numberize(v)
237-
};
238-
(k, v)
278+
/// Which kind of leaf a random column orders: a world orders one (see
279+
/// [`super::profile::Ids::ordering_ranks`]).
280+
#[derive(Clone, Copy, Debug)]
281+
enum Ordered {
282+
/// `$.n` and `$.list[*].n`.
283+
Numbers,
284+
/// `$.name` and `$.tags[*]`.
285+
Strings,
286+
}
287+
288+
impl Ordered {
289+
fn paths(self) -> Vec<JsonPath> {
290+
match self {
291+
Self::Numbers => ordered(&["$.n", "$.list[*].n"]),
292+
Self::Strings => ordered(&["$.name", "$.tags[*]"]),
293+
}
294+
}
295+
296+
/// `value` with every leaf an ordered path could reach made the kind
297+
/// this column orders: a string under the key `n` a number, or a number
298+
/// under the key `name` or directly in an array under `tags` a string.
299+
/// Everything else stays, `null`s, booleans and containers at the
300+
/// ordered paths included, which carry no term.
301+
fn fix(self, value: Value) -> Value {
302+
let to_number = |v: &Value| Value::from(i64::try_from(v.to_string().len() % 7).unwrap());
303+
let to_string = |v: &Value| Value::from(STRINGS[v.to_string().len() % STRINGS.len()]);
304+
match (self, value) {
305+
(_, Value::Object(map)) => Value::Object(
306+
map.into_iter()
307+
.map(|(k, v)| {
308+
let v = match (self, k.as_str(), v) {
309+
(Self::Numbers, "n", v) if v.is_string() => to_number(&v),
310+
(Self::Strings, "name", v) if v.is_number() => to_string(&v),
311+
(Self::Strings, "tags", Value::Array(items)) => Value::Array(
312+
items
313+
.into_iter()
314+
.map(|item| {
315+
if item.is_number() {
316+
to_string(&item)
317+
} else {
318+
self.fix(item)
319+
}
320+
})
321+
.collect(),
322+
),
323+
(_, _, v) => self.fix(v),
324+
};
325+
(k, v)
326+
})
327+
.collect(),
328+
),
329+
(_, Value::Array(items)) => {
330+
Value::Array(items.into_iter().map(|item| self.fix(item)).collect())
331+
}
332+
(_, other) => other,
333+
}
334+
}
335+
336+
/// An ordering bound of this kind at one of this kind's paths.
337+
fn query(self) -> BoxedStrategy<Query> {
338+
match self {
339+
Self::Numbers => (prop::sample::select(vec!["$.n", "$.list[*].n"]), leaf())
340+
.prop_filter_map("a number bound", |(p, v)| {
341+
v.is_number().then(|| Query::Order(p.into(), v))
239342
})
240-
.collect(),
241-
),
242-
Value::Array(items) => Value::Array(items.into_iter().map(numberize).collect()),
243-
other => other,
343+
.boxed(),
344+
Self::Strings => (
345+
prop::sample::select(vec!["$.name", "$.tags[*]"]),
346+
prop::sample::select(STRINGS),
347+
)
348+
.prop_map(|(p, v)| Query::Order(p.into(), Value::from(v)))
349+
.boxed(),
350+
}
244351
}
245352
}
246353

247-
fn document() -> impl Strategy<Value = Value> {
354+
fn document(kind: Ordered) -> impl Strategy<Value = Value> {
248355
prop_oneof![
249356
8 => object(value(), 0..5),
250357
1 => prop::collection::vec(value(), 0..3).prop_map(Value::Array),
251358
1 => leaf(),
252359
]
253-
.prop_map(numberize)
360+
.prop_map(move |v| kind.fix(v))
254361
}
255362

256-
fn query() -> impl Strategy<Value = Query> {
363+
fn query(kind: Ordered) -> impl Strategy<Value = Query> {
257364
let subtree = vec![
258365
"$",
259366
"$.a",
@@ -294,31 +401,34 @@ fn query() -> impl Strategy<Value = Query> {
294401
prop::collection::vec(leaf(), 1..3).prop_map(Value::Array),
295402
object(prop::collection::vec(object(leaf(), 1..2), 1..2).prop_map(Value::Array), 1..2),
296403
]
297-
.prop_map(|v| Query::Contains(numberize(v))),
404+
.prop_map(move |v| Query::Contains(kind.fix(v))),
298405
1 => (prop::sample::select(vec!["$.a", "$.tags[0]", "$.list[*].b"]),
299406
prop_oneof![(-3i64..6).prop_map(Value::from), prop::sample::select(vec!["x", "ab"]).prop_map(Value::from)])
300407
.prop_map(|(p, v)| Query::Equals(p.into(), v)),
301-
1 => (prop::sample::select(vec!["$.n", "$.list[*].n"]), -3i64..6)
302-
.prop_map(|(p, v)| Query::Order(p.into(), Value::from(v))),
408+
1 => kind.query(),
303409
]
304410
}
305411

306-
/// A random column: a root option, two to six documents, and four to ten
307-
/// queries, some of which repeat.
412+
/// A random column: a root option, the kind of leaf it orders, two to six
413+
/// documents, and four to ten queries, some of which repeat.
308414
pub fn random_world() -> impl Strategy<Value = World> {
309-
any::<bool>().prop_flat_map(|sealed_root| {
310-
(
311-
prop::collection::vec(document(), 2..7),
312-
prop::collection::vec(query(), 4..11),
313-
)
314-
.prop_map(move |(docs, queries)| World {
315-
name: "random",
316-
column: Column {
317-
sealed_root,
318-
ordered: ordered(&["$.n", "$.list[*].n"]),
319-
},
320-
docs,
321-
queries,
322-
})
323-
})
415+
(
416+
any::<bool>(),
417+
prop_oneof![Just(Ordered::Numbers), Just(Ordered::Strings)],
418+
)
419+
.prop_flat_map(|(sealed_root, kind)| {
420+
(
421+
prop::collection::vec(document(kind), 2..7),
422+
prop::collection::vec(query(kind), 4..11),
423+
)
424+
.prop_map(move |(docs, queries)| World {
425+
name: "random",
426+
column: Column {
427+
sealed_root,
428+
ordered: kind.paths(),
429+
},
430+
docs,
431+
queries,
432+
})
433+
})
324434
}

‎packages/stack-encrypt/tests/json_leakage/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
//!
6767
//! - **The positive control.** The simulator, run from the full profile, is
6868
//! told from the real scheme by no distinguisher, on the handcrafted
69-
//! columns and on 48 random ones. If this fails, the real scheme reveals
69+
//! columns and on 256 random ones. If this fails, the real scheme reveals
7070
//! something `L` does not state: that is a finding against the page (or
7171
//! against the code), and the failure names the distinguisher and prints
7272
//! both features.

0 commit comments

Comments
 (0)