Skip to main content

world_id_primitives/
session.rs

1use crate::{
2    FieldElement, PrimitiveError,
3    oprf::{OprfPrefix, OprfPrefixedFieldElement as _},
4    poseidon::{self, ds},
5};
6use embed_doc_image::embed_doc_image;
7use ruint::aliases::U256;
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
9
10/// An identifier for a session (can be re-used).
11///
12/// A session allows RPs to ensure that it's still the same World ID
13/// interacting with them across multiple interactions.
14///
15/// A `SessionId` is obtained after creating an initial session.
16///
17/// See the diagram below on how Session Proofs work, the [`SessionId`] and the `r` seed
18/// ![Session Proofs Diagram][session-proofs.png]
19///
20/// Note that the `action` stored here is unrelated to the randomized action used
21/// internally by [`SessionNullifier`]s — that randomized action exists only to ensure
22/// the circuit's nullifier output is unique per Session Proof.
23#[embed_doc_image("session-proofs.png", "assets/session-proofs.png")]
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct SessionId {
26    /// The actual commitment being verified in the ZK-circuit.
27    ///
28    /// It is computed as H(DS_C || leaf_index || session_id_r_seed), see
29    /// `signal computed_id_commitment` in `oprf_nullifier.circom`.
30    pub commitment: FieldElement,
31    /// A random seed generated by the authenticator in the initial Uniqueness Proof.
32    ///
33    /// This seed is the input to the OPRF Query to derive `session_id_r_seed` (`r`). It
34    /// is part of the `session_id` so the RP can provide it when requesting a Session Proof.
35    ///
36    /// # Important: Prefix
37    /// To ensure there are no collisions between the generated `r`s and the nullifiers
38    /// for Uniqueness Proofs (as they use the same OPRF Key and query structure), the
39    /// `oprf_seed`s, which are plugged as `action` in the Query Proof (see `QueryProofCircuitInput` in `world-id-proof`),
40    /// MUST be prefixed with an explicit byte of `0x01`. All other actions have a `0x00` byte prefix. This
41    /// collision avoidance is important because it ensures that any requests for nullifiers meant
42    /// for Uniqueness Proofs are always signed by the RP (otherwise, an RP signature for a Session Proof
43    /// could be used for requesting computation of _any_ nullifier).
44    ///
45    /// # Re-derivation
46    ///
47    /// The Authenticator can deterministically re-derive `r` from the OPRF nodes without
48    /// needing to cache `r` locally as:
49    /// ```text
50    /// r = OPRF(pk_rpId, DS_C || leafIndex || oprf_seed)
51    /// ```
52    pub oprf_seed: FieldElement,
53}
54
55impl SessionId {
56    const JSON_PREFIX: &str = "session_";
57
58    /// Creates a new session id. Most uses should default to `from_r_seed` instead.
59    ///
60    /// # Errors
61    /// If the provided `oprf_seed` is not prefixed properly.
62    pub fn new(commitment: FieldElement, oprf_seed: FieldElement) -> Result<Self, PrimitiveError> {
63        // OPRF Seeds must always start with a byte of `0x01`. See [`Self::oprf_seed`]
64        // for details. Panic is acceptable as `oprf_seed` generation should
65        // generally be done with `Self::from_r_seed`
66        if !oprf_seed.has_prefix(OprfPrefix::SessionOprfSeed) {
67            return Err(PrimitiveError::InvalidInput {
68                attribute: "session_id".to_string(),
69                reason: "inner oprf_seed is not valid".to_string(),
70            });
71        }
72        Ok(Self {
73            commitment,
74            oprf_seed,
75        })
76    }
77
78    /// Initializes a `SessionId` from the OPRF-output seed (`r`), and the `oprf_seed`
79    /// used as input for the OPRF computation.
80    ///
81    /// This matches the logic in `oprf_nullifier.circom` for computing the `commitment` from the OPRF seed.
82    ///
83    /// # Seed (`session_id_r_seed`)
84    /// - The seed MUST be computationally indistinguishable from random,
85    ///   i.e. uniformly distributed because it uses OPRF.
86    /// - When computed, the OPRF nodes will use the same `oprfKeyId` for the RP, with a different domain separator.
87    /// - Requesting this seed requires a properly signed request from the RP and a complete query proof.
88    /// - The seed generation is based on a randomly generated seed used as an "action" in a Query Proof. Note
89    ///   this `action` is different than the randomized action used internally by [`SessionNullifier`]s.
90    pub fn from_r_seed(
91        leaf_index: u64,
92        session_id_r_seed: FieldElement,
93        oprf_seed: FieldElement,
94    ) -> Result<Self, PrimitiveError> {
95        if !oprf_seed.has_prefix(OprfPrefix::SessionOprfSeed) {
96            return Err(PrimitiveError::InvalidInput {
97                attribute: "session_id".to_string(),
98                reason: "inner oprf_seed is not valid".to_string(),
99            });
100        }
101
102        let commitment = poseidon::hash(
103            ds::SESSION_COMMITMENT,
104            [leaf_index.into(), session_id_r_seed],
105        );
106        Ok(Self {
107            commitment,
108            oprf_seed,
109        })
110    }
111
112    /// Generates a new [`Self::oprf_seed`] to initialize a new Session.
113    pub fn generate_oprf_seed<R: rand::CryptoRng + rand::RngCore>(rng: &mut R) -> FieldElement {
114        FieldElement::random_with_prefix(rng, OprfPrefix::SessionOprfSeed)
115    }
116
117    /// Verifies that `session_id_r_seed` re-derives to this session id's [`Self::commitment`].
118    ///
119    /// Checks an `r` seed obtained elsewhere (e.g. an Authenticator's cache) without
120    /// re-deriving it via the OPRF nodes.
121    ///
122    /// # Errors
123    /// - If this session id's [`Self::oprf_seed`] is not valid.
124    /// - If the derived commitment does not match [`Self::commitment`].
125    pub fn verify_commitment(
126        &self,
127        leaf_index: u64,
128        session_id_r_seed: FieldElement,
129    ) -> Result<(), PrimitiveError> {
130        let computed = Self::from_r_seed(leaf_index, session_id_r_seed, self.oprf_seed)?;
131        if computed.commitment != self.commitment {
132            return Err(PrimitiveError::SessionIdCommitmentMismatch);
133        }
134        Ok(())
135    }
136
137    /// Returns the 64-byte big-endian representation (2 x 32-byte field elements).
138    #[must_use]
139    pub fn to_compressed_bytes(&self) -> [u8; 64] {
140        let mut bytes = [0u8; 64];
141        bytes[..32].copy_from_slice(&self.commitment.to_be_bytes());
142        bytes[32..].copy_from_slice(&self.oprf_seed.to_be_bytes());
143        bytes
144    }
145
146    /// Constructs from compressed bytes (must be exactly 64 bytes).
147    ///
148    /// # Errors
149    /// Returns an error if the input is not exactly 64 bytes or if values are not valid field elements.
150    pub fn from_compressed_bytes(bytes: &[u8]) -> Result<Self, String> {
151        if bytes.len() != 64 {
152            return Err(format!(
153                "Invalid length: expected 64 bytes, got {}",
154                bytes.len()
155            ));
156        }
157
158        let commitment = FieldElement::from_be_bytes(bytes[..32].try_into().unwrap())
159            .map_err(|e| format!("invalid commitment: {e}"))?;
160        let oprf_seed = FieldElement::from_be_bytes(bytes[32..].try_into().unwrap())
161            .map_err(|e| format!("invalid oprf_seed: {e}"))?;
162
163        if bytes[32] != OprfPrefix::SessionOprfSeed as u8 {
164            return Err("invalid prefix for oprf_seed".to_string());
165        }
166
167        Ok(Self {
168            commitment,
169            oprf_seed,
170        })
171    }
172}
173
174impl Default for SessionId {
175    fn default() -> Self {
176        let mut oprf_seed = [0u8; 32];
177        oprf_seed[0] = OprfPrefix::SessionOprfSeed as u8;
178        let oprf_seed = U256::from_be_bytes(oprf_seed)
179            .try_into()
180            .expect("always fits in the field");
181        Self {
182            commitment: FieldElement::ZERO,
183            oprf_seed,
184        }
185    }
186}
187
188impl Serialize for SessionId {
189    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
190    where
191        S: Serializer,
192    {
193        let bytes = self.to_compressed_bytes();
194        if serializer.is_human_readable() {
195            // JSON: prefixed hex-encoded compressed bytes for explicit typing.
196            serializer.serialize_str(&format!("{}{}", Self::JSON_PREFIX, hex::encode(bytes)))
197        } else {
198            // Binary: compressed bytes
199            serializer.serialize_bytes(&bytes)
200        }
201    }
202}
203
204impl<'de> Deserialize<'de> for SessionId {
205    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
206    where
207        D: Deserializer<'de>,
208    {
209        let bytes = if deserializer.is_human_readable() {
210            let value = String::deserialize(deserializer)?;
211            let hex_str = value.strip_prefix(Self::JSON_PREFIX).ok_or_else(|| {
212                D::Error::custom(format!(
213                    "session id must start with '{}'",
214                    Self::JSON_PREFIX
215                ))
216            })?;
217            hex::decode(hex_str).map_err(D::Error::custom)?
218        } else {
219            Vec::deserialize(deserializer)?
220        };
221
222        Self::from_compressed_bytes(&bytes).map_err(D::Error::custom)
223    }
224}
225
226/// How a proof request refers to a session.
227///
228/// Wire encoding (the request's `session_id` field): absent or `null` → [`Self::None`],
229/// `"create"` → [`Self::Create`], a `"session_"`-prefixed id → [`Self::Existing`].
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
231pub enum SessionRef {
232    /// No session involvement.
233    #[default]
234    None,
235    /// Mint a fresh session. For [`crate::ProofType::Session`] this proves the new
236    /// session in the same response; for [`crate::ProofType::Uniqueness`] this
237    /// returns a uniqueness proof committed to the newly minted session.
238    Create,
239    /// Refer to an existing session. Only valid for [`crate::ProofType::Session`].
240    Existing(SessionId),
241}
242
243impl SessionRef {
244    const CREATE_TOKEN: &str = "create";
245
246    /// Returns true if the request involves no session.
247    #[must_use]
248    pub const fn is_none(&self) -> bool {
249        matches!(self, Self::None)
250    }
251
252    /// Returns true if the request asks to mint a fresh session.
253    #[must_use]
254    pub const fn is_create(&self) -> bool {
255        matches!(self, Self::Create)
256    }
257
258    /// Returns the referenced existing session id, if any.
259    #[must_use]
260    pub const fn existing(&self) -> Option<SessionId> {
261        match self {
262            Self::Existing(id) => Some(*id),
263            _ => None,
264        }
265    }
266}
267
268impl From<SessionId> for SessionRef {
269    fn from(id: SessionId) -> Self {
270        Self::Existing(id)
271    }
272}
273
274impl Serialize for SessionRef {
275    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
276    where
277        S: Serializer,
278    {
279        match self {
280            Self::None => serializer.serialize_none(),
281            Self::Create => {
282                if serializer.is_human_readable() {
283                    serializer.serialize_str(Self::CREATE_TOKEN)
284                } else {
285                    // Binary: 6-byte token, cannot collide with the 64-byte `SessionId` encoding
286                    serializer.serialize_bytes(Self::CREATE_TOKEN.as_bytes())
287                }
288            }
289            Self::Existing(id) => id.serialize(serializer),
290        }
291    }
292}
293
294impl<'de> Deserialize<'de> for SessionRef {
295    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
296    where
297        D: Deserializer<'de>,
298    {
299        struct SessionRefVisitor;
300
301        impl<'de> serde::de::Visitor<'de> for SessionRefVisitor {
302            type Value = SessionRef;
303
304            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
305                write!(
306                    formatter,
307                    "null, \"{}\", or a '{}'-prefixed session id",
308                    SessionRef::CREATE_TOKEN,
309                    SessionId::JSON_PREFIX
310                )
311            }
312
313            fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
314                Ok(SessionRef::None)
315            }
316
317            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
318                Ok(SessionRef::None)
319            }
320
321            fn visit_some<D2>(self, deserializer: D2) -> Result<Self::Value, D2::Error>
322            where
323                D2: Deserializer<'de>,
324            {
325                if deserializer.is_human_readable() {
326                    let value = String::deserialize(deserializer)?;
327                    if value == SessionRef::CREATE_TOKEN {
328                        return Ok(SessionRef::Create);
329                    }
330                    let hex_str = value.strip_prefix(SessionId::JSON_PREFIX).ok_or_else(|| {
331                        D2::Error::custom(format!(
332                            "session_id must be \"{}\" or start with '{}'",
333                            SessionRef::CREATE_TOKEN,
334                            SessionId::JSON_PREFIX
335                        ))
336                    })?;
337                    let bytes = hex::decode(hex_str).map_err(D2::Error::custom)?;
338                    SessionId::from_compressed_bytes(&bytes)
339                        .map(SessionRef::Existing)
340                        .map_err(D2::Error::custom)
341                } else {
342                    let bytes = Vec::<u8>::deserialize(deserializer)?;
343                    if bytes == SessionRef::CREATE_TOKEN.as_bytes() {
344                        return Ok(SessionRef::Create);
345                    }
346                    SessionId::from_compressed_bytes(&bytes)
347                        .map(SessionRef::Existing)
348                        .map_err(D2::Error::custom)
349                }
350            }
351        }
352
353        deserializer.deserialize_option(SessionRefVisitor)
354    }
355}
356
357/// A session nullifier for World ID Session proofs. It is analogous to a request nonce,
358/// it **does NOT guarantee uniqueness of a World ID** as a `Nullifier` does.
359///
360/// This type is intended to be opaque for RPs. For an RP context, they should only
361/// be concerned of this needing to be passthrough to the `verifySession()` contract function.
362///
363/// This type exists as an adaptation to be able to use the same ZK-circuit for
364/// both Uniqueness Proofs and Session Proofs, and it encompasses:
365/// - the nullifier used as the proof output.
366/// - a random action bound to the same proof.
367///
368/// The `WorldIDVerifier.sol` contract expects this as a `uint256[2]` array
369/// use `as_ethereum_representation()` for conversion.
370///
371/// # Future
372///
373/// Note the session nullifier exists **only** to support the same ZK-circuit than for Uniqueness Proofs; as
374/// World ID evolves to a different proving system which won't require circuit precompiles, a new circuit MUST
375/// be created which does not generate a nullifier at all, and the input randomized action will not be required either.
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
377pub struct SessionNullifier {
378    /// The nullifier value for this proof.
379    nullifier: FieldElement,
380    /// The random action value bound to this session proof.
381    action: FieldElement,
382}
383
384impl SessionNullifier {
385    const JSON_PREFIX: &str = "snil_";
386
387    /// Creates a new session nullifier.
388    pub fn new(nullifier: FieldElement, action: FieldElement) -> Result<Self, PrimitiveError> {
389        if !action.has_prefix(OprfPrefix::SessionAction) {
390            return Err(PrimitiveError::InvalidInput {
391                attribute: "session_nullifier".to_string(),
392                reason: "inner action is not valid".to_string(),
393            });
394        }
395        Ok(Self { nullifier, action })
396    }
397
398    /// Returns the nullifier value.
399    #[must_use]
400    pub const fn nullifier(&self) -> FieldElement {
401        self.nullifier
402    }
403
404    /// Returns the action value.
405    #[must_use]
406    pub const fn action(&self) -> FieldElement {
407        self.action
408    }
409
410    /// Returns the session nullifier as an Ethereum-compatible array for `verifySession()`.
411    ///
412    /// Format: `[nullifier, action]` matching the contract's `uint256[2] sessionNullifier`.
413    #[must_use]
414    pub fn as_ethereum_representation(&self) -> [U256; 2] {
415        [self.nullifier.into(), self.action.into()]
416    }
417
418    /// Creates a session nullifier from an Ethereum representation.
419    ///
420    /// # Errors
421    /// Returns an error if the U256 values are not valid field elements.
422    pub fn from_ethereum_representation(value: [U256; 2]) -> Result<Self, String> {
423        let nullifier =
424            FieldElement::try_from(value[0]).map_err(|e| format!("invalid nullifier: {e}"))?;
425        let action =
426            FieldElement::try_from(value[1]).map_err(|e| format!("invalid action: {e}"))?;
427
428        if !action.has_prefix(OprfPrefix::SessionAction) {
429            return Err("inner action is not valid".to_string());
430        }
431        Ok(Self { nullifier, action })
432    }
433
434    /// Returns the 64-byte big-endian representation (2 x 32-byte field elements).
435    #[must_use]
436    pub fn to_compressed_bytes(&self) -> [u8; 64] {
437        let mut bytes = [0u8; 64];
438        bytes[..32].copy_from_slice(&self.nullifier.to_be_bytes());
439        bytes[32..].copy_from_slice(&self.action.to_be_bytes());
440        bytes
441    }
442
443    /// Constructs from compressed bytes (must be exactly 64 bytes).
444    ///
445    /// # Errors
446    /// Returns an error if the input is not exactly 64 bytes or if values are not valid field elements.
447    pub fn from_compressed_bytes(bytes: &[u8]) -> Result<Self, String> {
448        if bytes.len() != 64 {
449            return Err(format!(
450                "Invalid length: expected 64 bytes, got {}",
451                bytes.len()
452            ));
453        }
454
455        let nullifier = FieldElement::from_be_bytes(bytes[..32].try_into().unwrap())
456            .map_err(|e| format!("invalid nullifier: {e}"))?;
457        let action = FieldElement::from_be_bytes(bytes[32..].try_into().unwrap())
458            .map_err(|e| format!("invalid action: {e}"))?;
459
460        if bytes[32] != OprfPrefix::SessionAction as u8 {
461            return Err("invalid action. missing expected prefix.".to_string());
462        }
463
464        Ok(Self { nullifier, action })
465    }
466}
467
468impl Default for SessionNullifier {
469    fn default() -> Self {
470        let mut action = [0u8; 32];
471        action[0] = OprfPrefix::SessionAction as u8;
472        let action = U256::from_be_bytes(action)
473            .try_into()
474            .expect("always fits in the field");
475        Self {
476            nullifier: FieldElement::ZERO,
477            action,
478        }
479    }
480}
481
482impl Serialize for SessionNullifier {
483    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
484    where
485        S: Serializer,
486    {
487        let bytes = self.to_compressed_bytes();
488        if serializer.is_human_readable() {
489            // JSON: prefixed hex-encoded compressed bytes for explicit typing.
490            serializer.serialize_str(&format!("{}{}", Self::JSON_PREFIX, hex::encode(bytes)))
491        } else {
492            // Binary: compressed bytes
493            serializer.serialize_bytes(&bytes)
494        }
495    }
496}
497
498impl<'de> Deserialize<'de> for SessionNullifier {
499    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
500    where
501        D: Deserializer<'de>,
502    {
503        let bytes = if deserializer.is_human_readable() {
504            let value = String::deserialize(deserializer)?;
505            let hex_str = value.strip_prefix(Self::JSON_PREFIX).ok_or_else(|| {
506                D::Error::custom(format!(
507                    "session nullifier must start with '{}'",
508                    Self::JSON_PREFIX
509                ))
510            })?;
511            hex::decode(hex_str).map_err(D::Error::custom)?
512        } else {
513            Vec::deserialize(deserializer)?
514        };
515
516        Self::from_compressed_bytes(&bytes).map_err(D::Error::custom)
517    }
518}
519
520impl From<SessionNullifier> for [U256; 2] {
521    fn from(value: SessionNullifier) -> Self {
522        value.as_ethereum_representation()
523    }
524}
525
526#[cfg(test)]
527mod session_id_tests {
528    use super::*;
529    use ruint::uint;
530
531    fn test_field_element(value: u64) -> FieldElement {
532        FieldElement::from(value)
533    }
534
535    /// Creates an oprf_seed with the right prefix
536    fn test_oprf_seed(value: u64) -> FieldElement {
537        // set the first byte to 0x01; no need to clear the first bits as the input is u64
538        let n = U256::from(value)
539            | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256);
540        FieldElement::try_from(n).expect("test value fits in field")
541    }
542
543    #[test]
544    fn test_new_and_accessors() {
545        let commitment = test_field_element(1001);
546        let seed = test_oprf_seed(42);
547        let id = SessionId::new(commitment, seed).unwrap();
548
549        assert_eq!(id.commitment, commitment);
550        assert_eq!(id.oprf_seed, seed);
551    }
552
553    #[test]
554    fn test_default() {
555        let id = SessionId::default();
556        assert_eq!(id.commitment, FieldElement::ZERO);
557        assert_eq!(
558            id.oprf_seed,
559            uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256)
560                .try_into()
561                .unwrap()
562        );
563    }
564
565    #[test]
566    fn test_bytes_roundtrip() {
567        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
568        let bytes = id.to_compressed_bytes();
569
570        assert_eq!(bytes.len(), 64);
571
572        let decoded = SessionId::from_compressed_bytes(&bytes).unwrap();
573        assert_eq!(id, decoded);
574    }
575
576    #[test]
577    fn test_bytes_use_field_element_encoding() {
578        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
579        let bytes = id.to_compressed_bytes();
580
581        let mut expected = [0u8; 64];
582        expected[..32].copy_from_slice(&id.commitment.to_be_bytes());
583        expected[32..].copy_from_slice(&id.oprf_seed.to_be_bytes());
584        assert_eq!(bytes, expected);
585    }
586
587    #[test]
588    fn test_invalid_bytes_length() {
589        let too_short = vec![0u8; 63];
590        let result = SessionId::from_compressed_bytes(&too_short);
591        assert!(result.is_err());
592        assert!(result.unwrap_err().contains("Invalid length"));
593
594        let too_long = vec![0u8; 65];
595        let result = SessionId::from_compressed_bytes(&too_long);
596        assert!(result.is_err());
597        assert!(result.unwrap_err().contains("Invalid length"));
598    }
599
600    #[test]
601    fn test_from_compressed_bytes_rejects_wrong_oprf_seed_prefix() {
602        let mut bytes = [0u8; 64];
603        // Valid commitment (zero is a valid field element)
604        // oprf_seed with wrong prefix: 0x00 instead of 0x01
605        bytes[32] = 0x00;
606        let result = SessionId::from_compressed_bytes(&bytes);
607        assert!(result.is_err());
608        assert!(
609            result.unwrap_err().contains("invalid prefix"),
610            "should reject oprf_seed without 0x01 prefix"
611        );
612    }
613
614    #[test]
615    fn test_json_roundtrip() {
616        let id = SessionId::new(test_field_element(1001), test_oprf_seed(42)).unwrap();
617        let json = serde_json::to_string(&id).unwrap();
618
619        assert!(json.starts_with("\"session_"));
620        assert!(json.ends_with('"'));
621
622        let decoded: SessionId = serde_json::from_str(&json).unwrap();
623        assert_eq!(id, decoded);
624    }
625
626    #[test]
627    fn test_json_format() {
628        let id = SessionId::new(test_field_element(1), test_oprf_seed(2)).unwrap();
629        let json = serde_json::to_string(&id).unwrap();
630
631        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
632        assert!(parsed.is_string());
633        let value = parsed.as_str().unwrap();
634        assert!(value.starts_with("session_"));
635    }
636
637    #[test]
638    fn test_json_wrong_prefix_rejected() {
639        let result = serde_json::from_str::<SessionId>("\"snil_00\"");
640        assert!(result.is_err());
641    }
642
643    #[test]
644    fn test_generates_random_oprf_seed() {
645        let mut rng = rand::rngs::OsRng;
646
647        let seed_1 = SessionId::generate_oprf_seed(&mut rng);
648        let seed_2 = SessionId::generate_oprf_seed(&mut rng);
649
650        assert_ne!(seed_1, seed_2);
651    }
652
653    #[test]
654    fn test_from_r_seed_generated_seed_has_session_prefix() {
655        let mut rng = rand::rngs::OsRng;
656
657        for _ in 0..50 {
658            let seed = SessionId::generate_oprf_seed(&mut rng);
659            // Top byte must be exactly 0x01: bit 248 set, bits 249-255 clear
660            assert_eq!(seed.to_u256() >> 248, U256::from(1));
661        }
662    }
663
664    #[test]
665    fn test_from_r_seed_commitment_snapshot() {
666        let leaf_index = 42u64;
667        let r_seed = test_field_element(123);
668        let oprf_seed = test_oprf_seed(456);
669
670        let session_id = SessionId::from_r_seed(leaf_index, r_seed, oprf_seed).unwrap();
671
672        let expected = "0x1e7853ebd4fc9d9f0232fdcfae116023610bdf66a22e2700445d7a2e0e7e6152"
673            .parse::<U256>()
674            .unwrap();
675        assert_eq!(
676            session_id.commitment.to_u256(),
677            expected,
678            "commitment snapashot for session commitment changed"
679        );
680    }
681
682    #[test]
683    fn test_verify_commitment() {
684        let leaf_index = 42u64;
685        let r_seed = test_field_element(123);
686        let session_id = SessionId::from_r_seed(leaf_index, r_seed, test_oprf_seed(456)).unwrap();
687
688        session_id.verify_commitment(leaf_index, r_seed).unwrap();
689        assert_eq!(
690            session_id
691                .verify_commitment(leaf_index, test_field_element(124))
692                .unwrap_err(),
693            PrimitiveError::SessionIdCommitmentMismatch
694        );
695    }
696}
697
698#[cfg(test)]
699mod session_ref_tests {
700    use super::*;
701    use ruint::uint;
702
703    fn test_session_id() -> SessionId {
704        let oprf_seed = U256::from(42u64)
705            | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256);
706        SessionId::new(
707            FieldElement::from(1001u64),
708            FieldElement::try_from(oprf_seed).expect("test value fits in field"),
709        )
710        .expect("valid session id")
711    }
712
713    #[test]
714    fn test_default_is_none() {
715        assert_eq!(SessionRef::default(), SessionRef::None);
716        assert!(SessionRef::None.is_none());
717        assert!(SessionRef::Create.is_create());
718        assert_eq!(
719            SessionRef::Existing(test_session_id()).existing(),
720            Some(test_session_id())
721        );
722        assert_eq!(
723            SessionRef::from(test_session_id()).existing(),
724            Some(test_session_id())
725        );
726    }
727
728    #[test]
729    fn test_deserialize_create_token() {
730        let parsed: SessionRef = serde_json::from_str("\"create\"").unwrap();
731        assert_eq!(parsed, SessionRef::Create);
732    }
733
734    #[test]
735    fn test_deserialize_existing_matches_session_id_parse() {
736        let id = test_session_id();
737        let json = serde_json::to_string(&id).unwrap();
738        let parsed: SessionRef = serde_json::from_str(&json).unwrap();
739        assert_eq!(parsed, SessionRef::Existing(id));
740    }
741
742    #[test]
743    fn test_deserialize_null_is_none() {
744        let parsed: SessionRef = serde_json::from_str("null").unwrap();
745        assert_eq!(parsed, SessionRef::None);
746    }
747
748    #[test]
749    fn test_rejects_unknown_strings() {
750        for input in ["\"Create\"", "\"creat\"", "\"snil_00\"", "\"\""] {
751            let result = serde_json::from_str::<SessionRef>(input);
752            let err = result.expect_err(input).to_string();
753            assert!(
754                err.contains("create") || err.contains("session_"),
755                "error for {input} should name the accepted forms: {err}"
756            );
757        }
758    }
759
760    #[test]
761    fn test_json_roundtrip_all_states() {
762        let cases = [
763            (SessionRef::None, "null"),
764            (SessionRef::Create, "\"create\""),
765        ];
766        for (state, expected_json) in cases {
767            let json = serde_json::to_string(&state).unwrap();
768            assert_eq!(json, expected_json);
769            let parsed: SessionRef = serde_json::from_str(&json).unwrap();
770            assert_eq!(parsed, state);
771        }
772
773        let existing = SessionRef::Existing(test_session_id());
774        let json = serde_json::to_string(&existing).unwrap();
775        assert!(json.starts_with("\"session_"));
776        let parsed: SessionRef = serde_json::from_str(&json).unwrap();
777        assert_eq!(parsed, existing);
778    }
779
780    #[test]
781    fn test_cbor_roundtrip_all_states() {
782        for state in [
783            SessionRef::None,
784            SessionRef::Create,
785            SessionRef::Existing(test_session_id()),
786        ] {
787            let mut buffer = Vec::new();
788            ciborium::into_writer(&state, &mut buffer).unwrap();
789            let decoded: SessionRef = ciborium::from_reader(&buffer[..]).unwrap();
790            assert_eq!(state, decoded);
791        }
792    }
793}
794
795#[cfg(test)]
796mod session_nullifier_tests {
797    use super::*;
798    use ruint::uint;
799
800    fn test_field_element(value: u64) -> FieldElement {
801        FieldElement::from(value)
802    }
803
804    /// Creates an action with the required `0x02` prefix
805    fn test_action(value: u64) -> FieldElement {
806        let n = U256::from(value)
807            | uint!(0x0200000000000000000000000000000000000000000000000000000000000000_U256);
808        FieldElement::try_from(n).expect("test value fits in field")
809    }
810
811    #[test]
812    fn test_new_and_accessors() {
813        let nullifier = test_field_element(1001);
814        let action = test_action(42);
815        let session = SessionNullifier::new(nullifier, action).unwrap();
816
817        assert_eq!(session.nullifier(), nullifier);
818        assert_eq!(session.action(), action);
819    }
820
821    #[test]
822    fn test_as_ethereum_representation() {
823        let nullifier = test_field_element(100);
824        let action = test_action(200);
825        let session = SessionNullifier::new(nullifier, action).unwrap();
826
827        let repr = session.as_ethereum_representation();
828        assert_eq!(repr[0], U256::from(100));
829        assert_eq!(repr[1], action.to_u256());
830    }
831
832    #[test]
833    fn test_from_ethereum_representation() {
834        let action = test_action(200);
835        let repr = [U256::from(100), action.to_u256()];
836        let session = SessionNullifier::from_ethereum_representation(repr).unwrap();
837
838        assert_eq!(session.nullifier(), test_field_element(100));
839        assert_eq!(session.action(), action);
840    }
841
842    #[test]
843    fn test_json_roundtrip() {
844        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
845        let json = serde_json::to_string(&session).unwrap();
846
847        // Verify JSON uses the prefixed compact representation
848        assert!(json.starts_with("\"snil_"));
849        assert!(json.ends_with('"'));
850
851        // Verify roundtrip
852        let decoded: SessionNullifier = serde_json::from_str(&json).unwrap();
853        assert_eq!(session, decoded);
854    }
855
856    #[test]
857    fn test_json_format() {
858        let session = SessionNullifier::new(test_field_element(1), test_action(2)).unwrap();
859        let json = serde_json::to_string(&session).unwrap();
860
861        // Should be a prefixed compact string
862        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
863        assert!(parsed.is_string());
864        let value = parsed.as_str().unwrap();
865        assert!(value.starts_with("snil_"));
866    }
867
868    #[test]
869    fn test_bytes_roundtrip() {
870        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
871        let bytes = session.to_compressed_bytes();
872
873        assert_eq!(bytes.len(), 64); // 32 + 32 bytes
874
875        let decoded = SessionNullifier::from_compressed_bytes(&bytes).unwrap();
876        assert_eq!(session, decoded);
877    }
878
879    #[test]
880    fn test_bytes_use_field_element_encoding() {
881        let session = SessionNullifier::new(test_field_element(1001), test_action(42)).unwrap();
882        let bytes = session.to_compressed_bytes();
883
884        let mut expected = [0u8; 64];
885        expected[..32].copy_from_slice(&session.nullifier().to_be_bytes());
886        expected[32..].copy_from_slice(&session.action().to_be_bytes());
887        assert_eq!(bytes, expected);
888    }
889
890    #[test]
891    fn test_invalid_bytes_length() {
892        let too_short = vec![0u8; 63];
893        let result = SessionNullifier::from_compressed_bytes(&too_short);
894        assert!(result.is_err());
895        assert!(result.unwrap_err().contains("Invalid length"));
896
897        let too_long = vec![0u8; 65];
898        let result = SessionNullifier::from_compressed_bytes(&too_long);
899        assert!(result.is_err());
900        assert!(result.unwrap_err().contains("Invalid length"));
901    }
902
903    #[test]
904    fn test_default() {
905        let session = SessionNullifier::default();
906        assert_eq!(session.nullifier(), FieldElement::ZERO);
907        let expected_action: FieldElement =
908            uint!(0x0200000000000000000000000000000000000000000000000000000000000000_U256)
909                .try_into()
910                .unwrap();
911        assert_eq!(session.action(), expected_action);
912    }
913
914    #[test]
915    fn test_into_u256_array() {
916        let action = test_action(200);
917        let session = SessionNullifier::new(test_field_element(100), action).unwrap();
918        let arr: [U256; 2] = session.into();
919
920        assert_eq!(arr[0], U256::from(100));
921        assert_eq!(arr[1], action.to_u256());
922    }
923
924    #[test]
925    fn test_new_rejects_invalid_action_prefix() {
926        let nullifier = test_field_element(1);
927        let bad_action = test_field_element(42); // no 0x02 prefix
928        let result = SessionNullifier::new(nullifier, bad_action);
929        assert!(result.is_err());
930
931        let err = result.unwrap_err();
932        assert!(
933            matches!(err, PrimitiveError::InvalidInput { .. }),
934            "expected InvalidInput, got {err:?}"
935        );
936    }
937
938    #[test]
939    fn test_new_rejects_oprf_seed_prefix_as_action() {
940        let nullifier = test_field_element(1);
941        // 0x01 prefix (OprfSeed) is not valid for Action
942        let oprf_prefixed = U256::from(42u64)
943            | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256);
944        let bad_action = FieldElement::try_from(oprf_prefixed).unwrap();
945        assert!(SessionNullifier::new(nullifier, bad_action).is_err());
946    }
947
948    #[test]
949    fn test_from_ethereum_representation_rejects_invalid_action() {
950        let repr = [U256::from(100), U256::from(200)]; // action has 0x00 prefix
951        let result = SessionNullifier::from_ethereum_representation(repr);
952        assert!(result.is_err());
953        assert!(
954            result.unwrap_err().contains("action"),
955            "error should mention the action"
956        );
957    }
958
959    #[test]
960    fn test_from_compressed_bytes_rejects_invalid_action_prefix() {
961        let mut bytes = [0u8; 64];
962        // Valid nullifier (zero), but action with 0x00 prefix
963        bytes[32] = 0x00;
964        let result = SessionNullifier::from_compressed_bytes(&bytes);
965        assert!(result.is_err());
966        assert!(
967            result.unwrap_err().contains("action"),
968            "error should mention the action"
969        );
970    }
971
972    #[test]
973    fn test_json_rejects_invalid_action_prefix() {
974        // Build JSON with a valid nullifier but an action lacking the 0x02 prefix
975        let nullifier = test_field_element(1);
976        let bad_action = test_field_element(2); // 0x00 prefix
977        let mut bytes = [0u8; 64];
978        bytes[..32].copy_from_slice(&nullifier.to_be_bytes());
979        bytes[32..].copy_from_slice(&bad_action.to_be_bytes());
980        let json = format!("\"snil_{}\"", hex::encode(bytes));
981
982        let result = serde_json::from_str::<SessionNullifier>(&json);
983        assert!(result.is_err());
984    }
985}