Skip to main content

hopr_protocol_pix/
types.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    num::NonZero,
4    ops::Add,
5};
6
7use hopr_types::{
8    crypto::{
9        crypto_traits::{
10            self, KeyIvInit, StreamCipher,
11            elliptic_curve::{Curve, PrimeField},
12        },
13        prelude::HalfKey,
14        primitives::Blake3,
15    },
16    primitive::{
17        hybrid_array::{
18            Array, ArraySize,
19            typenum::{Sum, U, Unsigned},
20        },
21        prelude::{BytesRepresentable, GeneralError},
22    },
23};
24
25use crate::{
26    ExitAcknowledgementShareProcessor, Group, GroupEncoding, PixGroup, PixGroupRepr, PixScalar, PixSpec,
27    SsaPartCommitment, errors, errors::PixError,
28};
29
30/// Raw zeroable SSA Index.
31pub type RawSsaIndex = u32;
32
33/// Type used to index Session Stealth Addresses (SSA).
34///
35/// Note that SSA Index starts with 1.
36pub type SsaIndex = NonZero<RawSsaIndex>;
37
38/// Type used to index polynomials that reconstruct parts of a Session Stealth Addresses (SSA).
39///
40/// The index is 0-based.
41pub type PolynomialIndex = u16;
42
43/// Type used to index coefficients in a polynomial.
44///
45/// The index is 0-based.
46pub type CoefficientIndex = u16;
47
48/// Byte size of the [`SsaIndex`] when serialized as a big-endian prefix.
49const SSA_INDEX_SIZE: usize = size_of::<SsaIndex>();
50
51/// Byte size of the [`PolynomialIndex`] when serialized as a big-endian prefix.
52const POLY_INDEX_SIZE: usize = size_of::<PolynomialIndex>();
53
54/// [`typenum`]: hopr_types::primitive::hybrid_array::typenum
55/// Size of the [`SsaIndex`] and [`PolynomialIndex`] prefix prepended to the encrypted share.
56///
57/// Derived at compile time from `size_of::<SsaIndex>() + size_of::<PolynomialIndex>()` via
58/// `typenum`'s `Const`/`ToUInt` machinery. The matching runtime invariant is asserted by
59/// the `size_of_indices_must_match` unit test below.
60pub type SsaPolyIndexPrefixSize = Sum<U<SSA_INDEX_SIZE>, U<POLY_INDEX_SIZE>>;
61
62/// Uniquely identifies a Session Stealth Address (SSA).
63///
64/// This consists of a pseudonym and [`SsaIndex`].
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67pub struct SsaId<P> {
68    pseudonym: P,
69    ssa_index: SsaIndex,
70}
71
72impl<P> SsaId<P> {
73    /// Creates a new `SsaId` with the given pseudonym and SSA index.
74    pub fn new(pseudonym: P, ssa_index: SsaIndex) -> Self {
75        Self { pseudonym, ssa_index }
76    }
77
78    /// Pseudonym part of the `HoprSenderId`.
79    #[inline]
80    pub fn pseudonym(&self) -> &P {
81        &self.pseudonym
82    }
83
84    /// Index (i-value) of the Session Stealth Address (SSA).
85    #[inline]
86    pub fn ssa_index(&self) -> SsaIndex {
87        self.ssa_index
88    }
89}
90
91impl<P: std::fmt::Display> std::fmt::Display for SsaId<P> {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{}-ssa#{}", self.pseudonym, self.ssa_index)
94    }
95}
96
97/// Uniquely identifies a polynomial that allows forming a Session Stealth Address (SSA) corresponding
98/// to a specific Session.
99///
100/// The index consists of the following parts:
101/// 1. The Pseudonym part of the `HoprSenderId` - fixed for the given Session.
102/// 2. Index (i) of the Session Stealth Address (SSA)
103/// 3. Index (j) of the polynomial used to reconstruct the portion of the SSA.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
106pub struct SsaPolynomialId<P> {
107    id: SsaId<P>,
108    poly_index: PolynomialIndex,
109}
110
111/// Polynomial coefficient commitments laid out coefficient-major, which is how the wire messages
112/// chunk them.
113///
114/// PIX only ever populates [`CONSTANT_TERM_COEFFICIENT`](crate::CONSTANT_TERM_COEFFICIENT), so in
115/// practice this holds exactly one key. The map shape is retained because the wire format still
116/// admits higher coefficient indices — see [`SsaPartCommitment`] for why
117/// none are produced.
118pub type TransposedVerifiers<S> = HashMap<CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>>;
119
120impl<P> SsaPolynomialId<P> {
121    /// Creates a new `SsaPolynomialId` with the given `SsaId` and polynomial index.
122    pub fn new(id: SsaId<P>, poly_index: PolynomialIndex) -> Self {
123        Self { id, poly_index }
124    }
125
126    /// Pseudonym part of the `HoprSenderId`.
127    #[inline]
128    pub fn pseudonym(&self) -> &P {
129        &self.id.pseudonym
130    }
131
132    /// Index (i-value) of the Session Stealth Address (SSA).
133    #[inline]
134    pub fn ssa_index(&self) -> SsaIndex {
135        self.id.ssa_index
136    }
137
138    /// Index (j-value) of the polynomial used to reconstruct the portion of the SSA.
139    #[inline]
140    pub fn poly_index(&self) -> PolynomialIndex {
141        self.poly_index
142    }
143}
144
145impl<P> AsRef<SsaId<P>> for SsaPolynomialId<P> {
146    fn as_ref(&self) -> &SsaId<P> {
147        &self.id
148    }
149}
150
151impl<P: std::fmt::Display> std::fmt::Display for SsaPolynomialId<P> {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write!(f, "{}:{}", self.id, self.poly_index)
154    }
155}
156
157/// Share of a polynomial used to reconstruct a portion of the Session Stealth Address (SSA).
158///
159/// This corresponds to the `P_ij(X)` of the polynomial used to reconstruct the j-th portion of i-th SSA
160/// at some value `X`.
161///
162/// The struct does not hold the `X` value, as it is usually computed from the
163/// [`nonce`](TaggedEncryptedPartialSsaShare).
164///
165/// See [`TaggedEncryptedPartialSsaShare`] and [`EncryptedPartialSsaShare`] for more details.
166#[derive(Clone, Default, Hash, PartialEq, Eq)]
167pub struct PartialSsaShare<S: PixSpec>(pub(crate) <PixScalar<S> as PrimeField>::Repr);
168
169impl<S: PixSpec> std::fmt::Debug for PartialSsaShare<S> {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("PartialSsaShare").finish_non_exhaustive()
172    }
173}
174
175impl<S: PixSpec> PartialSsaShare<S> {
176    /// Encrypts this partial SSA share using the given acknowledgement [`HalfKey`].
177    pub fn encrypt(
178        mut self,
179        spi: &SsaPolynomialId<S::Pseudonym>,
180        ack_key: &HalfKey,
181    ) -> errors::Result<EncryptedPartialSsaShare<S>, S::Pseudonym>
182    where
183        FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
184        EncShareSize<S>: ArraySize,
185    {
186        let mut cipher = derive_ssa_encryption_key::<S>(spi, ack_key)?;
187        cipher.apply_keystream(self.0.as_mut());
188
189        let mut out = Array::<u8, EncShareSize<S>>::default();
190        out[0..size_of::<SsaIndex>()].copy_from_slice(&spi.ssa_index().get().to_be_bytes());
191        out[size_of::<SsaIndex>()..size_of::<SsaIndex>() + size_of::<PolynomialIndex>()]
192            .copy_from_slice(&spi.poly_index().to_be_bytes());
193        out[size_of::<SsaIndex>() + size_of::<PolynomialIndex>()..].copy_from_slice(self.0.as_ref());
194        Ok(EncryptedPartialSsaShare(out))
195    }
196}
197
198impl<S: PixSpec> AsRef<<PixScalar<S> as PrimeField>::Repr> for PartialSsaShare<S> {
199    fn as_ref(&self) -> &<PixScalar<S> as PrimeField>::Repr {
200        &self.0
201    }
202}
203
204fn derive_ssa_encryption_key<S: PixSpec>(
205    spi: &SsaPolynomialId<S::Pseudonym>,
206    ack: &HalfKey,
207) -> errors::Result<S::Cipher, S::Pseudonym> {
208    let mut output = Blake3::new_derive_key(S::KEY_DERIVATION_CONTEXT)
209        .update_reader(ack.as_ref())
210        .and_then(|h| h.update_reader(spi.id.pseudonym.as_ref()))
211        .and_then(|h| h.update_reader(spi.id.ssa_index.get().to_be_bytes().as_ref()))
212        .and_then(|h| h.update_reader(spi.poly_index.to_be_bytes().as_ref()))
213        .map_err(|_| hopr_types::crypto::errors::CryptoError::InvalidInputValue("invalid ssa encryption key"))?
214        .finalize_xof();
215
216    let mut key = crypto_traits::Key::<S::Cipher>::default();
217    let mut iv = crypto_traits::Iv::<S::Cipher>::default();
218
219    let mut out = vec![0u8; key.len() + iv.len()];
220    output.fill(&mut out);
221
222    let (v_iv, v_key) = out.split_at(iv.len());
223    iv.copy_from_slice(v_iv);
224    key.copy_from_slice(v_key);
225
226    Ok(S::Cipher::new(&key, &iv))
227}
228
229/// Size of the field-bytes portion of the encrypted share.
230pub type FieldBytesSize<S> = <<S as PixSpec>::Curve as Curve>::FieldBytesSize;
231
232/// Total size of the [`EncryptedPartialSsaShare`] internal representation:
233/// `SsaPolyIndexPrefixSize` + FieldBytesSize.
234pub type EncShareSize<S> = Sum<FieldBytesSize<S>, SsaPolyIndexPrefixSize>;
235
236/// Contains an encrypted partial Session Stealth Address (SSA) share.
237///
238/// The internal byte layout is:
239/// 1. [`SsaIndex`] (big-endian, 4 bytes)
240/// 2. [`PolynomialIndex`] (big-endian, 2 bytes)
241/// 3. The encrypted scalar share (FieldBytesSize bytes)
242///
243/// This share can be decrypted to [`PartialSsaShare`]
244/// to be verified and used for reconstruction.
245#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
246pub struct EncryptedPartialSsaShare<S: PixSpec>(Array<u8, EncShareSize<S>>)
247where
248    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
249    EncShareSize<S>: ArraySize;
250
251#[cfg(feature = "serde")]
252impl<S: PixSpec> serde::Serialize for EncryptedPartialSsaShare<S>
253where
254    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
255    EncShareSize<S>: ArraySize,
256{
257    fn serialize<Ser: serde::Serializer>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> {
258        serde_bytes::Bytes::new(self.0.as_ref()).serialize(serializer)
259    }
260}
261
262#[cfg(feature = "serde")]
263impl<'de, S: PixSpec> serde::Deserialize<'de> for EncryptedPartialSsaShare<S>
264where
265    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
266    EncShareSize<S>: ArraySize,
267{
268    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
269        let bytes = <&serde_bytes::Bytes as serde::Deserialize>::deserialize(deserializer)?;
270        Self::try_from(bytes.as_ref()).map_err(serde::de::Error::custom)
271    }
272}
273
274impl<S: PixSpec> EncryptedPartialSsaShare<S>
275where
276    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
277    EncShareSize<S>: ArraySize,
278{
279    /// [`SsaIndex`] and [`PolynomialIndex`] embedded in this encrypted share.
280    ///
281    /// Returns `None` if the share is empty.
282    pub fn indices(&self) -> Option<(SsaIndex, PolynomialIndex)> {
283        let ssa_index: Option<SsaIndex> =
284            RawSsaIndex::from_be_bytes(self.0[0..size_of::<RawSsaIndex>()].try_into().ok()?)
285                .try_into()
286                .ok();
287
288        let poly_index = PolynomialIndex::from_be_bytes(
289            self.0[size_of::<RawSsaIndex>()..size_of::<RawSsaIndex>() + size_of::<PolynomialIndex>()]
290                .try_into()
291                .ok()?,
292        );
293
294        ssa_index.map(|ssa_index| (ssa_index, poly_index))
295    }
296
297    /// Tries to decrypt the encrypted partial SSA share using the provided pseudonym and
298    /// acknowledgement [`HalfKey`].
299    ///
300    /// NOTE: that the share must be verified by the reconstructor.
301    pub(crate) fn decrypt(
302        self,
303        pseudonym: &S::Pseudonym,
304        ack_key: &HalfKey,
305    ) -> errors::Result<PartialSsaShare<S>, S::Pseudonym> {
306        if let Some((ssa_index, poly_index)) = self.indices() {
307            let spi = SsaPolynomialId::new(SsaId::new(*pseudonym, ssa_index), poly_index);
308            let mut cipher = derive_ssa_encryption_key::<S>(&spi, ack_key)?;
309            let mut share = <PixScalar<S> as PrimeField>::Repr::default();
310            share.copy_from_slice(&self.0[SsaPolyIndexPrefixSize::USIZE..]);
311            cipher.apply_keystream(share.as_mut());
312            Ok(PartialSsaShare(share))
313        } else {
314            Err(PixError::ShareIsEmpty)
315        }
316    }
317
318    /// Returns true if the encrypted share is empty (all zeroes or zero SSA index).
319    #[inline]
320    pub fn is_empty(&self) -> bool {
321        self.0 == Array::<u8, EncShareSize<S>>::default() || self.indices().is_none()
322    }
323}
324
325impl<S: PixSpec + Copy> Copy for EncryptedPartialSsaShare<S>
326where
327    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
328    EncShareSize<S>: ArraySize,
329    Array<u8, EncShareSize<S>>: Copy,
330{
331}
332
333impl<S: PixSpec> AsRef<[u8]> for EncryptedPartialSsaShare<S>
334where
335    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
336    EncShareSize<S>: ArraySize,
337{
338    fn as_ref(&self) -> &[u8] {
339        &self.0
340    }
341}
342
343impl<'a, S: PixSpec> TryFrom<&'a [u8]> for EncryptedPartialSsaShare<S>
344where
345    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
346    EncShareSize<S>: ArraySize,
347{
348    type Error = GeneralError;
349
350    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
351        Array::try_from(value)
352            .map(Self)
353            .map_err(|_| GeneralError::ParseError("EncryptedPartialSsaShare.size".into()))
354    }
355}
356
357impl<S: PixSpec> BytesRepresentable for EncryptedPartialSsaShare<S>
358where
359    FieldBytesSize<S>: Add<SsaPolyIndexPrefixSize>,
360    EncShareSize<S>: ArraySize,
361{
362    const SIZE: usize = EncShareSize::<S>::USIZE;
363}
364
365/// This is a wrapped [`EncryptedPartialSsaShare`] extracted from a specific SURB (presumably along with the associated
366/// `AcknowledgementChallenge`).
367#[derive(Clone, Debug, PartialEq, Eq)]
368#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
369#[cfg_attr(
370    feature = "serde",
371    serde(bound(
372        serialize = "P: serde::Serialize, T: serde::Serialize, EncryptedPartialSsaShare<S>: serde::Serialize",
373        deserialize = "P: serde::Deserialize<'de>, T: serde::Deserialize<'de>, EncryptedPartialSsaShare<S>: \
374                       serde::Deserialize<'de>"
375    ))
376)]
377pub struct TaggedEncryptedPartialSsaShare<S: PixSpec, P = <S as PixSpec>::Pseudonym, T = PixScalar<S>> {
378    /// Pseudonym of the sender that created the encrypted partial SSA share.
379    pub pseudonym: P,
380    /// Nonce used to generate the encrypted partial SSA share.
381    ///
382    /// This must typically be convertible to [`PixScalar`] of `S`.
383    pub nonce: T,
384    /// Encrypted partial SSA share.
385    pub partial_share: EncryptedPartialSsaShare<S>,
386}
387
388impl<S: PixSpec> TaggedEncryptedPartialSsaShare<S, S::Pseudonym, PixScalar<S>> {
389    /// Creates a new tagged encrypted partial SSA share from the
390    /// [encrypted partial SSA share](TaggedEncryptedPartialSsaShare::partial_share) that must correspond
391    /// to the given `pseudonym` and `nonce`.
392    pub fn new(
393        pseudonym: S::Pseudonym,
394        nonce: &impl AsRef<[u8]>,
395        partial_share: EncryptedPartialSsaShare<S>,
396    ) -> errors::Result<Self, S::Pseudonym> {
397        if let Some((ssa_index, poly_index)) = partial_share.indices() {
398            Ok(Self {
399                pseudonym,
400                nonce: S::msg_to_scalar(
401                    &SsaPolynomialId::new(SsaId::new(pseudonym, ssa_index), poly_index),
402                    nonce.as_ref(),
403                )?,
404                partial_share,
405            })
406        } else {
407            Err(PixError::ShareIsEmpty)
408        }
409    }
410}
411
412impl<S: PixSpec> TaggedEncryptedPartialSsaShare<S, S::Pseudonym, PixScalar<S>> {
413    /// SSA ID this share corresponds to.
414    ///
415    /// Returns `None` if the share is empty.
416    #[inline]
417    pub fn ssa_id(&self) -> Option<SsaId<S::Pseudonym>> {
418        self.partial_share
419            .indices()
420            .map(|(ssa_index, _)| SsaId::new(self.pseudonym, ssa_index))
421    }
422
423    /// SSA polynomial ID this share corresponds to.
424    ///
425    /// Returns `None` if the share is empty.
426    #[inline]
427    pub fn ssa_polynomial_id(&self) -> Option<SsaPolynomialId<S::Pseudonym>> {
428        self.partial_share
429            .indices()
430            .map(|(ssa_index, poly_index)| SsaPolynomialId::new(SsaId::new(self.pseudonym, ssa_index), poly_index))
431    }
432}
433
434impl<S: PixSpec + Copy, P: Copy, T: Copy> Copy for TaggedEncryptedPartialSsaShare<S, P, T> where
435    EncryptedPartialSsaShare<S>: Copy
436{
437}
438
439/// Contains a generated share from a specific previously committed SSA.
440#[derive(Clone, PartialEq, Eq)]
441pub struct GeneratedShare<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
442    /// ID of the polynomial corresponding to the partial SSA share.
443    pub id: SsaPolynomialId<P>,
444    /// Generated partial SSA share.
445    pub share: PartialSsaShare<S>,
446}
447
448impl<S: PixSpec, P: std::fmt::Debug> std::fmt::Debug for GeneratedShare<S, P> {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        f.debug_struct("GeneratedShare")
451            .field("id", &self.id)
452            .finish_non_exhaustive()
453    }
454}
455
456impl<S: PixSpec> GeneratedShare<S, S::Pseudonym> {
457    /// Convenience method to [encrypt](PartialSsaShare::encrypt) the share using an acknowledgement [`HalfKey`].
458    #[inline]
459    pub fn encrypt(self, ack: &HalfKey) -> errors::Result<EncryptedPartialSsaShare<S>, S::Pseudonym> {
460        self.share.encrypt(&self.id, ack)
461    }
462}
463
464/// Wire form of a [`PixScalar`].
465type ScalarRepr<S> = <PixScalar<S> as PrimeField>::Repr;
466
467/// Proof that whoever published an [`SsaCommitment`] knows its discrete logarithm.
468///
469/// ## Why this exists
470///
471/// The SSA deposit key is `s + e`, where `s` is the sum of the Entry's polynomial constant terms and
472/// `e` is the Exit's commitment secret. The deposit is safe precisely because neither party knows
473/// the sum. But the Exit publishes `e·G` *first* — the `SsaRequest` message carries it so the Entry
474/// can derive the address it has to fund — and without this proof nothing stops a malicious Entry
475/// from picking a `w` it knows, publishing constant terms that sum to `w·G − e·G`, and ending up
476/// with a deposit address whose key is `w`. It could then sweep its own deposit while the
477/// polynomial whose constant term it does *not* know never yields a valid share, so the Exit is
478/// never paid — and because the Entry chooses the order in which polynomials are drained, it can
479/// place that one last and be served nearly the whole cycle first.
480///
481/// Requiring proof of knowledge of `s` closes this, and the case analysis is exhaustive:
482///
483/// * if the Entry can produce the proof it knows `s`, and then `s + e` is out of reach because `e` is not;
484/// * if it cannot, the Exit rejects the SSA before it ever publishes a deposit address.
485///
486/// The proof is over the *sum* rather than per polynomial on purpose: an individual constant-term
487/// commitment whose discrete log the Entry does not know is harmless, as long as the sum's is known,
488/// because the deposit key is then still unreachable.
489///
490/// The Exit needs no matching proof as long as it keeps committing first — it cannot adapt `e·G` to
491/// the Entry's commitment, so the symmetric attack is unavailable to it. Reversing the message order
492/// would move the exploit to the Exit and oblige *it* to prove instead.
493///
494/// ## Construction
495///
496/// A standard non-interactive Schnorr proof of knowledge: `R = r·G`,
497/// `c = H(ssa_id ‖ commitment ‖ R)`, `z = r + c·s`, verified as `z·G == R + c·commitment`. Neither
498/// component is secret, so both travel and print in the clear.
499#[derive(Debug)]
500#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
501#[cfg_attr(
502    feature = "serde",
503    serde(bound(
504        serialize = "PixGroupRepr<S>: serde::Serialize, ScalarRepr<S>: serde::Serialize",
505        deserialize = "PixGroupRepr<S>: serde::Deserialize<'de>, ScalarRepr<S>: serde::Deserialize<'de>"
506    ))
507)]
508pub struct SsaCommitmentProof<S: PixSpec> {
509    /// Commitment to the proof nonce, `R = r·G`.
510    nonce_commitment: PixGroupRepr<S>,
511    /// Response, `z = r + c·s`.
512    response: ScalarRepr<S>,
513}
514
515// Both components are plain byte arrays, so these are unconditional in `S`. Deriving them instead
516// would demand `S: Clone + PartialEq + Eq`, which callers generic only over `PixSpec` cannot supply.
517impl<S: PixSpec> Clone for SsaCommitmentProof<S> {
518    fn clone(&self) -> Self {
519        *self
520    }
521}
522
523impl<S: PixSpec> Copy for SsaCommitmentProof<S> {}
524
525impl<S: PixSpec> PartialEq for SsaCommitmentProof<S> {
526    fn eq(&self, other: &Self) -> bool {
527        self.nonce_commitment.as_ref() == other.nonce_commitment.as_ref()
528            && AsRef::<[u8]>::as_ref(&self.response) == AsRef::<[u8]>::as_ref(&other.response)
529    }
530}
531
532impl<S: PixSpec> Eq for SsaCommitmentProof<S> {}
533
534impl<S: PixSpec> SsaCommitmentProof<S> {
535    /// Byte size of the serialized proof: the nonce commitment followed by the response.
536    pub const SIZE: usize = size_of::<PixGroupRepr<S>>() + size_of::<ScalarRepr<S>>();
537
538    /// Proves knowledge of `secret`, the discrete logarithm of `ssa_commitment`.
539    pub fn prove(
540        ssa_id: &SsaId<S::Pseudonym>,
541        secret: &PixScalar<S>,
542        ssa_commitment: &PixGroup<S>,
543    ) -> errors::Result<Self, S::Pseudonym> {
544        // The nonce must be freshly random for every proof. Two proofs sharing an `r` but having
545        // different challenges expose `secret` as `(z₁ − z₂)/(c₁ − c₂)`, so this must never be
546        // derived from the SSA id, the commitment, or anything else that repeats.
547        let nonce =
548            <PixScalar<S> as crypto_traits::elliptic_curve::Field>::random(&mut hopr_types::crypto_random::rng());
549        let nonce_commitment = PixGroup::<S>::mul_by_generator(&nonce);
550        let challenge = S::commitment_proof_challenge(ssa_id, ssa_commitment, &nonce_commitment)?;
551
552        Ok(Self {
553            nonce_commitment: nonce_commitment.to_bytes(),
554            response: (nonce + challenge * *secret).to_repr(),
555        })
556    }
557
558    /// Checks the proof against the `ssa_commitment` it is supposed to open.
559    ///
560    /// Returns `false` for anything malformed as well as for a genuine verification failure — a
561    /// caller cannot act differently on the two, since both mean the commitment is unusable.
562    pub fn verify(&self, ssa_id: &SsaId<S::Pseudonym>, ssa_commitment: &PixGroup<S>) -> bool {
563        let Some(nonce_commitment) = Option::<PixGroup<S>>::from(PixGroup::<S>::from_bytes(&self.nonce_commitment))
564        else {
565            return false;
566        };
567        // Same subgroup check the coefficient commitments get: on a curve with a cofactor a
568        // small-order `R` would let the verification equation hold for a wrong response.
569        if !bool::from(crate::CofactorGroup::is_torsion_free(&nonce_commitment)) {
570            return false;
571        }
572        let Some(response) = Option::<PixScalar<S>>::from(PixScalar::<S>::from_repr(self.response)) else {
573            return false;
574        };
575        let Ok(challenge) = S::commitment_proof_challenge(ssa_id, ssa_commitment, &nonce_commitment) else {
576            return false;
577        };
578
579        PixGroup::<S>::mul_by_generator(&response) == nonce_commitment + *ssa_commitment * challenge
580    }
581
582    /// Serializes the proof as `nonce_commitment ‖ response`, exactly [`Self::SIZE`] bytes.
583    pub fn to_bytes(&self) -> Vec<u8> {
584        let mut out = Vec::with_capacity(Self::SIZE);
585        out.extend_from_slice(self.nonce_commitment.as_ref());
586        out.extend_from_slice(self.response.as_ref());
587        out
588    }
589
590    /// Parses a proof from the layout produced by [`Self::to_bytes`].
591    ///
592    /// Only the length is checked here; whether the components are meaningful is decided by
593    /// [`Self::verify`], so that a malformed proof and an invalid one take the same path.
594    pub fn try_from_bytes(bytes: &[u8]) -> errors::Result<Self, S::Pseudonym> {
595        if bytes.len() != Self::SIZE {
596            return Err(PixError::InvalidInput);
597        }
598
599        let (nonce_bytes, response_bytes) = bytes.split_at(size_of::<PixGroupRepr<S>>());
600        let mut nonce_commitment = PixGroupRepr::<S>::default();
601        AsMut::<[u8]>::as_mut(&mut nonce_commitment).copy_from_slice(nonce_bytes);
602        let mut response = ScalarRepr::<S>::default();
603        AsMut::<[u8]>::as_mut(&mut response).copy_from_slice(response_bytes);
604
605        Ok(Self {
606            nonce_commitment,
607            response,
608        })
609    }
610}
611
612/// Contains commitment to a specific SSA and corresponding verifier.
613#[derive(Debug, Clone, PartialEq, Eq)]
614#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
615pub struct SsaCommitment<S: PixSpec, P = <S as PixSpec>::Pseudonym> {
616    /// ID of the SSA that is being committed to.
617    pub ssa_id: SsaId<P>,
618    /// Commitment to the SSA.
619    #[cfg_attr(feature = "serde", serde(with = "elliptic_curve_tools::group"))]
620    pub ssa_commitment: PixGroup<S>,
621    /// Proof that the sender knows the discrete logarithm of [`Self::ssa_commitment`].
622    ///
623    /// Without it the recipient cannot tell a genuine commitment from one crafted so that the
624    /// *combined* deposit key is known to the sender alone — see [`SsaCommitmentProof`].
625    pub commitment_proof: SsaCommitmentProof<S>,
626    /// Commitments to the polynomials' constant terms, keyed by coefficient index.
627    ///
628    /// Always exactly one entry, at [`CONSTANT_TERM_COEFFICIENT`](crate::CONSTANT_TERM_COEFFICIENT).
629    #[cfg_attr(
630        feature = "serde",
631        serde(bound(
632            serialize = "PixGroupRepr<S>: serde::Serialize",
633            deserialize = "PixGroupRepr<S>: serde::Deserialize<'de>"
634        ))
635    )]
636    pub verifiers: TransposedVerifiers<S>,
637}
638
639impl<S: PixSpec, P> IntoIterator for SsaCommitment<S, P> {
640    type IntoIter = std::collections::hash_map::IntoIter<CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>>;
641    type Item = (CoefficientIndex, Vec<(PolynomialIndex, PixGroupRepr<S>)>);
642
643    fn into_iter(self) -> Self::IntoIter {
644        self.verifiers.into_iter()
645    }
646}
647
648impl<S: PixSpec> SsaCommitment<S, S::Pseudonym> {
649    /// Reconstructs the per-polynomial commitments from the transposed representation, ordered by
650    /// polynomial index.
651    ///
652    /// Only the [`crate::CONSTANT_TERM_COEFFICIENT`] row is read; any other coefficient index is
653    /// ignored, matching what the Exit does with one on the wire.
654    pub fn reconstruct_part_commitments(self) -> errors::Result<Vec<SsaPartCommitment<S>>, S::Pseudonym> {
655        let constant_terms: BTreeMap<PolynomialIndex, PixGroupRepr<S>> = self
656            .verifiers
657            .get(&crate::CONSTANT_TERM_COEFFICIENT)
658            .into_iter()
659            .flatten()
660            .copied()
661            .collect();
662
663        constant_terms
664            .into_iter()
665            .map(|(poly_idx, commitment)| {
666                Ok(SsaPartCommitment::from_decoded_commitment(
667                    SsaPolynomialId::new(self.ssa_id, poly_idx),
668                    SsaPartCommitment::<S>::decode_commitment(&commitment)?,
669                ))
670            })
671            .collect()
672    }
673
674    /// Shorthand to pass all the coefficient commitments into the [reconstructor](ExitAcknowledgementShareProcessor).
675    ///
676    /// The proof accompanies the constant-term batch, mirroring how the wire messages carry it.
677    /// In practice that is the only batch there is — see [`SsaPartCommitment`] — but the loop is
678    /// kept over the whole map because the type still admits higher coefficient indices.
679    pub fn process_into_reconstructor<R: ExitAcknowledgementShareProcessor<S>>(
680        self,
681        reconstructor: &R,
682    ) -> Result<(), R::Error> {
683        let ssa_id = self.ssa_id;
684        let proof = self.commitment_proof;
685        for (coeff_idx, coeffs) in self.verifiers {
686            let batch_proof = (coeff_idx == crate::CONSTANT_TERM_COEFFICIENT).then_some(proof);
687            reconstructor.insert_coefficient_commitments(ssa_id, coeff_idx, batch_proof, coeffs.into_iter())?;
688        }
689        Ok(())
690    }
691}
692
693/// Represents the current state of a specific SSA commitment on an Exit node.
694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
695#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
696pub struct SsaCommitmentState<P, A> {
697    /// ID of the SSA that is being committed to.
698    pub ssa_id: SsaId<P>,
699    /// Commitment to the SSA, if it's already known.
700    pub ssa_deposit_address: Option<A>,
701    /// Whether the commitment is fully committed and therefore its partial shares are verifiable.
702    pub is_verifiable: bool,
703    /// Whether this SSA was encountered for the first time.
704    pub is_first_encountered: bool,
705    /// Whether the SSA deposit address has been discovered.
706    pub deposit_address_first_encountered: bool,
707}
708
709impl<P, A> SsaCommitmentState<P, A> {
710    /// Creates a new SsaCommitmentState for the given SSA ID.
711    ///
712    /// It has no associated commitment and is not verifiable initially.
713    pub fn new(ssa_id: SsaId<P>) -> Self {
714        Self {
715            ssa_id,
716            ssa_deposit_address: None,
717            is_verifiable: false,
718            is_first_encountered: true,
719            deposit_address_first_encountered: false,
720        }
721    }
722}
723
724/// Absolute recovery progress for a single SSA cycle.
725///
726/// Carries running totals rather than per-batch deltas, so a consumer can detect a stall, a
727/// completion, or a protocol violation without having received every snapshot. That matters because
728/// acknowledgement batches are processed concurrently: snapshots can arrive out of order, and one
729/// may be dropped under load. A consumer should keep the maximum it has seen and treat a lower or
730/// repeated `useful_shares` as benign noise.
731///
732/// `P` is the pseudonym type.
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
734pub struct SsaRecoveryProgress<P> {
735    /// The SSA this snapshot describes.
736    pub ssa_id: SsaId<P>,
737    /// Shares that advanced reconstruction — verified, distinct, and below their polynomial's
738    /// threshold when they arrived. Excludes duplicates and the surplus a conforming Entry sends.
739    ///
740    /// The **payment** counter: what the Exit can eventually be paid for, and the right input to a
741    /// consumer's accounting. It is the wrong input to a liveness check — see
742    /// [`shares_seen`](Self::shares_seen).
743    pub useful_shares: u64,
744    /// Shares accepted for this cycle whether or not they advanced it. Always at least
745    /// [`useful_shares`](Self::useful_shares).
746    ///
747    /// The **liveness** counter: evidence that the Entry is still serving this cycle. The two differ
748    /// by more than a rounding error, and conflating them has a specific failure mode. A conforming
749    /// Entry emits `threshold + surplus` shares per polynomial across a window advancing in lockstep,
750    /// so every window ends with `surplus × window` consecutive shares that advance nothing —
751    /// 4096 packets at the deployed dimensions. A consumer that treats "no useful share" as "the
752    /// Entry has gone quiet" will act on that run, and if the action is to withhold service it
753    /// removes the only thing that could have produced the next useful share.
754    ///
755    /// So: bound service and idleness on this, and decide payment on `useful_shares`.
756    pub shares_seen: u64,
757    /// Useful shares that constitute full recovery: `polynomials × threshold`.
758    ///
759    /// A consumer that negotiated the dimensions can compare this against its own expectation; a
760    /// mismatch is a protocol violation rather than drift.
761    pub target_useful_shares: u64,
762    /// Polynomial parts that have reconstructed and opened their commitment.
763    pub recovered_polynomials: u16,
764}
765
766/// Contains the already recovered secret scalar corresponding to a specific SSA.
767///
768/// `P` is the pseudonym type, `A` is the private key type for SSA.
769#[derive(Clone, Copy)]
770pub struct RecoveredSsa<P, A> {
771    /// ID of the SSA that was recovered.
772    pub ssa_id: SsaId<P>,
773    /// Recovered secret scalar (private key corresponding to the SSA deposit address).
774    pub ssa: A,
775}
776
777impl<P: std::fmt::Debug, A> std::fmt::Debug for RecoveredSsa<P, A> {
778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779        f.debug_struct("RecoveredSsa")
780            .field("ssa_id", &self.ssa_id)
781            .finish_non_exhaustive()
782    }
783}
784
785impl<P: PartialEq, A> PartialEq for RecoveredSsa<P, A> {
786    fn eq(&self, other: &Self) -> bool {
787        self.ssa_id == other.ssa_id
788    }
789}
790
791impl<P: Eq, A> Eq for RecoveredSsa<P, A> {}
792
793impl<P: std::hash::Hash, A> std::hash::Hash for RecoveredSsa<P, A> {
794    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
795        self.ssa_id.hash(state);
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use hopr_types::{crypto::types::SimplePseudonym, crypto_random::Randomizable};
802    use vsss_rs::elliptic_curve::Field;
803
804    use super::*;
805    use crate::tests::TestSpec;
806
807    #[test]
808    fn test_tagged_encrypted_partial_ssa_share_traits() {
809        let pseudonym = SimplePseudonym::random();
810        let nonce = 42u64;
811        let partial_share = EncryptedPartialSsaShare::<TestSpec>::default();
812        let tagged = TaggedEncryptedPartialSsaShare {
813            pseudonym,
814            nonce,
815            partial_share,
816        };
817
818        // Test Clone
819        #[allow(clippy::clone_on_copy)]
820        let cloned = tagged.clone();
821        assert_eq!(tagged, cloned);
822
823        // Test Copy
824        let copied = tagged;
825        assert_eq!(tagged, copied);
826
827        // Test Debug
828        let debug_str = format!("{:?}", tagged);
829        assert!(debug_str.contains("TaggedEncryptedPartialSsaShare"));
830        assert!(debug_str.contains("pseudonym"));
831        assert!(debug_str.contains("nonce"));
832        assert!(debug_str.contains("partial_share"));
833    }
834
835    #[cfg(feature = "serde")]
836    #[test]
837    fn test_tagged_encrypted_partial_ssa_share_serde() {
838        let pseudonym = SimplePseudonym::random();
839        let nonce = 42u64;
840        let partial_share = EncryptedPartialSsaShare::<TestSpec>::default();
841        let _tagged = TaggedEncryptedPartialSsaShare {
842            pseudonym,
843            nonce,
844            partial_share,
845        };
846        // Compile-time check only for now as no serde_json is available in this crate
847    }
848
849    #[test]
850    fn size_of_indices_must_match() {
851        let actual = size_of::<SsaIndex>() + size_of::<PolynomialIndex>();
852        let expected = SsaPolyIndexPrefixSize::USIZE;
853        assert_eq!(actual, expected);
854    }
855
856    #[test]
857    fn default_enc_share_is_empty() {
858        assert!(EncryptedPartialSsaShare::<TestSpec>::default().is_empty());
859        assert!(EncryptedPartialSsaShare::<TestSpec>::default().indices().is_none());
860    }
861
862    #[test]
863    fn ssa_part_shares_should_encrypt_and_decrypt() -> anyhow::Result<()> {
864        let key = HalfKey::random();
865        let spi = SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 1.try_into()?), 0);
866        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
867
868        let share_1 = PartialSsaShare::<TestSpec>(scalar.to_repr());
869        let share_2 = share_1.clone().encrypt(&spi, &key)?.decrypt(spi.pseudonym(), &key)?;
870
871        assert_eq!(share_1, share_2);
872        Ok(())
873    }
874
875    #[test]
876    fn debug_redaction_partial_ssa_share() {
877        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
878        let share = PartialSsaShare::<TestSpec>(scalar.to_repr());
879        let debug = format!("{:?}", share);
880        assert!(debug.contains("PartialSsaShare"));
881        // The scalar repr should not appear in Debug output
882        assert_eq!(
883            debug, "PartialSsaShare { .. }",
884            "PartialSsaShare Debug must exactly match the redacted format"
885        );
886    }
887
888    #[test]
889    fn debug_redaction_generated_share() {
890        use crate::tests::TestSpec;
891
892        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
893        let share = PartialSsaShare::<TestSpec>(scalar.to_repr());
894        let id = SsaPolynomialId::new(
895            SsaId::new(
896                SimplePseudonym::try_from([0u8; 10].as_ref()).unwrap(),
897                1.try_into().unwrap(),
898            ),
899            0,
900        );
901        let generated = GeneratedShare { id, share };
902        let debug = format!("{:?}", generated);
903
904        // Must include the public ID field
905        assert!(debug.contains("GeneratedShare"));
906        assert!(debug.contains("id"));
907
908        // Must NOT include the secret share field — only the public id
909        assert_eq!(
910            debug,
911            format!("GeneratedShare {{ id: {:?}, .. }}", id),
912            "GeneratedShare Debug must expose only the id field"
913        );
914    }
915
916    #[test]
917    fn debug_redaction_recovered_ssa() {
918        use hopr_types::crypto::{keypairs::Keypair, prelude::ChainKeypair};
919
920        let pseudonym = SimplePseudonym::random();
921        let ssa_id = SsaId::new(pseudonym, 1.try_into().unwrap());
922        let dummy_key = ChainKeypair::random();
923        let recovered = RecoveredSsa { ssa_id, ssa: dummy_key };
924        let debug = format!("{:?}", recovered);
925
926        // Must include the public ssa_id field
927        assert!(debug.contains("RecoveredSsa"));
928        assert!(debug.contains("ssa_id"));
929
930        // Must NOT include the secret ssa field — only the public ssa_id
931        assert_eq!(
932            debug,
933            format!("RecoveredSsa {{ ssa_id: {:?}, .. }}", ssa_id),
934            "RecoveredSsa Debug must expose only the ssa_id field"
935        );
936    }
937
938    /// `RecoveredSsa`'s `PartialEq` and `Hash` are hand-written to ignore the key, so that `A` need
939    /// not be comparable or hashable. Two recoveries of the same SSA are the same event.
940    #[test]
941    fn recovered_ssa_equality_and_hash_ignore_the_key() {
942        use std::hash::{Hash as _, Hasher as _};
943
944        use hopr_types::crypto::{keypairs::Keypair, prelude::ChainKeypair};
945
946        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
947        let other_id = SsaId::new(SimplePseudonym::random(), 2.try_into().unwrap());
948
949        let hash_of = |r: &RecoveredSsa<SimplePseudonym, ChainKeypair>| {
950            let mut h = std::collections::hash_map::DefaultHasher::new();
951            r.hash(&mut h);
952            h.finish()
953        };
954
955        let a = RecoveredSsa {
956            ssa_id,
957            ssa: ChainKeypair::random(),
958        };
959        let b = RecoveredSsa {
960            ssa_id,
961            ssa: ChainKeypair::random(),
962        };
963        let c = RecoveredSsa {
964            ssa_id: other_id,
965            ssa: ChainKeypair::random(),
966        };
967
968        assert_eq!(a, b, "two different keys for the same SSA must compare equal");
969        assert_eq!(hash_of(&a), hash_of(&b), "and must hash equal");
970        assert_ne!(a, c);
971        assert_ne!(hash_of(&a), hash_of(&c));
972    }
973
974    #[test]
975    fn ssa_ids_expose_their_parts_and_display_hierarchically() {
976        let pseudonym = SimplePseudonym::random();
977        let ssa_index: SsaIndex = 7.try_into().unwrap();
978        let ssa_id = SsaId::new(pseudonym, ssa_index);
979
980        assert_eq!(ssa_id.pseudonym(), &pseudonym);
981        assert_eq!(ssa_id.ssa_index(), ssa_index);
982        assert_eq!(ssa_id.to_string(), format!("{pseudonym}-ssa#7"));
983
984        let spi = SsaPolynomialId::new(ssa_id, 3);
985        assert_eq!(spi.pseudonym(), &pseudonym);
986        assert_eq!(spi.ssa_index(), ssa_index);
987        assert_eq!(spi.poly_index(), 3);
988        // The polynomial id borrows the SSA id it was built from, so cycle-scoped lookups can key
989        // off a polynomial-scoped value without rebuilding one.
990        assert_eq!(AsRef::<SsaId<_>>::as_ref(&spi), &ssa_id);
991        assert_eq!(spi.to_string(), format!("{ssa_id}:3"));
992    }
993
994    #[test]
995    fn encrypted_share_round_trips_through_bytes_and_rejects_the_wrong_length() {
996        let key = HalfKey::random();
997        let spi =
998            SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 5.try_into().unwrap()), 9);
999        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1000        let encrypted = PartialSsaShare::<TestSpec>(scalar.to_repr())
1001            .encrypt(&spi, &key)
1002            .expect("encryption must succeed");
1003
1004        // The indices are carried in the clear as a prefix, so the Exit can route a share before it
1005        // has the key to decrypt it.
1006        assert_eq!(encrypted.indices(), Some((spi.ssa_index(), spi.poly_index())));
1007        assert!(!encrypted.is_empty());
1008
1009        let bytes: &[u8] = encrypted.as_ref();
1010        assert_eq!(bytes.len(), EncryptedPartialSsaShare::<TestSpec>::SIZE);
1011        assert_eq!(
1012            EncryptedPartialSsaShare::<TestSpec>::try_from(bytes).expect("exact length must parse"),
1013            encrypted
1014        );
1015
1016        for wrong in [&bytes[..bytes.len() - 1], &[][..]] {
1017            assert!(
1018                EncryptedPartialSsaShare::<TestSpec>::try_from(wrong).is_err(),
1019                "a {}-byte buffer must not parse as a {}-byte share",
1020                wrong.len(),
1021                EncryptedPartialSsaShare::<TestSpec>::SIZE
1022            );
1023        }
1024    }
1025
1026    #[cfg(feature = "serde")]
1027    #[test]
1028    fn encrypted_share_serde_round_trips_as_opaque_bytes() {
1029        let key = HalfKey::random();
1030        let spi =
1031            SsaPolynomialId::<SimplePseudonym>::new(SsaId::new(SimplePseudonym::random(), 2.try_into().unwrap()), 1);
1032        let scalar = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1033        let encrypted = PartialSsaShare::<TestSpec>(scalar.to_repr())
1034            .encrypt(&spi, &key)
1035            .expect("encryption must succeed");
1036
1037        // Serialized as a byte string rather than a sequence of integers, so the encoding stays the
1038        // same size on the wire as the share itself.
1039        let encoded = serde_cbor_2::to_vec(&encrypted).expect("serialization must succeed");
1040        let decoded: EncryptedPartialSsaShare<TestSpec> =
1041            serde_cbor_2::from_slice(&encoded).expect("deserialization must succeed");
1042        assert_eq!(decoded, encrypted);
1043
1044        // A short byte string must be rejected by the same length check `TryFrom` applies.
1045        // `0x43` is a CBOR byte string of length 3.
1046        let truncated = [0x43u8, 0x00, 0x00, 0x00];
1047        assert!(serde_cbor_2::from_slice::<EncryptedPartialSsaShare<TestSpec>>(&truncated).is_err());
1048    }
1049
1050    #[test]
1051    fn commitment_proof_verifies_only_against_the_commitment_it_opens() -> anyhow::Result<()> {
1052        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1053        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1054        let commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1055
1056        let proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1057        assert!(proof.verify(&ssa_id, &commitment));
1058
1059        // The challenge binds the SSA id, so a proof cannot be replayed onto another cycle.
1060        let other_id = SsaId::new(*ssa_id.pseudonym(), 2.try_into()?);
1061        assert!(!proof.verify(&other_id, &commitment), "the proof must bind the SSA id");
1062
1063        // ...and onto another commitment, which is the attack it exists to stop.
1064        let other_secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1065        let other_commitment = PixGroup::<TestSpec>::mul_by_generator(&other_secret);
1066        assert!(
1067            !proof.verify(&ssa_id, &other_commitment),
1068            "the proof must bind the commitment"
1069        );
1070
1071        // Two proofs of the same statement use fresh nonces, so they must differ. Reusing a nonce
1072        // across differing challenges leaks the secret.
1073        let again = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1074        assert_ne!(proof, again, "the proof nonce must be fresh for every proof");
1075        assert!(again.verify(&ssa_id, &commitment));
1076
1077        // Clone is a copy of two byte arrays; equality compares both components.
1078        #[allow(clippy::clone_on_copy)]
1079        let cloned = proof.clone();
1080        assert_eq!(proof, cloned);
1081
1082        Ok(())
1083    }
1084
1085    #[test]
1086    fn commitment_proof_round_trips_through_bytes() -> anyhow::Result<()> {
1087        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1088        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1089        let commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1090        let proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &commitment)?;
1091
1092        let bytes = proof.to_bytes();
1093        assert_eq!(bytes.len(), SsaCommitmentProof::<TestSpec>::SIZE);
1094
1095        let parsed = SsaCommitmentProof::<TestSpec>::try_from_bytes(&bytes)?;
1096        assert_eq!(parsed, proof);
1097        assert!(parsed.verify(&ssa_id, &commitment));
1098
1099        for wrong in [&bytes[..bytes.len() - 1], &[][..]] {
1100            assert!(
1101                SsaCommitmentProof::<TestSpec>::try_from_bytes(wrong).is_err(),
1102                "only the exact length may parse"
1103            );
1104        }
1105
1106        // Garbage of the right length parses but must not verify: `try_from_bytes` checks the
1107        // length only, deliberately routing a malformed proof and an invalid one down one path.
1108        let garbage = SsaCommitmentProof::<TestSpec>::try_from_bytes(&[0xAA; SsaCommitmentProof::<TestSpec>::SIZE])?;
1109        assert!(!garbage.verify(&ssa_id, &commitment));
1110
1111        Ok(())
1112    }
1113
1114    #[test]
1115    fn ssa_commitment_iterates_its_transposed_verifiers() -> anyhow::Result<()> {
1116        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
1117        let secret = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1118        let ssa_commitment = PixGroup::<TestSpec>::mul_by_generator(&secret);
1119        let commitment_proof = SsaCommitmentProof::<TestSpec>::prove(&ssa_id, &secret, &ssa_commitment)?;
1120
1121        let entries: Vec<(PolynomialIndex, PixGroupRepr<TestSpec>)> = (0..3u16)
1122            .map(|poly_index| {
1123                let s = PixScalar::<TestSpec>::random(&mut hopr_types::crypto_random::rng());
1124                (poly_index, PixGroup::<TestSpec>::mul_by_generator(&s).to_bytes())
1125            })
1126            .collect();
1127
1128        let commitment = SsaCommitment::<TestSpec> {
1129            ssa_id,
1130            ssa_commitment,
1131            commitment_proof,
1132            verifiers: HashMap::from([(crate::CONSTANT_TERM_COEFFICIENT, entries.clone())]),
1133        };
1134
1135        let collected: Vec<_> = commitment.into_iter().collect();
1136        assert_eq!(collected.len(), 1, "PIX commits to the constant term and nothing else");
1137        assert_eq!(collected[0].0, crate::CONSTANT_TERM_COEFFICIENT);
1138        assert_eq!(collected[0].1, entries);
1139
1140        Ok(())
1141    }
1142
1143    #[test]
1144    fn ssa_commitment_state_starts_unverifiable_and_unaddressed() {
1145        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into().unwrap());
1146        let state = SsaCommitmentState::<SimplePseudonym, hopr_types::primitive::prelude::Address>::new(ssa_id);
1147
1148        assert_eq!(state.ssa_id, ssa_id);
1149        assert!(
1150            state.ssa_deposit_address.is_none(),
1151            "the address is the sum of constant terms, none of which have arrived yet"
1152        );
1153        assert!(!state.is_verifiable, "shares cannot verify before the commitment does");
1154        assert!(state.is_first_encountered);
1155        assert!(!state.deposit_address_first_encountered);
1156    }
1157}