Skip to main content

hopr_crypto_packet/
types.rs

1use std::{borrow::Cow, fmt::Formatter, marker::PhantomData, ops::Not};
2
3use hopr_protocol_pix::{CofactorGroup, GroupEncoding, PixGroup, SsaCommitmentProof};
4use hopr_types::primitive::prelude::{BytesRepresentable, GeneralError};
5
6use crate::{
7    HoprEncryptedPartialSsaShare, HoprPixGroupRepr, HoprPixSpec, HoprSphinxHeaderSpec, HoprSphinxSuite,
8    PAYLOAD_SIZE_INT,
9    por::ProofOfRelayValues,
10    sphinx::{
11        errors::SphinxError,
12        prelude::{PaddedPayload, SURB, SphinxHeaderSpec, SphinxSuite},
13    },
14};
15
16flagset::flags! {
17   /// Individual packet signals passed up between the packet sender and destination.
18   #[repr(u8)]
19   #[derive(PartialOrd, Ord, strum::EnumString, strum::Display)]
20   pub enum PacketSignal: u8 {
21        /// The other party is in a "SURB distress" state, potentially running out of SURBs soon.
22        ///
23        /// Has no effect on packets that take the "forward path".
24        SurbDistress = 0b0000_0001,
25        /// The other party has run out of SURBs, and this was potentially the last message they could
26        /// send.
27        ///
28        /// Has no effect on packets that take the "forward path".
29        ///
30        /// Implies [`SurbDistress`].
31        OutOfSurbs = 0b0000_0011,
32   }
33}
34
35/// Packet signal states that can be passed between the packet sender and destination.
36///
37/// These signals can be typically propagated up to the application layer to take an appropriate
38/// action to the signaled states.
39pub type PacketSignals = flagset::FlagSet<PacketSignal>;
40
41/// Additional encoding of a packet message that can be preceded by a number of [`SURBs`](SURB).
42pub struct PacketMessage<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize>(PaddedPayload<P>, PhantomData<(S, H)>);
43
44/// Convenience alias for HOPR specific [`PacketMessage`].
45pub type HoprPacketMessage = PacketMessage<HoprSphinxSuite, HoprSphinxHeaderSpec, PAYLOAD_SIZE_INT>;
46
47/// Individual parts of a [`PacketMessage`]: SURBs, the actual message (payload) and additional signals for the
48/// recipient.
49pub struct PacketParts<'a, S: SphinxSuite, H: SphinxHeaderSpec> {
50    /// Contains (a potentially empty) list of SURBs.
51    pub surbs: Vec<SURB<S, H>>,
52    /// Contains the actual packet payload.
53    pub payload: Cow<'a, [u8]>,
54    /// Additional packet signals from the sender to the recipient.
55    pub signals: PacketSignals,
56}
57
58impl<S: SphinxSuite, H: SphinxHeaderSpec> Clone for PacketParts<'_, S, H>
59where
60    H::KeyId: Clone,
61    H::SurbReceiverData: Clone,
62{
63    fn clone(&self) -> Self {
64        Self {
65            surbs: self.surbs.clone(),
66            payload: self.payload.clone(),
67            signals: self.signals,
68        }
69    }
70}
71
72impl<S: SphinxSuite, H: SphinxHeaderSpec> std::fmt::Debug for PacketParts<'_, S, H>
73where
74    H::KeyId: std::fmt::Debug,
75    H::SurbReceiverData: std::fmt::Debug,
76{
77    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("PacketParts")
79            .field("surbs", &self.surbs)
80            .field("payload", &self.payload)
81            .field("signals", &self.signals)
82            .finish()
83    }
84}
85
86impl<S: SphinxSuite, H: SphinxHeaderSpec> PartialEq for PacketParts<'_, S, H>
87where
88    H::KeyId: PartialEq,
89    H::SurbReceiverData: PartialEq,
90{
91    fn eq(&self, other: &Self) -> bool {
92        self.surbs == other.surbs && self.payload == other.payload && self.signals == other.signals
93    }
94}
95
96impl<S: SphinxSuite, H: SphinxHeaderSpec> Eq for PacketParts<'_, S, H>
97where
98    H::KeyId: Eq,
99    H::SurbReceiverData: Eq,
100{
101}
102
103/// Convenience alias for HOPR specific [`PacketParts`].
104pub type HoprPacketParts<'a> = PacketParts<'a, HoprSphinxSuite, HoprSphinxHeaderSpec>;
105
106// Coerces PacketSignals to only lower 4 bits.
107pub(crate) const S_MASK: u8 = 0b0000_1111;
108
109impl<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize> PacketMessage<S, H, P> {
110    /// Size of the message header.
111    ///
112    /// This is currently 1 byte to indicate the number of SURBs that precede the message.
113    pub const HEADER_LEN: usize = 1;
114    /// The maximum number of SURBs a packet message can hold, according to RFC-0003.
115    ///
116    /// The number of SURBs in a `PacketMessage` is intentionally limited to 15, so that
117    /// the upper 4 bits remain reserved for additional flags.
118    pub const MAX_SURBS_PER_MESSAGE: usize = S_MASK as usize;
119}
120
121impl<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize> TryFrom<PacketParts<'_, S, H>> for PacketMessage<S, H, P> {
122    type Error = SphinxError;
123
124    fn try_from(value: PacketParts<S, H>) -> Result<Self, Self::Error> {
125        if value.surbs.len() > Self::MAX_SURBS_PER_MESSAGE {
126            return Err(GeneralError::ParseError("HoprPacketMessage.num_surbs not valid".into()).into());
127        }
128
129        if value.signals.bits() > S_MASK {
130            return Err(GeneralError::ParseError("HoprPacketMessage.flags not valid".into()).into());
131        }
132
133        // The total size of the packet message must not exceed the maximum packet size.
134        if Self::HEADER_LEN + value.surbs.len() * SURB::<S, H>::SIZE + value.payload.len() > P {
135            return Err(GeneralError::ParseError("HoprPacketMessage.size not valid".into()).into());
136        }
137
138        let mut ret = Vec::with_capacity(PaddedPayload::<P>::SIZE);
139        let flags_and_len = (value.signals.bits() << S_MASK.trailing_ones()) | (value.surbs.len() as u8 & S_MASK);
140        ret.push(flags_and_len);
141        for surb in value.surbs.into_iter().map(|s| s.into_boxed()) {
142            ret.extend(surb);
143        }
144        ret.extend_from_slice(value.payload.as_ref());
145
146        // Save one reallocation by using the vector that we just created
147        Ok(Self(PaddedPayload::new_from_vec(ret)?, PhantomData))
148    }
149}
150
151impl<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize> TryFrom<PacketMessage<S, H, P>> for PacketParts<'_, S, H> {
152    type Error = SphinxError;
153
154    fn try_from(value: PacketMessage<S, H, P>) -> Result<Self, Self::Error> {
155        let data = value.0.into_unpadded()?;
156        if data.is_empty() {
157            return Err(GeneralError::ParseError("HoprPacketMessage.size".into()).into());
158        }
159
160        let num_surbs = (data[0] & S_MASK) as usize;
161        let signals = PacketSignals::new((data[0] & S_MASK.not()) >> S_MASK.trailing_ones())
162            .map_err(|_| GeneralError::ParseError("HoprPacketMessage.signals".into()))?;
163
164        if num_surbs > 0 {
165            let surb_end = num_surbs * SURB::<S, H>::SIZE;
166            if surb_end >= data.len() {
167                return Err(GeneralError::ParseError("HoprPacketMessage.num_surbs not valid".into()).into());
168            }
169
170            let mut data = data.into_vec();
171
172            let surbs = data[1..=surb_end]
173                .chunks_exact(SURB::<S, H>::SIZE)
174                .map(SURB::<S, H>::try_from)
175                .collect::<Result<Vec<_>, _>>()?;
176
177            // Skip buffer all the way to the end of the SURBs.
178            data.drain(0..=surb_end).for_each(drop);
179
180            Ok(PacketParts {
181                surbs,
182                payload: Cow::Owned(data),
183                signals,
184            })
185        } else {
186            let mut data = data.into_vec();
187            data.remove(0);
188            Ok(PacketParts {
189                surbs: Vec::with_capacity(0),
190                payload: Cow::Owned(data),
191                signals,
192            })
193        }
194    }
195}
196
197impl<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize> From<PaddedPayload<P>> for PacketMessage<S, H, P> {
198    fn from(value: PaddedPayload<P>) -> Self {
199        Self(value, PhantomData)
200    }
201}
202
203impl<S: SphinxSuite, H: SphinxHeaderSpec, const P: usize> From<PacketMessage<S, H, P>> for PaddedPayload<P> {
204    fn from(value: PacketMessage<S, H, P>) -> Self {
205        value.0
206    }
207}
208
209/// Wraps the [`ProofOfRelayValues`] with some additional information about the sender of the packet,
210/// that is supposed to be passed along with the SURB.
211#[derive(Clone, Copy, Debug, PartialEq, Eq)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct SurbReceiverInfo(#[cfg_attr(feature = "serde", serde(with = "serde_bytes"))] [u8; Self::SIZE]);
214
215impl SurbReceiverInfo {
216    pub fn new(
217        pov: ProofOfRelayValues,
218        encrypted_partial_ssa_share: HoprEncryptedPartialSsaShare,
219        generation: u8,
220    ) -> Self {
221        let mut ret = [0u8; Self::SIZE];
222        ret[0..ProofOfRelayValues::SIZE].copy_from_slice(pov.as_ref());
223        ret[ProofOfRelayValues::SIZE..ProofOfRelayValues::SIZE + HoprEncryptedPartialSsaShare::SIZE]
224            .copy_from_slice(encrypted_partial_ssa_share.as_ref());
225        // Generation is the trailing byte, so the PoR and SSA-share offsets above are unchanged.
226        ret[Self::SIZE - 1] = generation;
227        Self(ret)
228    }
229
230    pub fn proof_of_relay_values(&self) -> ProofOfRelayValues {
231        ProofOfRelayValues::try_from(&self.0[0..ProofOfRelayValues::SIZE])
232            .expect("SurbReceiverInfo always contains valid ProofOfRelayValues")
233    }
234
235    pub fn encrypted_partial_ssa_share(&self) -> HoprEncryptedPartialSsaShare {
236        HoprEncryptedPartialSsaShare::try_from(
237            &self.0[ProofOfRelayValues::SIZE..ProofOfRelayValues::SIZE + HoprEncryptedPartialSsaShare::SIZE],
238        )
239        .expect("SurbReceiverInfo always contains valid HoprEncryptedPartialSsaShare")
240    }
241
242    /// Generation (RFC-1982 serial) of the SURB batch this SURB belongs to.
243    ///
244    /// The SURB creator bumps this whenever it changes the return path, minting an entirely fresh
245    /// batch. The replying side keeps only the highest generation it has seen, so SURBs left over
246    /// from a superseded (and possibly dead) return path are dropped rather than used. A return
247    /// path that dies deep in a multi-hop route is invisible to the replying side, so this
248    /// creator-supplied tag is the only signal that distinguishes a stale SURB from a live one.
249    pub fn generation(&self) -> u8 {
250        self.0[Self::SIZE - 1]
251    }
252}
253
254impl AsRef<[u8]> for SurbReceiverInfo {
255    fn as_ref(&self) -> &[u8] {
256        &self.0
257    }
258}
259
260impl<'a> TryFrom<&'a [u8]> for SurbReceiverInfo {
261    type Error = GeneralError;
262
263    fn try_from(value: &'a [u8]) -> std::result::Result<Self, Self::Error> {
264        value
265            .try_into()
266            .map(Self)
267            .map_err(|_| GeneralError::ParseError("SurbReceiverInfo".into()))
268    }
269}
270
271impl BytesRepresentable for SurbReceiverInfo {
272    // Trailing term is the SURB-batch generation byte; see [`SurbReceiverInfo::generation`].
273    const SIZE: usize = ProofOfRelayValues::SIZE + HoprEncryptedPartialSsaShare::SIZE + size_of::<u8>();
274}
275
276/// New-type wrapper for the PIX group element representation to provide additional functionality.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub struct HoprPixGroupElement(pub HoprPixGroupRepr);
279
280impl std::hash::Hash for HoprPixGroupElement {
281    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
282        // Spelled out because the secp256k1 `HoprPixGroupRepr` is a `hybrid_array::Array`,
283        // which has several `AsRef` impls and cannot infer the target here.
284        AsRef::<[u8]>::as_ref(&self.0).hash(state);
285    }
286}
287
288impl HoprPixGroupElement {
289    /// Tries to convert the instance into a `PixGroup<HoprPixSpec>`.
290    pub fn try_into_pix_group(self) -> Result<PixGroup<HoprPixSpec>, GeneralError> {
291        Option::<PixGroup<HoprPixSpec>>::from(PixGroup::<HoprPixSpec>::from_bytes(&self.0))
292            .filter(|pt| {
293                // Reject points outside the prime-order subgroup. Baby JubJub has
294                // cofactor 8, so small-order points can pass the on-curve check.
295                bool::from(pt.is_torsion_free())
296            })
297            .ok_or(GeneralError::ParseError("pix group from bytes failed".into()))
298    }
299}
300
301impl From<HoprPixGroupRepr> for HoprPixGroupElement {
302    fn from(value: HoprPixGroupRepr) -> Self {
303        Self(value)
304    }
305}
306
307impl AsRef<[u8]> for HoprPixGroupElement {
308    fn as_ref(&self) -> &[u8] {
309        self.0.as_ref()
310    }
311}
312
313impl<'a> TryFrom<&'a [u8]> for HoprPixGroupElement {
314    type Error = GeneralError;
315
316    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
317        if value.len() != size_of::<HoprPixGroupRepr>() {
318            return Err(GeneralError::ParseError("pix repr length".into()));
319        }
320        let mut arr = HoprPixGroupRepr::default();
321        AsMut::<[u8]>::as_mut(&mut arr).copy_from_slice(value);
322        Ok(Self(arr))
323    }
324}
325
326impl std::fmt::Display for HoprPixGroupElement {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        write!(f, "{}", const_hex::encode(self.0))
329    }
330}
331
332/// Byte size of a serialized [`SsaCommitmentProof`] for [`HoprPixSpec`].
333pub const HOPR_PIX_COMMITMENT_PROOF_SIZE: usize = SsaCommitmentProof::<HoprPixSpec>::SIZE;
334
335/// Wire form of the PIX client SSA commitment proof of knowledge.
336///
337/// A fixed-size byte blob so that the Start protocol can carry it without knowing the curve, the
338/// same way [`HoprPixGroupElement`] wraps a coefficient commitment. The layout is
339/// [`SsaCommitmentProof::to_bytes`]'s, and neither component is secret.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct HoprPixCommitmentProof(pub [u8; HOPR_PIX_COMMITMENT_PROOF_SIZE]);
342
343impl HoprPixCommitmentProof {
344    /// Tries to convert the instance back into the typed proof.
345    ///
346    /// Only the length is checked here; whether the proof actually opens the commitment is decided
347    /// by `SsaCommitmentProof::verify` on the reconstructor side.
348    pub fn try_into_pix_proof(self) -> Result<SsaCommitmentProof<HoprPixSpec>, GeneralError> {
349        SsaCommitmentProof::<HoprPixSpec>::try_from_bytes(&self.0)
350            .map_err(|_| GeneralError::ParseError("pix commitment proof from bytes failed".into()))
351    }
352}
353
354impl From<SsaCommitmentProof<HoprPixSpec>> for HoprPixCommitmentProof {
355    fn from(value: SsaCommitmentProof<HoprPixSpec>) -> Self {
356        let mut out = [0u8; HOPR_PIX_COMMITMENT_PROOF_SIZE];
357        out.copy_from_slice(&value.to_bytes());
358        Self(out)
359    }
360}
361
362impl AsRef<[u8]> for HoprPixCommitmentProof {
363    fn as_ref(&self) -> &[u8] {
364        &self.0
365    }
366}
367
368impl<'a> TryFrom<&'a [u8]> for HoprPixCommitmentProof {
369    type Error = GeneralError;
370
371    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
372        Ok(Self(value.try_into().map_err(|_| {
373            GeneralError::ParseError("pix commitment proof length".into())
374        })?))
375    }
376}
377
378impl std::fmt::Display for HoprPixCommitmentProof {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        write!(f, "{}", const_hex::encode(self.0))
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use anyhow::anyhow;
387    use bimap::BiHashMap;
388    use hex_literal::hex;
389    use hopr_types::{
390        crypto::prelude::*, crypto_random::Randomizable, internal::routing::HoprSenderId, primitive::prelude::*,
391    };
392
393    use super::*;
394    use crate::{
395        HoprEncryptedPartialSsaShare, HoprSphinxHeaderSpec, HoprSphinxSuite, HoprSurb, packet::HoprPacket,
396        por::generate_proof_of_relay, sphinx::prelude::*, types::SurbReceiverInfo,
397    };
398
399    lazy_static::lazy_static! {
400        static ref PEERS: [(ChainKeypair, OffchainKeypair); 4] = [
401            (hex!("a7c486ceccf5ab53bd428888ab1543dc2667abd2d5e80aae918da8d4b503a426"), hex!("5eb212d4d6aa5948c4f71574d45dad43afef6d330edb873fca69d0e1b197e906")),
402            (hex!("9a82976f7182c05126313bead5617c623b93d11f9f9691c87b1a26f869d569ed"), hex!("e995db483ada5174666c46bafbf3628005aca449c94ebdc0c9239c3f65d61ae0")),
403            (hex!("ca4bdfd54a8467b5283a0216288fdca7091122479ccf3cfb147dfa59d13f3486"), hex!("9dec751c00f49e50fceff7114823f726a0425a68a8dc6af0e4287badfea8f4a4")),
404            (hex!("e306ebfb0d01d0da0952c9a567d758093a80622c6cb55052bf5f1a6ebd8d7b5c"), hex!("9a82976f7182c05126313bead5617c623b93d11f9f9691c87b1a26f869d569ed"))
405        ].map(|(p1,p2)| (ChainKeypair::from_secret(&p1).expect("lazy static keypair should be valid"), OffchainKeypair::from_secret(&p2).expect("lazy static keypair should be valid")));
406
407        static ref MAPPER: SimpleBiMapper<HoprSphinxSuite, HoprSphinxHeaderSpec> = PEERS
408            .iter()
409            .enumerate()
410            .map(|(i, (_, k))| (KeyIdent::from(i as u32), *k.public()))
411            .collect::<BiHashMap<_, _>>()
412            .into();
413    }
414
415    fn generate_surbs(count: usize) -> anyhow::Result<Vec<SURB<HoprSphinxSuite, HoprSphinxHeaderSpec>>> {
416        let path = PEERS.iter().map(|(_, k)| *k.public()).collect::<Vec<_>>();
417        let path_ids = MAPPER
418            .map_keys_to_ids(&path)
419            .into_iter()
420            .map(|v| v.ok_or(anyhow!("missing id")))
421            .collect::<Result<Vec<_>, _>>()?;
422        let pseudonym = SimplePseudonym::random();
423        let recv_data = HoprSenderId::new(&pseudonym);
424
425        Ok((0..count)
426            .map(|_| {
427                let shared_keys = HoprSphinxSuite::new_shared_keys(&path)?;
428                let (por_strings, (por_values, _)) = generate_proof_of_relay(&shared_keys.secrets)
429                    .map_err(|e| CryptoError::Other(GeneralError::NonSpecificError(e.to_string())))?;
430
431                create_surb::<HoprSphinxSuite, HoprSphinxHeaderSpec>(
432                    shared_keys,
433                    &path_ids,
434                    &por_strings,
435                    recv_data,
436                    SurbReceiverInfo::new(por_values, HoprEncryptedPartialSsaShare::default(), 0),
437                )
438                .map(|(s, _)| s)
439            })
440            .collect::<Result<Vec<_>, _>>()?)
441    }
442
443    #[test]
444    fn hopr_packet_message_message_only() -> anyhow::Result<()> {
445        let parts_1 = HoprPacketParts {
446            surbs: vec![],
447            payload: b"test message".into(),
448            signals: PacketSignal::OutOfSurbs.into(),
449        };
450
451        let parts_2: HoprPacketParts = HoprPacketMessage::try_from(parts_1.clone())?.try_into()?;
452        assert_eq!(parts_1, parts_2);
453
454        Ok(())
455    }
456
457    #[test]
458    fn hopr_packet_message_surbs_only() -> anyhow::Result<()> {
459        let parts_1 = HoprPacketParts {
460            surbs: generate_surbs(2)?,
461            payload: Cow::default(),
462            signals: PacketSignal::OutOfSurbs.into(),
463        };
464
465        let parts_2: HoprPacketParts = HoprPacketMessage::try_from(parts_1.clone())?.try_into()?;
466        assert_eq!(parts_1, parts_2);
467
468        Ok(())
469    }
470
471    #[test]
472    fn hopr_packet_message_surbs_and_msg() -> anyhow::Result<()> {
473        let parts_1 = HoprPacketParts {
474            surbs: generate_surbs(2)?,
475            payload: b"test msg".into(),
476            signals: PacketSignal::OutOfSurbs.into(),
477        };
478
479        let parts_2: HoprPacketParts = HoprPacketMessage::try_from(parts_1.clone())?.try_into()?;
480        assert_eq!(parts_1, parts_2);
481
482        Ok(())
483    }
484
485    #[test]
486    fn hopr_packet_size_msg_size_limit() {
487        let res = HoprPacketMessage::try_from(HoprPacketParts {
488            surbs: vec![],
489            payload: (&[1u8; HoprPacket::PAYLOAD_SIZE + 1]).into(),
490            signals: None.into(),
491        });
492        assert!(res.is_err());
493    }
494
495    #[test]
496    fn hopr_packet_message_surbs_size_limit() -> anyhow::Result<()> {
497        let res = HoprPacketMessage::try_from(PacketParts {
498            surbs: generate_surbs(HoprPacketMessage::MAX_SURBS_PER_MESSAGE + 1)?,
499            payload: Cow::default(),
500            signals: None.into(),
501        });
502        assert!(res.is_err());
503
504        let res = HoprPacketMessage::try_from(HoprPacketParts {
505            surbs: generate_surbs(3)?,
506            payload: Cow::default(),
507            signals: None.into(),
508        });
509        assert!(res.is_err());
510
511        Ok(())
512    }
513
514    #[test]
515    fn hopr_packet_message_surbs_flag_limit() -> anyhow::Result<()> {
516        let res = HoprPacketMessage::try_from(PacketParts {
517            surbs: generate_surbs(3)?,
518            payload: Cow::default(),
519            signals: unsafe { PacketSignals::new_unchecked(16) },
520        });
521        assert!(res.is_err());
522
523        Ok(())
524    }
525
526    #[test]
527    fn hopr_packet_size_msg_and_surb_size_limit() -> anyhow::Result<()> {
528        let res = HoprPacketMessage::try_from(PacketParts {
529            surbs: generate_surbs(2)?,
530            payload: (&[1u8; HoprPacket::PAYLOAD_SIZE - 2 * HoprSurb::SIZE + 1]).into(),
531            signals: None.into(),
532        });
533        assert!(res.is_err());
534
535        Ok(())
536    }
537
538    fn random_pix_group_element() -> HoprPixGroupElement {
539        // Needed only on Baby JubJub, where `mul_by_generator` comes from the trait. secp256k1's
540        // `ProjectivePoint` has it inherently, so importing it there is an unused import — the same
541        // arms the curve selection itself uses, so this tracks it rather than restating it.
542        #[cfg(all(feature = "pix-bjj", not(feature = "pix-secp256k1")))]
543        use hopr_protocol_pix::Group;
544
545        let scalar = <hopr_protocol_pix::PixScalar<HoprPixSpec> as crypto_traits::elliptic_curve::Field>::random(
546            &mut hopr_types::crypto_random::rng(),
547        );
548        HoprPixGroupElement(hopr_protocol_pix::GroupEncoding::to_bytes(
549            &PixGroup::<HoprPixSpec>::mul_by_generator(&scalar),
550        ))
551    }
552
553    /// The wire wrapper is what the Start protocol carries, so its parse must reject anything the
554    /// typed group would not accept — a wrong length, and a point outside the prime-order subgroup.
555    #[test]
556    fn pix_group_element_round_trips_and_rejects_bad_input() -> anyhow::Result<()> {
557        let element = random_pix_group_element();
558
559        let point = element.try_into_pix_group()?;
560        assert_eq!(
561            HoprPixGroupElement::from(hopr_protocol_pix::GroupEncoding::to_bytes(&point)),
562            element,
563            "converting to the typed group and back must be lossless"
564        );
565
566        let bytes: &[u8] = element.as_ref();
567        assert_eq!(HoprPixGroupElement::try_from(bytes)?, element);
568        for wrong in [&bytes[..bytes.len() - 1], &[][..]] {
569            assert!(
570                HoprPixGroupElement::try_from(wrong).is_err(),
571                "a {}-byte buffer must not parse as a group element",
572                wrong.len()
573            );
574        }
575
576        // All-ones does not decode to any point at all, so this never reaches the subgroup filter.
577        // That case is `pix_group_element_rejects_a_small_order_point`.
578        let mut garbage_repr = HoprPixGroupRepr::default();
579        AsMut::<[u8]>::as_mut(&mut garbage_repr).fill(0xFF);
580        assert!(HoprPixGroupElement(garbage_repr).try_into_pix_group().is_err());
581
582        // Hex, so a commitment is greppable in a log line.
583        assert_eq!(element.to_string(), const_hex::encode(bytes));
584
585        // Hashed by bytes: the wrapper is used as a map key on the reconstructor's insert path.
586        let mut h1 = std::collections::hash_map::DefaultHasher::new();
587        let mut h2 = std::collections::hash_map::DefaultHasher::new();
588        std::hash::Hash::hash(&element, &mut h1);
589        std::hash::Hash::hash(&HoprPixGroupElement::try_from(bytes)?, &mut h2);
590        assert_eq!(
591            std::hash::Hasher::finish(&h1),
592            std::hash::Hasher::finish(&h2),
593            "equal elements must hash equal"
594        );
595
596        Ok(())
597    }
598
599    /// **M13.** A well-formed encoding of a point outside the prime-order subgroup must not parse
600    /// into a PIX group element. Baby JubJub is the production curve — `pix-bjj` is a default
601    /// feature and `pix-secp256k1` is not overriding it — and its cofactor is 8, so such points
602    /// exist.
603    ///
604    /// This pins the *property*, not the mechanism, and deliberately so. The subgroup check in
605    /// [`HoprPixGroupElement::try_into_pix_group`] is not the step that rejects here: the backend's
606    /// own `GroupEncoding::from_bytes` already refuses a non-prime-order point, so the encoding
607    /// below never reaches `is_torsion_free`. That makes the filter unpinnable — no test can tell
608    /// whether it is present — and it is retained as defence in depth against a backend or curve
609    /// change that stops checking, which on a cofactor-8 curve is a real hazard rather than a
610    /// hypothetical one.
611    ///
612    /// On secp256k1 the cofactor is 1, no such point exists, and the case is vacuous.
613    #[cfg(all(feature = "pix-bjj", not(feature = "pix-secp256k1")))]
614    #[test]
615    fn pix_group_element_rejects_a_small_order_point() {
616        use hopr_protocol_pix::Group;
617        type Affine = <BabyJubJub as crypto_traits::elliptic_curve::CurveArithmetic>::AffinePoint;
618
619        // In twisted Edwards coordinates the identity is (0, 1), and (0, -1) is the unique point of
620        // order 2: it satisfies a·0² + (−1)² = 1 = 1 + d·0²·(−1)², and doubling it gives the
621        // identity. Reading the coordinates off the identity keeps the base field unnamed, so this
622        // needs no direct dependency on the curve backend.
623        //
624        // Built from the fields rather than through `AffinePoint::new`, which is a *safe*
625        // constructor and rejects exactly the points this test is about.
626        let identity = PixGroup::<HoprPixSpec>::identity().to_affine();
627        let order_two = Affine {
628            x: identity.x,
629            y: -identity.y,
630        };
631        assert!(order_two.is_on_curve(), "(0, -1) must be a valid curve point");
632        assert!(
633            !order_two.is_in_prime_order_subgroup(),
634            "a point of order 2 must not be in the prime-order subgroup"
635        );
636
637        let repr = hopr_protocol_pix::GroupEncoding::to_bytes(&PixGroup::<HoprPixSpec>::from(order_two));
638        assert!(
639            HoprPixGroupElement(repr).try_into_pix_group().is_err(),
640            "a small-order point must not parse into a pix group element"
641        );
642    }
643
644    #[test]
645    fn pix_commitment_proof_wire_wrapper_round_trips() -> anyhow::Result<()> {
646        // See `random_pix_group_element` above: trait-provided on Baby JubJub, inherent on
647        // secp256k1.
648        #[cfg(all(feature = "pix-bjj", not(feature = "pix-secp256k1")))]
649        use hopr_protocol_pix::Group;
650        use hopr_protocol_pix::SsaId;
651
652        let ssa_id = SsaId::new(SimplePseudonym::random(), 1.try_into()?);
653        let secret = <hopr_protocol_pix::PixScalar<HoprPixSpec> as crypto_traits::elliptic_curve::Field>::random(
654            &mut hopr_types::crypto_random::rng(),
655        );
656        let commitment = PixGroup::<HoprPixSpec>::mul_by_generator(&secret);
657        let proof = SsaCommitmentProof::<HoprPixSpec>::prove(&ssa_id, &secret, &commitment)?;
658
659        let wire = HoprPixCommitmentProof::from(proof);
660        assert_eq!(wire.as_ref().len(), HOPR_PIX_COMMITMENT_PROOF_SIZE);
661        assert_eq!(wire.to_string(), const_hex::encode(wire.as_ref()));
662
663        let recovered = wire.try_into_pix_proof()?;
664        assert_eq!(recovered, proof);
665        assert!(
666            recovered.verify(&ssa_id, &commitment),
667            "the proof must survive the wire"
668        );
669
670        assert_eq!(HoprPixCommitmentProof::try_from(wire.as_ref())?, wire);
671        for wrong in [&wire.as_ref()[..HOPR_PIX_COMMITMENT_PROOF_SIZE - 1], &[][..]] {
672            assert!(
673                HoprPixCommitmentProof::try_from(wrong).is_err(),
674                "only the exact length may parse"
675            );
676        }
677
678        Ok(())
679    }
680
681    /// The curve feature selects a wire format, and these are its dimensions.
682    ///
683    /// `StartProtocol` derives its `SsaCommit` and `SsaRequest` layouts and its chunking from these
684    /// two sizes, so changing either changes what every PIX peer must parse — while the protocol
685    /// version byte stays where it is. That is why the curve is a network-wide build invariant
686    /// rather than a per-node settlement preference, and why a node announces its
687    /// [`PixSuite`](hopr_protocol_pix::PixSuite) in `PixParams` so a mismatch is refused at Session
688    /// establishment instead of surfacing as undecodable Start traffic.
689    ///
690    /// Pinned per feature arm because a size change is otherwise completely silent: nothing fails to
691    /// compile, and the first symptom is peers that cannot talk to each other.
692    #[test]
693    fn pix_wire_element_sizes_are_fixed_by_the_curve_feature() {
694        #[cfg(all(feature = "pix-bjj", not(feature = "pix-secp256k1")))]
695        {
696            assert_eq!(32, size_of::<HoprPixGroupRepr>(), "Baby JubJub compressed point");
697            assert_eq!(64, HOPR_PIX_COMMITMENT_PROOF_SIZE, "Baby JubJub commitment proof");
698            assert_eq!(
699                hopr_protocol_pix::PixSuite::BabyJubJub,
700                <HoprPixSpec as hopr_protocol_pix::PixSpec>::PIX_SUITE,
701                "the announced suite must name the curve actually compiled in"
702            );
703        }
704        #[cfg(any(feature = "pix-secp256k1", not(feature = "pix-bjj")))]
705        {
706            assert_eq!(33, size_of::<HoprPixGroupRepr>(), "secp256k1 compressed point");
707            assert_eq!(65, HOPR_PIX_COMMITMENT_PROOF_SIZE, "secp256k1 commitment proof");
708            assert_eq!(
709                hopr_protocol_pix::PixSuite::Secp256k1,
710                <HoprPixSpec as hopr_protocol_pix::PixSpec>::PIX_SUITE,
711                "the announced suite must name the curve actually compiled in"
712            );
713        }
714    }
715}