Skip to content

Commit 5d52632

Browse files
jeswrclaude
andauthored
fix(mpc): output stage selects payload limbs against the dummy encoding (#7098)
* refactor(mpc): route the remaining output stages through the randomness seam Issue #6117 step 6. Every seam-taking caller's oblivious output stage now draws from the SAME `DistributedRandomness` source as the stage before it, instead of minting a second `backend.dealer()` that restarted the keystream mid-protocol: - `join::HiddenValueJoin::batched_join_through` -> `oblivious_join_output_through` - `hidden_distinct::distinct_hidden_pairs_slots_through`, `hidden_path::eval_exact_k_chain_hidden_slots_through` and `hidden_path::eval_bounded_path_hidden_slots_through` -> `oblivious_set_output_through` - `oblivious_join::oblivious_set_output_hidden_keys` gains an in-crate `oblivious_set_output_hidden_keys_through` that draws both its per-pair equality bits and its output stage from one source; the public fn passes `&mut backend.dealer()`. The check that the source's `(n, t)` matches the backend is made up front, because the equality chain draws first. - the three public twins that minted two dealers of their own (`distinct_hidden_pairs_oblivious`, `eval_exact_k_chain_hidden`, `eval_bounded_path_hidden`) now hand their one dealer to the output stage, so they stay byte-identical to their `_slots` siblings. This is a REAL randomness change. Captured on unmodified origin/main and re-checked after: every revealed multiset (canonicalised) and every modelled cost is identical. What moved is the revealed slot ORDER and the trailing witness draws, because the shuffle now samples a different point of one continuous keystream. Pins re-captured and each one's comment says why; every transcript pin additionally asserts that the pre-routing whole-log fingerprint is still exactly the PREFIX of the new transcript, so only the output-stage suffix is new. No assertion weakened. PRSS consequence, stated honestly: the output stage always shares the real-row tag and the payload limbs, so the shapes whose own stage drew nothing (an empty DISTINCT, `k = 1` over distinct endpoint pairs, `p?`) no longer run under PRSS — they fail closed with `NotYetImplemented`, never panic, and the tests now assert that for every shape. The formerly draw-free shapes are still asserted to evaluate correctly through the seam on a dealer source. Semi-honest, honest-majority only; the new checks are shape checks only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 * docs(mpc): scope the transcript-prefix claim in the randomness SKILL note The note said the prefix of "each transcript fingerprint" is asserted unchanged. Only the MOVED pins have a prefix assertion; the hidden-key transcript is a new pin with no pre-routing value to compare against. Say that precisely. Also correcting a line in 99661ec's message: the two `PartialResult` entry points (`eval_exact_k_chain_hidden`, `eval_bounded_path_hidden`) hand their one dealer to the output stage, but they are not "byte-identical to their `_slots` siblings" — they return canonicalised rows, not slots. Only `distinct_hidden_pairs_oblivious` is asserted slot-identical to `distinct_hidden_pairs_slots`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 * fix(mpc): select payload limbs against the dummy encoding in the output stage The oblivious output stage selected only the slot TAG by the candidate's secret bit; the payload limbs rode through the shuffle unselected, so every slot's real payload was opened. Dummy slots now carry the dummy encoding, so the opened payload of a non-matching candidate is the pad, not its row. Construction: before the shuffle, each packed payload limb is obliviously selected by the slot's selector against the DUMMY limb block — the limbs of `encode_payload_row` applied to the all-unbound row of the (public) arity, padded to the uniform width with PAD_LIMB: limb_out = PAD + sel * (limb - PAD) one `mul_shares_raw` per limb, with the public PAD shifted out and back locally. `sel = 1` reproduces the limb exactly, so real rows' payloads are unchanged; `sel = 0` yields the dummy block, which decodes to exactly what a pure padding slot decodes to. Public / disclosed match bits behave identically — they are lifted to a sharing before the select, as the tag already did. Degrees: every element of a packed entry (tag and limbs alike) is now a degree-2t product, so the limb opens move to `reconstruct_degree(.., 2t)`. That needs n >= 2t+1, which the stage already requires for the tag select. Adding the public constant back is local and does not change the degree. The tag is now classified before any payload is decoded, and a dummy-tagged slot's decoded row is CHECKED against the dummy row (fail closed) rather than assumed, so the tag and the payload must agree on what a slot is. Cost model (modelled, not measured): new `ObliviousOutputCost:: payload_select_mults` counts the limb selects, one per limb per slot, so a slot costs 1 + limb_capacity multiplications in the same parallel round. Honestly more multiplications per slot than before. Every pre-existing cost field is unchanged for the existing fixtures. Tests: `rejected_candidates_payloads_open_to_the_dummy_encoding` and `rejected_secret_shared_candidates_open_to_the_dummy_encoding` (oblivious_join), `rejected_two_hop_pairs_payloads_open_to_the_dummy_encoding` (hidden_path a->b->c, where only (a,c) is connected) and `rejected_batched_join_rows_payloads_open_to_the_dummy_encoding` (join) compare the candidates' OWN payload rows against what every one of the B slots actually opened to, through test-only instrumentation on the real path (`opened_slot_rows_through`), and assert the opened set is exactly {matched rows} u {dummy encoding}. Plus `a_rejected_candidate_and_a_padding_slot_open_to_the_same_payload` and `payload_select_mults_are_counted_per_limb_per_slot`. Pins: the stage draws the same sharings in the same order (the select is local plus one `mul_shares_raw`, which draws nothing), so only the shuffled column the source returns differs. Seven whole-log transcript fingerprints were re-captured; every revealed-slot multiset, revealed slot ORDER, witness draw and pre-existing modelled cost is unchanged. Two prefix-equality assertions were added to the output-stage transcript pins (the draw prefix is pinned equal to its pre-change value), alongside the ones #7090 already has elsewhere. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 * test(mpc): dealer check for the empty DISTINCT, one path per hidden-path entry, B>0 wording Three small follow-ups from the #7090 review, in the files this branch touches. L1 — `prss_fails_closed_on_every_distinct_shape` now first runs the empty DISTINCT on a DEALER source (`distinct_hidden_pairs_slots_through(.., &[], 2)`) and asserts two `Dummy` slots and `cost.rows == 0`, before the PRSS refusal. The refusal below belongs to PRSS, not to a shape the operator cannot run. L2 — `eval_exact_k_chain_hidden` and `eval_bounded_path_hidden` now delegate to their `_slots_through` on ONE dealer and narrow the slots with a new `oblivious_join::partial_result_from_slots` (the dummy filter plus canonical row order that `oblivious_join_output_through` already did, factored out). One code path per operator, so nothing can quietly mint a second dealer. Results are identical, as the existing differential and seeded tests assert. L3 — the "under PRSS every shape fails closed" wording in the three test docs and in `skills/mpc/SKILL.md` now says "for any `B > 0`": at `B = 0` an empty input draws nothing and returns Ok. The doc on `oblivious_set_output_hidden_keys_through` now says that with empty key lists the PRSS refusal comes from the output stage, not from inside `secure_equal_to_bit_through`. And the distinct seeded test asserts the output-stage `ObliviousOutputCost`, unchanged at (4, 4, 4, 4, 35, 3), plus the one new field: 24 payload-limb selects (six limbs per slot over four slots). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 * fix(mpc): re-randomise every selected element before the output shuffle The oblivious output stage selects the slot tag and every packed payload limb by the slot's secret match bit, so each selected element leaves the select as a degree-`2t` product sharing. A product sharing opened without re-randomisation reveals more than its value: its opened share vector still determines the factor polynomials, so for `sel = 0` the x-coefficients of a limb and of the tag stand in a fixed ratio to that candidate's own limb. The opened shares of a non-matching candidate's slot were therefore not the same object as a padding slot's, even though both decode to the dummy row. Every selected element -- the tag and all its limbs -- is now degree-reduced through the randomness seam (`shamir::degree_reduce_through`, 2t+1 re-sharings) before it is packed and shuffled. The reduction returns a fresh degree-`t` sharing of the same value whose non-constant coefficients are independent of the factors, so the opened vector carries the value and nothing else. The tags and the limbs are consequently opened at degree `t`. Also in this round: - The output stage opens and classifies ALL `B` tags BEFORE any payload limb is opened, so an out-of-set tag refuses (`Tampered`) with zero limb opens. - The dummy check compares the opened LIMB VECTOR against the pad block exactly, not the decoded row: several wrong subsets of selected limbs still decode to the all-unbound row. - Decoding is strict. `limbs_to_bytes` refuses a non-pad byte past the length prefix and `decode_payload_row` refuses trailing bytes, so one limb block carries exactly one byte stream. - `ObliviousOutputCost` gains `degree_reductions` (one per selected element, `B * (1 + limb_capacity)`), and `opens` is documented as the degree-`t` tag opens. - Residual leakage documented honestly in the module docs and SKILL.md: the payload width is the longest candidate encoding rounded up to whole limbs and is visible to the parties, and the slot tags are opened to the compute parties, so they learn the number of real rows. Behaviour unchanged there; a caller-agreed public width bound and a reconstruction private to the recipient are separate changes. - The mixed-degree wording in `prss.rs`, its tests and SKILL.md now describes the column the stage actually presents (degree `t`), and the PRSS refusal site notes that a future PRSS integration must reduce the tags AND the limbs. Tests: `every_opened_element_interpolates_to_degree_at_most_t`, `the_share_level_recovery_never_returns_a_rejected_row`, `the_share_level_recovery_at_n3_over_many_trials` (with a positive control that the x-coefficient-ratio formula does recover the value from an unreduced product), `a_dummy_slots_opened_limbs_are_exactly_the_pad_block`, `the_pad_block_is_the_dummy_encoding_at_the_uniform_width`, `an_out_of_set_tag_refuses_before_any_limb_is_opened`, `a_well_formed_run_opens_every_slots_limbs`, `a_tampered_payload_on_a_dummy_slot_is_refused`, `limbs_to_bytes_refuses_content_past_the_length_prefix`, `decode_payload_row_refuses_trailing_bytes`, plus a `TamperingShuffleSource` in `randomness::test_sources`. The reduction draws, so the transcript pins, draw counts and revealed slot orders moved; each one was re-captured and every prefix assertion that still applies holds unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 * test(mpc): pin the distinct stage's degree-reduction count One reduction per selected element: the slot tag plus its six payload limbs, for each of the four revealed slots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QrHZ34TTVfgvvQxbPEKf7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a240e1e commit 5d52632

9 files changed

Lines changed: 1773 additions & 334 deletions

File tree

‎crates/sparq-mpc/src/hidden_distinct/tests.rs‎

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -361,17 +361,24 @@ fn distinct_through_refuses_a_source_that_does_not_match_the_backend() {
361361
}
362362
}
363363

