hopr_protocol_pix/lib.rs
1// PIX (Protocol for Incentivization of eXits): SSA commitment generation and reconstruction.
2#![deny(clippy::debug_assert_with_mut_call)]
3
4use std::ops::{Add, Mul};
5
6use hopr_types::{
7 crypto::{
8 crypto_traits::{
9 BlockSizeUser, FixedOutput, HashMarker, KeyIvInit, OutputSizeUser, StreamCipher,
10 elliptic_curve::ops::Reduce,
11 hash2curve::{ExpandMsgXmd, GroupDigest, MapToCurve, hash_to_scalar},
12 },
13 prelude::Pseudonym,
14 },
15 primitive::hybrid_array::{
16 Array, ArraySize,
17 typenum::{IsGreaterOrEqual, IsLess, IsLessOrEqual, NonZero, Prod, True, U2},
18 },
19};
20use vsss_rs::{
21 DefaultShare, IdentifierPrimeField,
22 elliptic_curve::{Curve, CurveArithmetic, PrimeCurve, PrimeField, consts::U256},
23};
24
25pub mod ack_verify;
26pub mod errors;
27mod generator;
28mod params;
29mod reconstructor;
30mod traits;
31mod types;
32
33pub use generator::{
34 EmissionProgress, SHARE_EMISSION_WINDOW, SsaGeneratorConfig, SsaShareGenerator, min_emission_for_early_recovery,
35};
36pub use params::{InvalidPixParams, PixParams, PixSuite};
37pub use reconstructor::{
38 AWAITING_ACK_ENTRY_BYTES, MAX_DEFERRED_ACKS_PER_CYCLE, MAX_DEFERRED_ACKS_PER_POLYNOMIAL,
39 PART_BUILDER_OVERHEAD_BYTES, SsaCommitmentGuard, SsaReconstructor, SsaReconstructorConfig, SsaRetirementScope,
40 peak_cycle_bytes, peak_share_buffer_bytes,
41};
42pub use traits::{EntryShareGenerator, ExitAcknowledgementShareProcessor, ShareResolution};
43pub use types::{
44 CoefficientIndex, EncryptedPartialSsaShare, GeneratedShare, PartialSsaShare, PolynomialIndex, RawSsaIndex,
45 RecoveredSsa, SsaCommitment, SsaCommitmentProof, SsaCommitmentState, SsaId, SsaIndex, SsaPolyIndexPrefixSize,
46 SsaPolynomialId, SsaRecoveryProgress, TaggedEncryptedPartialSsaShare,
47};
48pub use vsss_rs::elliptic_curve::{
49 Field, Group,
50 group::{GroupEncoding, cofactor::CofactorGroup},
51};
52
53#[doc(hidden)]
54pub mod prelude {
55 pub use super::*;
56}
57
58/// Coefficient index of the polynomial constant term.
59///
60/// The only coefficient PIX commits to: see [`SsaPartCommitment`] for why the rest are not sent.
61/// The wire format still carries a `coefficient_index`, so a peer *may* send others — the Exit
62/// ignores them.
63pub const CONSTANT_TERM_COEFFICIENT: CoefficientIndex = 0;
64
65/// Number of polynomials per SSA.
66///
67/// This and [`DEFAULT_POLY_THRESHOLD`] are the **deployed** split, not merely a library
68/// convenience: `hopr-transport-session` aliases them as `DEFAULT_PIX_POLYS_PER_SSA` and
69/// `DEFAULT_PIX_SHARES_PER_POLY`, and the Exit's accepted quota range is derived from their
70/// product. Changing either changes what nodes negotiate, and a product that no longer matches
71/// the Exit's range makes every PIX Session fail to establish — so move them together.
72pub const DEFAULT_POLYS_PER_SSA: u16 = 8192;
73
74/// Minimum number of shares needed to recover one polynomial of an SSA.
75///
76/// 64 rather than the 128 this was before the split was re-tuned. Dropping the non-constant
77/// coefficient commitments (see [`SsaPartCommitment`]) put every commitment-side cost — wire
78/// volume, ingest, reconstructor memory, share verification — on `polys` alone. What still grows
79/// with the threshold is Exit interpolation (`O(threshold²)` per polynomial), fault-detection
80/// latency (`threshold` return packets) and, on the Entry, the per-packet
81/// [`SsaShareGenerator::next_share`], which evaluates a `threshold`-wide polynomial by Horner for
82/// every share it emits. The full cost model is documented on `DEFAULT_PIX_POLYS_PER_SSA` in
83/// `hopr-transport-session`.
84///
85/// # What 64 optimises
86///
87/// **Exit reconstruction capacity, deliberately, and not total network CPU.** The two sides do not
88/// deserve equal weight: an Exit is a shared resource serving 10–30 concurrent clients while each
89/// Entry generates only its own shares, so the Exit is the side that saturates first and the one
90/// whose per-share cost sets what the network can carry. Both were measured (48 cores,
91/// `acknowledge_shares/interpolation` and `SsaShareGenerator::next_share`, µs/share):
92///
93/// | threshold | Exit | Entry | total |
94/// | --------- | ----- | ----- | ----- |
95/// | 16 | 13.15 | 0.90 | 14.05 |
96/// | 32 | 11.12 | 1.20 | 12.32 |
97/// | 48 | 10.62 | 1.51 | 12.13 |
98/// | 64 | 10.68 | 1.82 | 12.50 |
99///
100/// The Exit curve *falls* and then flattens: fitting µs/polynomial = `A + B·t + C·t²` gives
101/// `A ≈ 81 µs`, `B ≈ 7.61`, `C ≈ 0.028`, so per share it is `A/t + B + C·t` — the fixed
102/// per-polynomial commitment opening is amortised over `threshold` shares and punishes *low*
103/// thresholds harder than interpolation punishes high ones. The Exit-only optimum is
104/// `t = √(A/C) ≈ 54`, flat within 0.5 % from 48 to 64, and 64 sits within 0.4 % of it.
105///
106/// Under the total column 48 is about 3 % cheaper. That reading is recorded rather than acted on:
107/// it weights one Entry's share generation equally with the Exit work of the 10–30 clients that
108/// Exit serves, which is not where the capacity limit is. Moving the split also moves the
109/// negotiated per-SSA quota and everything derived from it, which is not worth 3 % of a model that
110/// does not describe the bottleneck.
111pub const DEFAULT_POLY_THRESHOLD: u8 = 64;
112
113/// Share loss a polynomial's surplus is sized to absorb, as a reciprocal: `1/5` is 20 %.
114///
115/// The surplus is insurance against *lost* shares, and this is the loss rate it covers. A
116/// polynomial reconstructs from the first `threshold` distinct shares to arrive out of
117/// `threshold + surplus` emitted, so surviving a per-packet loss rate of `p` needs
118/// `surplus >= threshold · p/(1−p)` — which makes the surplus inherently a **ratio** of the
119/// threshold, and `surplus/(threshold + surplus)` the loss rate it tolerates.
120///
121/// At `threshold/4` that is 20 %, and [`default_surplus_for`] rounds *up* so it is a floor rather
122/// than an approximation — see there for why the direction matters.
123const SURPLUS_LOSS_TOLERANCE_DIVISOR: u8 = 4;
124
125/// Shares to emit per polynomial beyond `threshold`, to absorb losses.
126///
127/// **A ratio, evaluated at the threshold actually configured** — see
128// `SURPLUS_LOSS_TOLERANCE_DIVISOR` is deliberately unlinked here and below: it is crate-private, so
129// an intra-doc link from this public function trips `rustdoc::private_intra_doc_links`, which the
130// `nix build .#docs` job builds as an error.
131/// `SURPLUS_LOSS_TOLERANCE_DIVISOR` for why a ratio is the physically meaningful shape.
132///
133/// This used to be a bare constant, `DEFAULT_POLY_THRESHOLD / 2`, evaluated once at the *default*
134/// threshold and then applied whatever the configured one was. Deployments run thresholds from 16
135/// to 64, so a single absolute surplus means wildly different insurance across that range: a flat
136/// 20 covers 24 % loss at threshold 64 but 56 % at threshold 16, where it exceeds the shares it
137/// insures. Since H5 the surplus is billed on purchase rather than on claim, so that over-insurance
138/// is quota an Entry pays for in every deposit.
139///
140/// **Rounds up.** The result is a guarantee of *at least* the documented tolerance, not an
141/// approximation of it: shares are indivisible, so a threshold that is not a multiple of
142/// `SURPLUS_LOSS_TOLERANCE_DIVISOR` has to land on one side of 20 % or the other, and covering
143/// less than the documented rate is the failure this function exists to prevent. Rounding down
144/// undershot for every such threshold — 33 received 8 surplus shares and covered 19.51 % — and gave
145/// thresholds 2 and 3 a surplus of **zero**, i.e. no loss tolerance at all, at and just above
146/// [`MIN_POLY_THRESHOLD`]. Rounding up over-covers by less than one share, which is most visible at
147/// the smallest thresholds (2 → 1 surplus → 33 %) and vanishes as the threshold grows (63 → 16 →
148/// 20.3 %).
149///
150/// The deployed value is unaffected: [`DEFAULT_POLY_THRESHOLD`] is 64, a multiple of the divisor, so
151/// this returns 16 either way and no negotiated quota moves.
152pub const fn default_surplus_for(threshold: u8) -> u8 {
153 // Not `(threshold + DIVISOR - 1) / DIVISOR`: that addition overflows a `u8` for thresholds above
154 // 252, which `MAX_POLY_THRESHOLD` (255) admits, and would fail const evaluation rather than
155 // merely misbehave.
156 threshold.div_ceil(SURPLUS_LOSS_TOLERANCE_DIVISOR)
157}
158
159/// Shares emitted per polynomial beyond [`DEFAULT_POLY_THRESHOLD`], to absorb losses.
160///
161/// The third leg of the deployed split, and like the other two a single value rather than one per
162/// crate: it used to be a literal `20` here and a separately-derived `32` in
163/// `PixGlobalConfig::additional_shares`, which meant [`SsaGeneratorConfig::default`] modelled a
164/// cycle no deployed node ever runs. That mattered little while the surplus was unpriced; it stopped
165/// being harmless once the per-SSA quota started counting it, because the quota is a `const` and had
166/// to pick one of the two.
167///
168/// Only the value of [`default_surplus_for`] at the default threshold. Anything that knows its own
169/// threshold should call that instead — this constant cannot, which is exactly the defect above.
170pub const DEFAULT_SURPLUS_SHARES: u8 = default_surplus_for(DEFAULT_POLY_THRESHOLD);
171
172/// Maximum number of polynomials per SSA supported by the [`SsaReconstructor`].
173pub const MAX_POLYS_PER_SSA: u16 = 16192;
174
175/// Retransmission requests the Entry answers for one SSA cycle.
176///
177/// A cycle's commitment ships as hundreds of packets, and a single lost one strands the whole cycle
178/// (see [`SsaShareGenerator::recommit`]), so the Exit must be able to ask again for the pieces it
179/// never received. This bounds how often it may: one request can legally name every polynomial of
180/// the cycle, and an answer is the entire burst again, so an unbounded count is a packet
181/// amplification lever available to a peer that has already been served.
182///
183/// Eight, because the Exit re-asks on an idle timer rather than once. At the shipped Exit settings
184/// — a 3 s re-request interval inside a 20 s commitment deadline — a cycle can produce about six
185/// attempts, and a peer batching several SSAs per request produces proportionally more against a
186/// scaled deadline. The excess is simply refused: an unanswered request costs the Exit one packet
187/// and this node nothing, which is why the two sides are not required to agree on the figure.
188pub const MAX_COMMITMENT_RETRANSMISSIONS: u8 = 8;
189
190/// Cycles per pseudonym whose commitment material the [`SsaShareGenerator`] retains for
191/// retransmission.
192///
193/// The newest this many are kept and the oldest is evicted, and within a live cache entry that is
194/// the only thing that releases them — emission deliberately does not enter into it. (The entry as a
195/// whole goes on idle eviction, which is a separate lifetime; see below.) Whether a re-sent commitment is
196/// still worth anything is a question about what the peer holds, and none of that is visible from
197/// this side: it completes a cycle out of the shares it has already buffered plus the commitment it
198/// is still missing, on its own delivery deadline. Releasing on drain instead would give up the
199/// repair exactly where it is worth most, a cycle whose every share has gone out and whose only
200/// missing piece is the one a lost packet took.
201///
202/// Eighteen is `MAX_OVERLAPPING_BATCHES` (2) generations of `MAX_SSA_BATCH_SIZE` (9) cycles, the
203/// figures `hopr-transport-session` is built to; they are not visible from here, so the derivation is
204/// written down rather than imported. It leaves a cycle covered for the whole of its own batch plus
205/// one further generation, and the successor gate makes that second generation expensive — a peer
206/// cannot reach it without draining nearly every cycle of the first, by which point it has been
207/// asking for far longer than [`MAX_COMMITMENT_RETRANSMISSIONS`] attempts on any sane re-request
208/// interval allow.
209///
210/// Costs `polynomials_per_ssa × 33 B` per cycle — 270 kB at the deployed dimensions, 4.8 MB per
211/// pseudonym with the cap full. Entry-side state, which is why it is not charged against an Exit's
212/// incoming-Session memory budget: that reserves reconstructor state for Sessions this node *serves*,
213/// and nothing else the generator holds is charged there either.
214///
215/// It lives as long as the pseudonym's entry in the generator's cache, which idle eviction alone
216/// releases — closing a Session clears nothing here. So a node holds this for every pseudonym it has
217/// committed a cycle for within that window, Sessions long closed included, and churn accumulates it
218/// rather than it tracking the live Session count.
219/// What bounds the consequence is that the same entry keeps its polynomial state under exactly the
220/// same rule, and that term is larger by two orders of magnitude: 270 kB here against ~33 MB there,
221/// per cycle. A Session whose cycles are still queued when it closes — the ordinary case, since
222/// draining one takes `polynomials_per_ssa × (threshold + surplus)` shares, 655 k at the deployed
223/// dimensions — leaves both, and this is under 1% of it. Reaching the cap instead means having
224/// drained eighteen cycles, some twelve million shares, by which point the polynomials are gone and
225/// 4.8 MB is the entire residue.
226///
227/// Clearing the entry outright at Session teardown would release both sooner, and is worth doing for
228/// the polynomial term; [`SsaShareGenerator::forget`] exists for it, but no production path calls it
229/// today.
230pub const MAX_RETAINED_COMMITMENT_CYCLES: usize = 18;
231
232/// Minimum SSA polynomial threshold.
233///
234/// A threshold of 1 would make every single share reconstruct its polynomial on its own, so the
235/// secret sharing would hide nothing.
236pub const MIN_POLY_THRESHOLD: u8 = 2;
237
238/// Maximum SSA polynomial threshold supported by the [`SsaReconstructor`].
239///
240/// A byte, because the threshold shares the lower half of the negotiated [`PixParams`] word with
241/// [`SsaGeneratorConfig::surplus_shares`] — see [`PixParams::to_u32`]. The bound is therefore
242/// structural rather than merely checked; the constant exists to name it.
243pub const MAX_POLY_THRESHOLD: u8 = u8::MAX;
244
245/// Protocol-wide floor on [`SsaReconstructorConfig::early_recovery_threshold`].
246///
247/// The Entry refuses a successor `SsaRequest` that arrives before the current batch can plausibly
248/// have been recovered — see [`min_emission_for_early_recovery`]. That boundary is a function of the
249/// *Exit's* `early_recovery_threshold`, and the two sides negotiate [`PixParams`] but not this: it is
250/// a local reconstructor setting on the Exit, and the Entry computing the gate has no way to read it.
251///
252/// So the Entry computes the gate at this floor instead of at its own value, and every Exit is
253/// required to sit at or above it. That turns an unshared value into a shared one without a wire
254/// change, and it is sound in the only direction that matters — an Exit at or above the floor asks at
255/// or after the point the Entry admits, so no conforming pair can fail to communicate. Deriving the
256/// gate from the Entry's own setting instead left two individually valid configurations that could
257/// not talk: an Exit configured lower sent its one-shot request early, the Entry silently dropped it,
258/// and the Session died waiting for a commitment that was deliberately refused.
259///
260/// Equal to the shipped default today, but a separate constant on purpose. The default is a policy
261/// choice an operator may raise; this is a compatibility bound, and *lowering* it is a protocol
262/// break — every Entry still running the old value would refuse the earlier request. Raising it is
263/// equally breaking in the other direction, since Exits at the old floor become unservable.
264///
265/// Enforced where the two halves meet rather than in [`SsaReconstructorConfig`]'s own validator: a
266/// reconstructor exercised on its own has no peer to be incompatible with, and pinning the
267/// early-recovery logic in a unit test needs thresholds well below anything deployable.
268pub const MIN_EARLY_RECOVERY_THRESHOLD: f64 = 0.85;
269
270// The shipped default must itself be admissible under the floor it is checked against. The two are
271// defined independently and only compared at configuration time, so a default lowered below this
272// would ship, validate every hand-written config, and then be rejected the moment an operator wrote
273// the default out explicitly — or worse, be accepted locally and refused by every conforming Entry.
274// Asserted here rather than only in a test because it costs nothing and cannot be skipped.
275const _: () = assert!(
276 SsaReconstructorConfig::DEFAULT_EARLY_RECOVERY_THRESHOLD >= MIN_EARLY_RECOVERY_THRESHOLD,
277 "SsaReconstructorConfig::DEFAULT_EARLY_RECOVERY_THRESHOLD must not sit below MIN_EARLY_RECOVERY_THRESHOLD — a \
278 stock Exit would ask for its next SSA batch before any conforming Entry admits the request"
279);
280
281/// Specification of the Protocol for Incentivization of eXits (PIX) instantiation.
282pub trait PixSpec: Send + Sync + 'static
283where
284 PixScalar<Self>: PrimeField,
285 PixGroup<Self>: Group<Scalar = PixScalar<Self>> + GroupEncoding + Default + CofactorGroup,
286 PixGroupRepr<Self>: std::fmt::Debug + PartialEq + Eq,
287 <PixDigest<Self> as OutputSizeUser>::OutputSize: IsLess<U256>,
288 <PixDigest<Self> as OutputSizeUser>::OutputSize:
289 IsLessOrEqual<<PixDigest<Self> as BlockSizeUser>::BlockSize, Output = True>,
290 <Self::Curve as Curve>::FieldBytesSize: Add<SsaPolyIndexPrefixSize>,
291 <<Self::Curve as Curve>::FieldBytesSize as Add<SsaPolyIndexPrefixSize>>::Output: ArraySize,
292 // hash2curve `hash_to_scalar` bounds for `msg_to_scalar`
293 <<Self::Curve as MapToCurve>::SecurityLevel as Mul<U2>>::Output: Sized,
294 <Self::Curve as MapToCurve>::SecurityLevel: Mul<U2>,
295 <PixDigest<Self> as OutputSizeUser>::OutputSize:
296 IsGreaterOrEqual<Prod<<Self::Curve as MapToCurve>::SecurityLevel, U2>, Output = True>,
297 <Self::Curve as Curve>::FieldBytesSize: NonZero,
298 PixScalar<Self>: Reduce<Array<u8, <Self::Curve as Curve>::FieldBytesSize>>,
299{
300 /// Prime order elliptic curve use for commitments.
301 type Curve: PrimeCurve + CurveArithmetic + GroupDigest;
302 /// Digest used for hashing operations.
303 type Digest: BlockSizeUser + FixedOutput + std::fmt::Debug + Default + HashMarker;
304 /// Pseudonym used to identify groups of SURBs.
305 type Pseudonym: Pseudonym + std::fmt::Debug + Copy + Send + Sync + 'static;
306 /// Stream cipher used to encrypt the SSA shares.
307 type Cipher: StreamCipher + KeyIvInit;
308 /// Deposit address type.
309 type DepositAddress: Copy + for<'a> From<&'a Self::AddressPrivateKey> + Send + Sync + 'static;
310 /// Private key type.
311 type AddressPrivateKey: Clone + Send + Sync + 'static;
312
313 /// Context data used to derive the SSA encryption key.
314 const KEY_DERIVATION_CONTEXT: &'static str = "HASH_SSA_POLY_SHARE";
315 /// Domain separator used to derive the X value of a share.
316 const HASH_SCALAR_DERIVATION_CONTEXT: &'static str = "HASH_SSA_POLY_SHARE_SCALAR";
317 /// Domain separator used to derive the Fiat–Shamir challenge of an [`SsaCommitmentProof`].
318 const HASH_COMMITMENT_PROOF_CONTEXT: &'static str = "HASH_SSA_COMMITMENT_PROOF";
319
320 /// Stable, protocol-versioned hash-to-scalar suite identifier used for
321 /// domain separation. This must be a fixed string — deriving it dynamically
322 /// from Debug output would break wire compatibility when dependency versions
323 /// change formatting.
324 const HASH_TO_SCALAR_SUITE_ID: &'static [u8];
325
326 /// Which curve this spec instantiates, as announced to the peer in [`PixParams`].
327 ///
328 /// Deliberately has no default. It must name the same curve as [`Curve`](Self::Curve), and a
329 /// default would let a new spec inherit a wrong answer silently — the failure it exists to
330 /// prevent is precisely two peers disagreeing about a curve neither of them states.
331 const PIX_SUITE: PixSuite;
332
333 /// Performs conversion of the given `spi` and `msg` into [`PixScalar`] of this spec.
334 fn msg_to_scalar(
335 spi: &SsaPolynomialId<Self::Pseudonym>,
336 msg: impl AsRef<[u8]>,
337 ) -> errors::Result<PixScalar<Self>, Self::Pseudonym>
338 where
339 Self: Sized,
340 {
341 hash_to_scalar::<Self::Curve, ExpandMsgXmd<Self::Digest>, <Self::Curve as Curve>::FieldBytesSize>(
342 &[
343 msg.as_ref(),
344 spi.pseudonym().as_ref(),
345 spi.ssa_index().get().to_be_bytes().as_ref(),
346 spi.poly_index().to_be_bytes().as_ref(),
347 ],
348 &[
349 Self::HASH_TO_SCALAR_SUITE_ID,
350 Self::HASH_SCALAR_DERIVATION_CONTEXT.as_bytes(),
351 ],
352 )
353 .map_err(|_| errors::PixError::InvalidInput)
354 }
355
356 /// Derives the Fiat–Shamir challenge of an [`SsaCommitmentProof`] over the client's
357 /// `ssa_commitment` and the prover's `nonce_commitment`.
358 ///
359 /// `ssa_id` is bound in so that a proof cannot be replayed onto a different SSA index or a
360 /// different Session's pseudonym.
361 ///
362 /// The Exit's own commitment is deliberately **not** bound in. The statement being proven is
363 /// knowledge of `dlog(ssa_commitment)` alone, and the deposit is protected because the Exit's
364 /// secret is what separates that from `dlog(ssa_commitment + exit_commitment)` — which holds
365 /// regardless of what the challenge hashes. Binding it would only prevent reusing one proof for
366 /// the same `ssa_commitment` against two different Exits, and an Entry that reuses its
367 /// commitment does know its discrete log, so that case is honest (reuse is a linkability
368 /// concern, not an exploit).
369 fn commitment_proof_challenge(
370 ssa_id: &SsaId<Self::Pseudonym>,
371 ssa_commitment: &PixGroup<Self>,
372 nonce_commitment: &PixGroup<Self>,
373 ) -> errors::Result<PixScalar<Self>, Self::Pseudonym>
374 where
375 Self: Sized,
376 {
377 let ssa_index = ssa_id.ssa_index().get().to_be_bytes();
378 let ssa_commitment = ssa_commitment.to_bytes();
379 let nonce_commitment = nonce_commitment.to_bytes();
380
381 hash_to_scalar::<Self::Curve, ExpandMsgXmd<Self::Digest>, <Self::Curve as Curve>::FieldBytesSize>(
382 &[
383 ssa_id.pseudonym().as_ref(),
384 ssa_index.as_ref(),
385 ssa_commitment.as_ref(),
386 nonce_commitment.as_ref(),
387 ],
388 &[
389 Self::HASH_TO_SCALAR_SUITE_ID,
390 Self::HASH_COMMITMENT_PROOF_CONTEXT.as_bytes(),
391 ],
392 )
393 .map_err(|_| errors::PixError::InvalidInput)
394 }
395
396 /// Converts `PixGroup` to an address that can be deposited to.
397 ///
398 /// Returns `None` if the conversion is not possible.
399 fn group_to_deposit_address(group: PixGroup<Self>) -> Option<Self::DepositAddress>;
400 /// Convert `PixScalar` to a private key of a deposit address.
401 ///
402 /// Returns `None` if the conversion is not possible.
403 fn scalar_to_private_key(scalar: PixScalar<Self>) -> Option<Self::AddressPrivateKey>;
404}
405
406/// Finite field used to represent the polynomial coefficients.
407pub type PixScalar<S> = <<S as PixSpec>::Curve as CurveArithmetic>::Scalar;
408/// Elliptic curve point used to represent the polynomial coefficient commitments.
409pub type PixGroup<S> = <<S as PixSpec>::Curve as CurveArithmetic>::ProjectivePoint;
410/// Serializable representation of the polynomial coefficient commitments.
411pub type PixGroupRepr<S> = <PixGroup<S> as GroupEncoding>::Repr; // This internally converts to affine
412/// Digest used for hashing operations.
413pub type PixDigest<S> = <S as PixSpec>::Digest;
414
415pub(crate) type CompletedShare<S> =
416 DefaultShare<IdentifierPrimeField<PixScalar<S>>, IdentifierPrimeField<PixScalar<S>>>;
417
418#[inline]
419pub(crate) fn into_completed_share<S: PixSpec>(
420 identifier: PixScalar<S>,
421 share: &PartialSsaShare<S>,
422) -> errors::Result<CompletedShare<S>, S::Pseudonym> {
423 Ok(DefaultShare {
424 identifier: identifier.into(),
425 value: Option::from(PixScalar::<S>::from_repr(share.0))
426 .map(|s: PixScalar<S>| s.into())
427 .ok_or(vsss_rs::Error::InvalidShare)?,
428 })
429}
430
431/// Commitment to the constant term of the polynomial with the given [`SsaPolynomialId`].
432///
433/// ## Why only the constant term
434///
435/// This used to be a full Feldman verifier — a commitment to *every* coefficient — so that each
436/// individual share could be checked the moment it arrived. Classic VSS needs that, because its
437/// shares sit with mutually distrusting parties that reconstruct later. PIX has exactly **one**
438/// shareholder: the Exit holds every share, reconstructs locally, is the whole quorum, and
439/// consumes only the recovered constant term. Checking `a₀·G == C₀` once, on the reconstructed
440/// part, is therefore deterministic and exact for the property actually relied upon — and costs
441/// one scalar multiplication per polynomial instead of `threshold` per share.
442///
443/// What the per-coefficient commitments did buy was fault *isolation*: one bad share could be
444/// rejected on arrival and its slot refilled from the surplus. That is given up. A share that
445/// fails to reconstruct implies a dishonest or broken Entry — it travels inside a
446/// Sphinx-authenticated SURB, and its decryption key is fixed by the very acknowledgement
447/// challenge it is filed under, so there is no benign path to a corrupt one — and such an Entry
448/// has already funded the deposit it thereby forfeits. The price paid is detection latency:
449/// a dishonest Entry is caught on the `threshold`-th share of a polynomial rather than the first.
450///
451/// [`surplus_shares`](SsaGeneratorConfig::surplus_shares) still absorbs *lost* shares, since
452/// reconstruction starts at the first `threshold` distinct shares that arrive.
453#[derive(Debug, Clone, PartialEq, Eq)]
454#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
455pub struct SsaPartCommitment<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
456 pub(crate) spi: SsaPolynomialId<P>,
457 #[cfg_attr(feature = "serde", serde(with = "elliptic_curve_tools::group"))]
458 pub(crate) constant_term: PixGroup<S>,
459}
460
461impl<S: PixSpec> SsaPartCommitment<S, S::Pseudonym> {
462 /// Creates a commitment from an **already decoded and subgroup-checked** group element.
463 ///
464 /// The only decode is [`decode_commitment`](Self::decode_commitment), performed once when the
465 /// commitment arrives on the wire. Decompression requires a modular square root and is the
466 /// dominant per-commitment cost, so nothing here decodes a second time.
467 #[inline]
468 pub fn from_decoded_commitment(spi: SsaPolynomialId<S::Pseudonym>, constant_term: PixGroup<S>) -> Self {
469 Self { spi, constant_term }
470 }
471
472 /// Returns the [`SsaPolynomialId`] of the polynomial this commitment belongs to.
473 #[inline]
474 pub fn spi(&self) -> &SsaPolynomialId<S::Pseudonym> {
475 &self.spi
476 }
477
478 /// Returns the commitment to the constant term of the polynomial.
479 #[inline]
480 pub fn constant_term(&self) -> &PixGroup<S> {
481 &self.constant_term
482 }
483
484 /// Checks a reconstructed constant term against this commitment.
485 ///
486 /// This is the *entire* verification the Exit performs on a polynomial, and it happens once,
487 /// after `threshold` shares have been interpolated. A mismatch means at least one of those
488 /// shares did not come from the committed polynomial; it does not say which.
489 #[inline]
490 pub fn verify_reconstructed(&self, secret: &PixScalar<S>) -> bool {
491 PixGroup::<S>::mul_by_generator(secret) == self.constant_term
492 }
493
494 /// Decodes a single serialized coefficient commitment into a group element.
495 ///
496 /// Rejects bytes that do not decode, and points outside the prime-order subgroup: Baby JubJub
497 /// has cofactor 8, so small-order points can pass the plain on-curve check.
498 ///
499 /// No value-based filtering is applied — a coefficient commitment equal to the generator
500 /// validly represents scalar coefficient 1 and must be preserved.
501 pub fn decode_commitment(commitment: &PixGroupRepr<S>) -> errors::Result<PixGroup<S>, S::Pseudonym> {
502 Option::<PixGroup<S>>::from(PixGroup::<S>::from_bytes(commitment))
503 .filter(|pt| bool::from(pt.is_torsion_free()))
504 .ok_or(errors::PixError::InvalidInput)
505 }
506}
507
508#[cfg(test)]
509pub(crate) mod tests {
510 use hopr_types::{
511 crypto::{
512 crypto_traits,
513 prelude::{ChainKeypair, Keypair, PublicKey, Secp256k1, SimplePseudonym},
514 },
515 primitive::prelude::Address,
516 };
517 use vsss_rs::{
518 ParticipantIdGenerator, ReadableShareSet, ShareVerifierGroup,
519 elliptic_curve::{Field, rand_core::CryptoRng},
520 feldman,
521 };
522
523 use super::*;
524 use crate::types::SsaId;
525
526 #[derive(Debug, Copy, Clone, PartialEq, Eq, Default, Hash, Ord, PartialOrd)]
527 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
528 pub struct TestSpec;
529
530 impl PixSpec for TestSpec {
531 type AddressPrivateKey = ChainKeypair;
532 type Cipher = hopr_types::crypto::primitives::ChaCha20;
533 type Curve = hopr_types::crypto::primitives::Secp256k1;
534 type DepositAddress = Address;
535 type Digest = hopr_types::crypto::primitives::Blake3;
536 type Pseudonym = SimplePseudonym;
537
538 const HASH_TO_SCALAR_SUITE_ID: &'static [u8] = b"Secp256k1_XMD:BLAKE3_SSWU_RO_";
539 const PIX_SUITE: PixSuite = PixSuite::Secp256k1;
540
541 fn group_to_deposit_address(group: PixGroup<Self>) -> Option<Self::DepositAddress> {
542 PublicKey::try_from(group.to_affine()).ok().map(|pk| pk.to_address())
543 }
544
545 fn scalar_to_private_key(scalar: PixScalar<Self>) -> Option<Self::AddressPrivateKey> {
546 ChainKeypair::from_secret(scalar.to_bytes().as_ref()).ok()
547 }
548 }
549
550 type Share<S> = DefaultShare<IdentifierPrimeField<PixScalar<S>>, IdentifierPrimeField<PixScalar<S>>>;
551 type StandardShamirResult<S> = (Vec<Share<S>>, Vec<ShareVerifierGroup<PixGroup<S>>>);
552
553 fn standard_shamir_generate<S: PixSpec>(
554 secret: PixScalar<S>,
555 t: usize,
556 x: &[PixScalar<S>],
557 mut rng: impl CryptoRng,
558 ) -> anyhow::Result<StandardShamirResult<S>> {
559 anyhow::ensure!(t > 0, "t must be greater than 0");
560 anyhow::ensure!(x.len() >= t, "x must have at least t elements");
561
562 let (shares, verifier_set) =
563 feldman::split_secret_with_participant_generators::<Share<S>, ShareVerifierGroup<PixGroup<S>>>(
564 t,
565 x.len(),
566 &secret.into(),
567 None,
568 &mut rng,
569 &[ParticipantIdGenerator::list(
570 &x.iter().map(|x| (*x).into()).collect::<Vec<_>>(),
571 )],
572 )
573 .map_err(anyhow::Error::msg)?;
574
575 Ok((shares, verifier_set))
576 }
577
578 fn test_spi() -> anyhow::Result<SsaPolynomialId<SimplePseudonym>> {
579 Ok(SsaPolynomialId::new(
580 SsaId::new(SimplePseudonym::try_from([0u8; 10].as_ref())?, 1.try_into()?),
581 1,
582 ))
583 }
584
585 /// The commitment PIX keeps must be exactly the constant-term entry of a standard Feldman
586 /// verifier set, and interpolating `threshold` of the standard shares must open it.
587 ///
588 /// This is the replacement for the old per-share verification test: the property the Exit now
589 /// relies on is not "every share lies on the committed polynomial" but "the reconstructed
590 /// constant term is the one that was committed to".
591 #[test]
592 fn ssa_part_commitment_must_correspond_to_standard() -> anyhow::Result<()> {
593 const THRESHOLD: usize = 10;
594
595 let mut rng = rand::rng();
596 let secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut rng);
597 let spi = test_spi()?;
598
599 let x = (0..=20_u32)
600 .map(|i| TestSpec::msg_to_scalar(&spi, i.to_be_bytes()).unwrap())
601 .collect::<Vec<_>>();
602
603 let (shares, verifier) = standard_shamir_generate::<TestSpec>(secret, THRESHOLD, &x, &mut rng)?;
604 assert_eq!(shares.len(), x.len());
605 // [generator, C₀, C₁ … C_{t-1}] — PIX now keeps only the second entry.
606 assert_eq!(verifier.len(), THRESHOLD + 1);
607
608 let commitment = SsaPartCommitment::<TestSpec>::from_decoded_commitment(spi, verifier[1].0);
609 assert_eq!(&verifier[1].0, commitment.constant_term());
610 assert_eq!(&spi, commitment.spi());
611
612 // Exactly `threshold` shares suffice, and they open the commitment.
613 let reconstructed = shares[..THRESHOLD].to_vec().combine().map_err(anyhow::Error::msg)?.0;
614 assert_eq!(secret, reconstructed, "threshold shares must recover the secret");
615 assert!(commitment.verify_reconstructed(&reconstructed));
616
617 // Any other scalar must not.
618 assert!(!commitment.verify_reconstructed(&(reconstructed + PixScalar::<TestSpec>::ONE)));
619
620 Ok(())
621 }
622
623 /// A single corrupted share is not detected on arrival — that is the cost of dropping the
624 /// per-coefficient commitments — but it does surface at reconstruction.
625 #[test]
626 fn ssa_part_commitment_must_reject_a_corrupted_share_set() -> anyhow::Result<()> {
627 const THRESHOLD: usize = 10;
628
629 let mut rng = rand::rng();
630 let secret = crypto_traits::elliptic_curve::Scalar::<Secp256k1>::random(&mut rng);
631 let spi = test_spi()?;
632
633 let x = (0..=20_u32)
634 .map(|i| TestSpec::msg_to_scalar(&spi, i.to_be_bytes()).unwrap())
635 .collect::<Vec<_>>();
636
637 let (mut shares, verifier) = standard_shamir_generate::<TestSpec>(secret, THRESHOLD, &x, &mut rng)?;
638 let commitment = SsaPartCommitment::<TestSpec>::from_decoded_commitment(spi, verifier[1].0);
639
640 *shares[3].value.as_mut() += PixScalar::<TestSpec>::ONE;
641
642 let reconstructed = shares[..THRESHOLD].to_vec().combine().map_err(anyhow::Error::msg)?.0;
643 assert_ne!(secret, reconstructed);
644 assert!(
645 !commitment.verify_reconstructed(&reconstructed),
646 "a corrupted share must make the reconstructed part fail its commitment"
647 );
648
649 Ok(())
650 }
651
652 /// A commitment equal to the generator represents constant term 1 and must survive decoding —
653 /// no value-based filtering is applied.
654 #[test]
655 fn decode_commitment_accepts_a_generator_valued_commitment() -> errors::Result<(), SimplePseudonym> {
656 let generator = PixGroup::<TestSpec>::generator();
657 let decoded = SsaPartCommitment::<TestSpec>::decode_commitment(&generator.to_bytes())?;
658 assert_eq!(generator, decoded);
659 Ok(())
660 }
661}