364-
/// Under PRSS EVERY shape fails closed, never panics. A row needs its keys shared
365-
/// as the holder's own input, which PRSS refuses; and since #6117 step 6 routed the
366-
/// output stage through the same source, so does the EMPTY DISTINCT — the output
367-
/// stage always shares the real-row tag and the payload limbs for its `B`
368-
/// pure-dummy slots, so there is no longer a draw-free shape here. (Before the
369-
/// routing the empty case ran, because the output stage minted its own dealer and
370-
/// never touched the caller's source.)
364+
/// Under PRSS every shape fails closed for any `B > 0`, never panics. A row needs
365+
/// its keys shared as the holder's own input, which PRSS refuses; and since #6117
366+
/// step 6 routed the output stage through the same source, so does the EMPTY
367+
/// DISTINCT — the output stage shares the real-row tag and the payload limbs for
368+
/// each of its `B` pure-dummy slots, so an empty input is draw-free only at
369+
/// `B = 0`. (Before the routing the empty case ran at any `B`, because the output
370+
/// stage minted its own dealer and never touched the caller's source.)
371+
///
372+
/// First, on a DEALER source, the empty DISTINCT still returns its `B` dummy slots:
373+
/// the PRSS refusal below belongs to PRSS, not to a shape this operator cannot run.
371374
#[test]
372375
fn prss_fails_closed_on_every_distinct_shape() {
373376
use crate::prss::PrssRandomness;
374377
let backend = ShamirBackend::new_seeded(3, 0xD15CA).unwrap();
378+
let (slots, cost) =
379+
distinct_hidden_pairs_slots_through(&mut backend.dealer(), &backend, &[], 2).unwrap();
380+
assert_eq!(slots, vec![OutputSlot::Dummy, OutputSlot::Dummy]);
381+
assert_eq!(cost.rows, 0);
375382
let mut prss = PrssRandomness::with_simulated_setup(3).unwrap();
376383
for pairs in [
377384
vec![],
@@ -456,19 +463,19 @@ fn seeded_distinct_stage_draws_exactly_what_the_dealer_drew_before_the_seam() {
456463
);
457464
assert_eq!(dealer.draw_fp().value(), 888758199960741934);
458465

459-
// The REVEALED slot ORDER moved in #6117 step 6 (it was
460-
// ["dummy", "dummy", "<http://ex/a> <http://ex/b>", "<http://ex/e> <http://ex/f>"]):
461-
// the output stage now draws its sharings and its shuffle from the SAME source
462-
// as the sort-and-scan stage instead of minting a second dealer, so the
463-
// shuffle samples a different point of one continuous keystream. The order is
464-
// the shuffle's secret permutation and carries no result meaning — the
466+
// The REVEALED slot ORDER has moved twice: first in #6117 step 6 (the output
467+
// stage draws its sharings and its shuffle from the SAME source as the
468+
// sort-and-scan stage instead of minting a second dealer), then again when the
469+
// output stage's per-element degree reduction added its re-sharings, so the
470+
// shuffle samples a later point of one continuous keystream. The order is the
471+
// shuffle's secret permutation and carries no result meaning — the
465472
// canonicalised multiset below is what the recipient sees, and it is
466473
// byte-identical to the pre-routing capture.
467474
let expected_slots = [
475+
"<http://ex/a> <http://ex/b>",
468476
"<http://ex/e> <http://ex/f>",
469477
"dummy",
470478
"dummy",
471-
"<http://ex/a> <http://ex/b>",
472479
];
473480
let canonical = [
474481
"<http://ex/a> <http://ex/b>",
@@ -486,9 +493,28 @@ fn seeded_distinct_stage_draws_exactly_what_the_dealer_drew_before_the_seam() {
486493
(3, 2)
487494
);
488495
// `distinct_hidden_pairs_oblivious` draws both stages from ONE dealer too, so
489-
// it reveals exactly the same slots in the same order.
490-
let (slots, _) = distinct_hidden_pairs_oblivious(&backend, &pairs, 4).unwrap();
496+
// it reveals exactly the same slots in the same order, and its output-stage
497+
// `ObliviousOutputCost` is unchanged from the pre-routing capture.
498+
let (slots, out_cost) = distinct_hidden_pairs_oblivious(&backend, &pairs, 4).unwrap();
491499
assert_eq!(slot_strings(&slots), expected_slots);
500+
assert_eq!(
501+
(
502+
out_cost.bound,
503+
out_cost.select_mults,
504+
out_cost.opens,
505+
out_cost.shuffle.items,
506+
out_cost.shuffle.switches,
507+
out_cost.shuffle.depth_rounds
508+
),
509+
(4, 4, 4, 4, 35, 3)
510+
);
511+
// The payload-limb select is the one NEW cost: each slot's endpoint-pair
512+
// payload packs six limbs (a length prefix plus five data limbs), so the limb
513+
// select adds 6 multiplications per slot on top of the one tag select.
514+
assert_eq!(out_cost.payload_select_mults, 24);
515+
// One degree reduction per SELECTED element: the tag plus the six limbs, for
516+
// each of the four slots.
517+
assert_eq!(out_cost.degree_reductions, 28);
492518
}
493519

494520
/// The COMPLETE draw sequence, not just its length: every seam call the stage
@@ -497,15 +523,16 @@ fn seeded_distinct_stage_draws_exactly_what_the_dealer_drew_before_the_seam() {
497523
/// when the witness draw count and the keep bits happen to agree.
498524
///
499525
/// **This is the #6117-step-6 guard.** The transcript now ends in the output
500-
/// stage's 29 `vss_own_input` draws and its `oblivious_shuffle`: if
526+
/// stage's 113 `vss_own_input` draws and its `oblivious_shuffle`: if
501527
/// `distinct_hidden_pairs_slots_through` went back to minting its own dealer for
502528
/// the output stage the log would stop at 8418 entries and both the length and the
503529
/// suffix assertion would fail.
504530
///
505531
/// The 8418-entry PREFIX is the pre-routing whole-log pin
506532
/// (`0x5455_9ace_20c7_0f97`, captured on the pre-routing code), asserted here
507533
/// unchanged: the routing appended an output-stage SUFFIX and moved nothing in the
508-
/// sort-and-scan part. The full-log fingerprint is the new pin.
534+
/// sort-and-scan part, and the output stage's per-element degree reduction only
535+
/// grew that suffix. The full-log fingerprint is the new pin.
509536
#[test]
510537
fn seeded_distinct_stage_transcript_is_pinned() {
511538
use crate::randomness::test_sources::{fingerprint, RecordingSource};
@@ -517,13 +544,14 @@ fn seeded_distinct_stage_transcript_is_pinned() {
517544
];
518545
let mut rec = RecordingSource::new(backend.dealer());
519546
distinct_hidden_pairs_slots_through(&mut rec, &backend, &pairs, 4).unwrap();
520-
assert_eq!(rec.log.len(), 8448);
521-
// Prefix unchanged from the pre-routing pin; suffix is the new output stage.
547+
assert_eq!(rec.log.len(), 8532);
548+
// Prefix unchanged from the pre-routing pin; the whole move is in the output
549+
// suffix, which grew by the per-element degree reductions.
522550
assert_eq!(fingerprint(&rec.log[..8418]), 0x5455_9ace_20c7_0f97);
523-
assert_eq!(fingerprint(&rec.log), 0xa636_3f2c_e548_9f86);
551+
assert_eq!(fingerprint(&rec.log), 0x0e8a_77a0_b90f_0200);
524552
let suffix: Vec<&str> = rec.log[8418..].iter().map(|(m, _)| *m).collect();
525553
assert_eq!(
526554
suffix,
527-
[["vss_own_input"; 29].as_slice(), &["oblivious_shuffle"]].concat()
555+
[["vss_own_input"; 113].as_slice(), &["oblivious_shuffle"]].concat()
528556
);
529557
}

‎crates/sparq-mpc/src/hidden_path.rs‎

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@
117117
use crate::compare::secure_equal_to_bit_through;
118118
use crate::field::Fp;
119119
use crate::oblivious_join::{
120-
oblivious_join_output_through, oblivious_set_output_through, Candidate, MatchBit,
121-
ObliviousOutput,
120+
oblivious_set_output_through, partial_result_from_slots, Candidate, MatchBit, ObliviousOutput,
122121
};
123122
use crate::partial::{MpcError, PartialResult};
124123
use crate::randomness::{require_source_matches, DistributedRandomness};
@@ -130,6 +129,14 @@ use std::collections::BTreeMap;
130129
const SUBJECT_VAR: &str = "__pp_a";
131130
const OBJECT_VAR: &str = "__pp_b";
132131

132+
/// The output schema both hidden-path entry points project onto: the endpoint pair.
133+
fn chain_out_vars() -> Vec<Variable> {
134+
vec![
135+
Variable::new_unchecked(SUBJECT_VAR),
136+
Variable::new_unchecked(OBJECT_VAR),
137+
]
138+
}
139+
133140
/// Maximum number of edge-tuples (`|E|^k`) the operator will enumerate before
134141
/// refusing the path with a [`MpcError::Protocol`]. The enumeration is
135142
/// `|E|^k` — exponential in the PUBLIC bound `k` and polynomial-of-degree-`k` in
@@ -378,23 +385,18 @@ fn check_params(backend: &ShamirBackend, edges: &HiddenEdges, k: usize) -> Resul
378385
/// `bound` is the public padded bound `B`; it must cover the distinct-endpoint-pair
379386
/// candidate count (fail-closed in `oblivious_set_output_through` — never
380387
/// truncates a match). One fresh dealer covers the chain stage AND the output
381-
/// stage. See the module docs for the full construction and security tier.
388+
/// stage: this is `eval_exact_k_chain_hidden_slots_through` on that one dealer,
389+
/// narrowed to the disclosed rows — a single code path, so nothing here can mint a
390+
/// second dealer. See the module docs for the full construction and security tier.
382391
pub fn eval_exact_k_chain_hidden(
383392
backend: &ShamirBackend,
384393
edges: &HiddenEdges,
385394
k: usize,
386395
bound: usize,
387396
) -> Result<PartialResult, MpcError> {
388-
check_params(backend, edges, k)?;
389-
let mut dealer = backend.dealer();
390-
let candidates = build_candidates(backend, &mut dealer, edges, k)?;
391-
let out_vars = vec![
392-
Variable::new_unchecked(SUBJECT_VAR),
393-
Variable::new_unchecked(OBJECT_VAR),
394-
];
395-
let (result, _cost) =
396-
oblivious_join_output_through(&mut dealer, backend, &candidates, out_vars, bound)?;
397-
Ok(result)
397+
let (slots, _cost) =
398+
eval_exact_k_chain_hidden_slots_through(&mut backend.dealer(), backend, edges, k, bound)?;
399+
Ok(partial_result_from_slots(slots, chain_out_vars()))
398400
}
399401

400402
/// Like [`eval_exact_k_chain_hidden`] but returns the raw oblivious output (the `B`
@@ -440,6 +442,29 @@ pub(crate) fn eval_exact_k_chain_hidden_slots_through(
440442
oblivious_set_output_through(source, backend, &candidates, 2, bound)
441443
}
442444

445+
/// Test-only instrumentation: the candidates
446+
/// `eval_exact_k_chain_hidden_slots_through` builds, together with the
447+
/// `(is_real, decoded_row)` of every slot the REAL output stage opens for them.
448+
/// Same candidate construction and same output stage as the public path — it just
449+
/// keeps the dummy slots' decoded payload rows, which [`ObliviousOutput`] cannot
450+
/// express.
451+
#[cfg(test)]
452+
#[allow(clippy::type_complexity)]
453+
pub(crate) fn exact_k_chain_candidates_and_opened_rows(
454+
source: &mut dyn DistributedRandomness,
455+
backend: &ShamirBackend,
456+
edges: &HiddenEdges,
457+
k: usize,
458+
bound: usize,
459+
) -> Result<(Vec<Candidate>, Vec<(bool, Vec<Option<Term>>)>), MpcError> {
460+
require_source_matches(source, backend)?;
461+
check_params(backend, edges, k)?;
462+
let candidates = build_candidates(backend, source, edges, k)?;
463+
let slots =
464+
crate::oblivious_join::opened_slot_rows_through(source, backend, &candidates, 2, bound)?;
465+
Ok((candidates, slots))
466+
}
467+
443468
// =====================================================================
444469
// sq-py8h.3 — bounded repetition `{1,k}` / `{0,k}` / `p?` + alternation
445470
// over the SAME hidden edge set, as a union-of-fixed-chains with an
@@ -813,24 +838,19 @@ fn build_bounded_candidates(
813838
/// `bound` is the public padded bound `B`; it must cover the distinct-endpoint-pair
814839
/// candidate count (fail-closed in `oblivious_set_output_through` — never
815840
/// truncates a match). One fresh dealer covers the union stage AND the output
816-
/// stage. Security tier is identical to [`eval_exact_k_chain_hidden`]:
841+
/// stage: this is `eval_bounded_path_hidden_slots_through` on that one dealer,
842+
/// narrowed to the disclosed rows — a single code path, so nothing here can mint a
843+
/// second dealer. Security tier is identical to [`eval_exact_k_chain_hidden`]:
817844
/// honest-majority, semi-honest, no new assumption.
818845
pub fn eval_bounded_path_hidden(
819846
backend: &ShamirBackend,
820847
edges: &PredicatedEdges,
821848
form: &HiddenBoundedPath,
822849
bound: usize,
823850
) -> Result<PartialResult, MpcError> {
824-
check_bounded_params(backend, edges, form)?;
825-
let mut dealer = backend.dealer();
826-
let candidates = build_bounded_candidates(backend, &mut dealer, edges, form)?;
827-
let out_vars = vec![
828-
Variable::new_unchecked(SUBJECT_VAR),
829-
Variable::new_unchecked(OBJECT_VAR),
830-
];
831-
let (result, _cost) =
832-
oblivious_join_output_through(&mut dealer, backend, &candidates, out_vars, bound)?;
833-
Ok(result)
851+
let (slots, _cost) =
852+
eval_bounded_path_hidden_slots_through(&mut backend.dealer(), backend, edges, form, bound)?;
853+
Ok(partial_result_from_slots(slots, chain_out_vars()))
834854
}
835855

836856
/// Like [`eval_bounded_path_hidden`] but returns the raw oblivious output (the `B`
@@ -848,7 +868,7 @@ pub fn eval_bounded_path_hidden_slots(
848868
/// [`eval_bounded_path_hidden_slots`] with the union stage's randomness drawn
849869
/// through the [`DistributedRandomness`] seam (sq-yyro, issue #6117). Same routing,
850870
/// source checks and fail-closed behaviour as
851-
/// [`eval_exact_k_chain_hidden_slots_through`], including the output stage drawn
871+
/// `eval_exact_k_chain_hidden_slots_through`, including the output stage drawn
852872
/// from the SAME `source`; the single-dealer simulation's revealed multiset and
853873
/// cost are unchanged.
854874
pub(crate) fn eval_bounded_path_hidden_slots_through(

‎crates/sparq-mpc/src/hidden_path/bounded_tests.rs‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -567,14 +567,14 @@ fn malformed_draws_in_the_or_fold_are_refused() {
567567
}
568568
}
569569

570-
/// Under PRSS EVERY shape fails closed, never panics. `{1,2}` needs a hop equality
571-
/// (a holder's own input shared), which PRSS refuses. Since #6117 step 6 routed the
572-
/// output stage through the same source, so does `p?` over a single predicate with
573-
/// no self-loop and no repeated endpoint pair, whose union stage draws nothing: the
574-
/// output stage always shares the real-row tag and the payload limbs for its `B`
575-
/// slots, so there is no longer a draw-free shape here. (Before the routing that
576-
/// case ran, because the output stage minted its own dealer and never touched the
577-
/// caller's source.)
570+
/// Under PRSS every shape fails closed for any `B > 0`, never panics. `{1,2}` needs
571+
/// a hop equality (a holder's own input shared), which PRSS refuses. Since #6117
572+
/// step 6 routed the output stage through the same source, so does `p?` over a
573+
/// single predicate with no self-loop and no repeated endpoint pair, whose union
574+
/// stage draws nothing: the output stage shares the real-row tag and the payload
575+
/// limbs for each of its `B` slots, so that shape is draw-free only at `B = 0`.
576+
/// (Before the routing that case ran at any `B`, because the output stage minted
577+
/// its own dealer and never touched the caller's source.)
578578
#[test]
579579
fn prss_fails_closed_on_every_bounded_shape() {
580580
use crate::prss::PrssRandomness;
@@ -693,23 +693,22 @@ fn seeded_bounded_stage_draws_exactly_what_the_dealer_drew_before_the_seam() {
693693
]
694694
);
695695
assert_eq!(dealer.draw_fp().value(), 1940071552581823960);
696-
// The REVEALED slot ORDER moved in #6117 step 6 (it was
697-
// ["<http://ex/a> <http://ex/b>", "<http://ex/b> <http://ex/a>", "dummy",
698-
// "<http://ex/a> <http://ex/a>", "<http://ex/b> <http://ex/b>"]): the output
699-
// stage now draws its sharings and its shuffle from the SAME source as the
700-
// union stage instead of minting a second dealer, so the shuffle samples a
701-
// different point of one continuous keystream. The order is the shuffle's
702-
// secret permutation and carries no result meaning — the canonicalised multiset
703-
// below is byte-identical to the pre-routing capture.
696+
// The REVEALED slot ORDER has moved twice: first in #6117 step 6 (the output
697+
// stage draws its sharings and its shuffle from the SAME source as the union
698+
// stage instead of minting a second dealer), then again when the output stage's
699+
// per-element degree reduction added its re-sharings, so the shuffle samples a
700+
// later point of one continuous keystream. The order is the shuffle's secret
701+
// permutation and carries no result meaning — the canonicalised multiset below
702+
// is byte-identical to the pre-routing capture.
704703
let (slots, cost) = eval_bounded_path_hidden_slots(&backend, &edges, &form, 5).unwrap();
705704
assert_eq!(
706705
slot_strings(&slots),
707706
[
708-
"<http://ex/b> <http://ex/a>",
707+
"<http://ex/b> <http://ex/b>",
709708
"dummy",
709+
"<http://ex/a> <http://ex/a>",
710710
"<http://ex/a> <http://ex/b>",
711-
"<http://ex/b> <http://ex/b>",
712-
"<http://ex/a> <http://ex/a>"
711+
"<http://ex/b> <http://ex/a>"
713712
]
714713
);
715714
let mut sorted = slot_strings(&slots);
@@ -742,15 +741,16 @@ fn seeded_bounded_stage_draws_exactly_what_the_dealer_drew_before_the_seam() {
742741
/// order-sensitive fingerprint over every method and returned `y`).
743742
///
744743
/// **This is the #6117-step-6 guard.** The transcript now ends in the output
745-
/// stage's 36 `vss_own_input` draws and its `oblivious_shuffle`: if
744+
/// stage's 141 `vss_own_input` draws and its `oblivious_shuffle`: if
746745
/// `eval_bounded_path_hidden_slots_through` went back to minting its own dealer for
747746
/// the output stage the log would stop at 1932 entries and both the length and the
748747
/// suffix assertion would fail.
749748
///
750749
/// The 1932-entry PREFIX is the pre-routing whole-log pin
751750
/// (`0x690e_cad1_cdf7_7eb0`, captured on the pre-routing code), asserted here
752751
/// unchanged: the routing appended an output-stage SUFFIX and moved nothing in the
753-
/// union part. The full-log fingerprint is the new pin.
752+
/// union part, and the output stage's per-element degree reduction only grew that
753+
/// suffix. The full-log fingerprint is the new pin.
754754
#[test]
755755
fn seeded_bounded_stage_transcript_is_pinned() {
756756
use crate::randomness::test_sources::{fingerprint, RecordingSource};
@@ -759,13 +759,14 @@ fn seeded_bounded_stage_transcript_is_pinned() {
759759
let form = HiddenBoundedPath::range("p", 0, 2);
760760
let mut rec = RecordingSource::new(backend.dealer());
761761
eval_bounded_path_hidden_slots_through(&mut rec, &backend, &edges, &form, 5).unwrap();
762-
assert_eq!(rec.log.len(), 1969);
763-
// Prefix unchanged from the pre-routing pin; suffix is the new output stage.
762+
assert_eq!(rec.log.len(), 2074);
763+
// Prefix unchanged from the pre-routing pin; the whole move is in the output
764+
// suffix, which grew by the per-element degree reductions.
764765
assert_eq!(fingerprint(&rec.log[..1932]), 0x690e_cad1_cdf7_7eb0);
765-
assert_eq!(fingerprint(&rec.log), 0x8ac7_aa74_a625_9a25);
766+
assert_eq!(fingerprint(&rec.log), 0x9585_8505_1ecb_a85b);
766767
let suffix: Vec<&str> = rec.log[1932..].iter().map(|(m, _)| *m).collect();
767768
assert_eq!(
768769
suffix,
769-
[["vss_own_input"; 36].as_slice(), &["oblivious_shuffle"]].concat()
770+
[["vss_own_input"; 141].as_slice(), &["oblivious_shuffle"]].concat()
770771
);
771772
}

0 commit comments

Comments
 (0)