Skip to main content

zkgroup/api/avatars/
avatar_upload_credential.rs

1//
2// Copyright 2026 Signal Messenger, LLC.
3// SPDX-License-Identifier: AGPL-3.0-only
4//
5
6//! Provides AvatarUploadCredential and related types.
7//!
8//! AvatarUploadCredential is a MAC-based credential over:
9//! - a timestamp, truncated to day granularity (public, chosen by server at issuance)
10//! - a Pedersen commitment `Cm = [aci_scalar]*H1 + [zk_credential_key_secret]*H2 + [rotation_id]*H3`
11//!   (blinded at issuance, revealed for verification)
12//!
13//! The commitment Cm is information-theoretically hiding: even knowing all ACIs, the verifying
14//! server cannot determine which ACI produced a given Cm.
15//!
16//! At issuance, the client already knows `rotation_id` (the server returns it when the client sets
17//! its ZK credential key), so the client builds the full `Cm = [aci_scalar]*H1 +
18//! [zk_credential_key_secret]*H2 + [rotation_id]*H3` directly, blinds it, and provides a standalone
19//! proof that the blinded Cm is well-formed for the authenticated ACI and the ZK credential key
20//! known to the server. The server verifies that proof against its own `rotation_id` (it subtracts
21//! `[rotation_id]*H3` while reconstructing the proof's adjusted point), which forces the client to
22//! have used the server's value — so the server still controls the avatar slot rotation ID, and
23//! still never learns Cm because it stays blinded.
24//!
25//! At presentation, the credential reveals Cm to the verifying server along with a standard
26//! credential validity proof.
27
28// We use upper case variable names for curve points by convention.
29#![allow(non_snake_case)]
30
31use curve25519_dalek_signal::ristretto::RistrettoPoint;
32use curve25519_dalek_signal::scalar::Scalar;
33use curve25519_dalek_signal::traits::VartimeMultiscalarMul as _;
34use partial_default::PartialDefault;
35use poksho::ShoApi;
36use poksho::shoapi::ShoApiExt as _;
37use serde::{Deserialize, Serialize};
38
39use crate::common::serialization::ReservedByte;
40use crate::common::sho::Sho;
41use crate::common::simple_types::*;
42use crate::generic_server_params::{GenericServerPublicParams, GenericServerSecretParams};
43use crate::zk_credential_key::{ZkCredentialKeyPair, ZkCredentialPublicKey};
44use crate::{RANDOMNESS_LEN, ZkGroupVerificationFailure};
45
46// ---------------------------------------------------------------------------
47// System parameters: Pedersen commitment generators
48// ---------------------------------------------------------------------------
49
50/// Independent generators for the avatar commitment `Cm = [aci_scalar]*H1 + [zk_credential_key_secret]*H2 + [rotation_id]*H3`.
51///
52/// Derived deterministically from a fixed label. H1, H2, and H3 must be independent of each other
53/// and independent of generators used elsewhere (e.g., G_j3 from profile key commitments).
54struct AvatarCommitmentParams {
55    H1: RistrettoPoint,
56    H2: RistrettoPoint,
57    H3: RistrettoPoint,
58}
59
60impl AvatarCommitmentParams {
61    fn get_hardcoded() -> Self {
62        let mut sho = Sho::new_seed(b"20260329_Signal_AvatarUploadCredential_CommitmentParams");
63        Self {
64            H1: sho.get_point(),
65            H2: sho.get_point(),
66            H3: sho.get_point(),
67        }
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Commitment point (wraps a RistrettoPoint, implements RevealedAttribute)
73// ---------------------------------------------------------------------------
74
75#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, PartialDefault)]
76pub struct CommitmentPoint(RistrettoPoint);
77
78impl zkcredential::attributes::RevealedAttribute for CommitmentPoint {
79    fn as_point(&self) -> RistrettoPoint {
80        self.0
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Credential label and Cm well-formedness proof label
86// ---------------------------------------------------------------------------
87
88const CREDENTIAL_LABEL: &[u8] = b"20260329_Signal_AvatarUploadCredential";
89const CM_WELL_FORMEDNESS_PROOF_LABEL: &[u8] =
90    b"20260329_Signal_AvatarUploadCredential_CmWellFormednessProof";
91
92// ---------------------------------------------------------------------------
93// Cm well-formedness proof: proves blinded Cm is well-formed
94// ---------------------------------------------------------------------------
95
96/// The standalone proof that the blinded Cm is well-formed.
97///
98/// Proves knowledge of (r, zk_credential_key_secret) such that:
99///   D1      = r * G
100///   D2_adj  = r * Y + zk_credential_key_secret * H2     where D2_adj = D2 - aci_scalar * H1 - rotation_id * H3
101///   ZkCredKeyPub  = zk_credential_key_secret * G
102///
103/// The shared "zk_credential_key_secret" label across eqs 2-3 enforces that the blinded Cm uses the same
104/// zk_credential_key_secret as the known ZK credential public key.
105#[derive(Serialize, Deserialize, Clone, PartialDefault)]
106struct CmWellFormednessProof {
107    poksho_proof: Vec<u8>,
108}
109
110impl CmWellFormednessProof {
111    fn statement() -> poksho::Statement {
112        let mut st = poksho::Statement::new();
113        // "G" is the Ristretto basepoint, pre-assigned at index 0 by poksho.
114        // ZkCredKeyPub = zk_credential_key_secret * G uses the same basepoint.
115        st.add("D1", &[("r", "G")]);
116        st.add("D2_adj", &[("r", "Y"), ("zk_credential_key_secret", "H2")]);
117        st.add("ZkCredKeyPub", &[("zk_credential_key_secret", "G")]);
118        st
119    }
120
121    fn prove(
122        blinding_nonce: Scalar,
123        zk_credential_key_secret: Scalar,
124        blinded_cm: &zkcredential::issuance::blind::BlindedPoint,
125        D2_adj: RistrettoPoint,
126        blinding_public_key: &zkcredential::issuance::blind::BlindingPublicKey,
127        zk_credential_key_pub: RistrettoPoint,
128        randomness: [u8; RANDOMNESS_LEN],
129    ) -> Self {
130        // `D2_adj = blinded_cm.D2 - [aci_scalar]*H1 - [rotation_id]*H3` is supplied by the caller,
131        // which already computed those public terms while building Cm — so there are no scalar
132        // multiplications here. The verifier reconstructs the same point from public values (see
133        // `verify`); that reconstruction is what enforces soundness.
134        let params = AvatarCommitmentParams::get_hardcoded();
135
136        let mut scalar_args = poksho::ScalarArgs::new();
137        scalar_args.add("r", blinding_nonce);
138        scalar_args.add("zk_credential_key_secret", zk_credential_key_secret);
139
140        // Note: "G" is pre-assigned by poksho as the Ristretto basepoint (index 0),
141        // so it must NOT be included in point_args. You'll never guess what this code looked
142        // like before I wrote this comment.
143        let mut point_args = poksho::PointArgs::new();
144        point_args.add("Y", blinding_public_key.Y);
145        point_args.add("H2", params.H2);
146
147        point_args.add("D1", blinded_cm.D1);
148        point_args.add("D2_adj", D2_adj);
149        point_args.add("ZkCredKeyPub", zk_credential_key_pub);
150
151        let poksho_proof = Self::statement()
152            .prove(
153                &scalar_args,
154                &point_args,
155                CM_WELL_FORMEDNESS_PROOF_LABEL,
156                &randomness,
157            )
158            .expect("valid proof");
159
160        Self { poksho_proof }
161    }
162
163    fn verify(
164        &self,
165        blinded_cm: &zkcredential::issuance::blind::BlindedPoint,
166        blinding_public_key: &zkcredential::issuance::blind::BlindingPublicKey,
167        aci_scalar: Scalar,
168        rotation_id: u64,
169        zk_credential_key_pub: RistrettoPoint,
170    ) -> Result<(), ZkGroupVerificationFailure> {
171        let params = AvatarCommitmentParams::get_hardcoded();
172        //  Both scalars are public, so vartime is safe.
173        let D2_adj = blinded_cm.D2
174            - RistrettoPoint::vartime_multiscalar_mul(
175                [aci_scalar, Scalar::from(rotation_id)],
176                [params.H1, params.H3],
177            );
178
179        // Note: "G" is pre-assigned by poksho as the Ristretto basepoint (index 0),
180        // so it must NOT be included in point_args.
181        let mut point_args = poksho::PointArgs::new();
182        point_args.add("Y", blinding_public_key.Y);
183        point_args.add("H2", params.H2);
184
185        point_args.add("D1", blinded_cm.D1);
186        point_args.add("D2_adj", D2_adj);
187        point_args.add("ZkCredKeyPub", zk_credential_key_pub);
188
189        Self::statement()
190            .verify_proof(
191                &self.poksho_proof,
192                &point_args,
193                CM_WELL_FORMEDNESS_PROOF_LABEL,
194            )
195            .map_err(|_| ZkGroupVerificationFailure)
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Helpers
201// ---------------------------------------------------------------------------
202
203/// Interprets a 128-bit ACI as a scalar for use in the avatar commitment.
204///
205/// The UUID bytes are zero-padded to 32 bytes. Since the group order is ~2^252, this is
206/// injective on the 128-bit input range (no reduction occurs). No hash is needed because
207/// the ACI is public to both parties — we only need injectivity, not uniformity.
208fn aci_to_scalar(aci: libsignal_core::Aci) -> Scalar {
209    let uuid_bytes = uuid::Uuid::from(aci).into_bytes();
210    let mut scalar_bytes = [0u8; 32];
211    scalar_bytes[..16].copy_from_slice(&uuid_bytes);
212    Scalar::from_bytes_mod_order(scalar_bytes)
213}
214
215/// Computes the avatar upload commitment `Cm = [aci_scalar]*H1 + [zk_credential_key_secret]*H2 +
216/// [rotation_id]*H3`, returning it alongside the *public offset* `[aci_scalar]*H1 +
217/// [rotation_id]*H3`.
218fn avatar_commitment(
219    aci_scalar: Scalar,
220    zk_credential_key_secret: Scalar,
221    rotation_id: u64,
222) -> (RistrettoPoint, RistrettoPoint) {
223    let params = AvatarCommitmentParams::get_hardcoded();
224    let public_offset = RistrettoPoint::vartime_multiscalar_mul(
225        [aci_scalar, Scalar::from(rotation_id)],
226        [params.H1, params.H3],
227    );
228    let cm = public_offset + zk_credential_key_secret * params.H2;
229    (cm, public_offset)
230}
231
232fn check_avatar_upload_credential_redemption_time(
233    redemption_time: Timestamp,
234    current_time: Timestamp,
235) -> Result<(), ZkGroupVerificationFailure> {
236    let acceptable_start_time = redemption_time
237        .checked_sub_seconds(crate::SECONDS_PER_DAY)
238        .ok_or(ZkGroupVerificationFailure)?;
239    let acceptable_end_time = redemption_time
240        .checked_add_seconds(2 * crate::SECONDS_PER_DAY)
241        .ok_or(ZkGroupVerificationFailure)?;
242
243    if !(acceptable_start_time..=acceptable_end_time).contains(&current_time) {
244        return Err(ZkGroupVerificationFailure);
245    }
246
247    Ok(())
248}
249
250/// Computes the full avatar commitment `Cm = [aci_scalar]*H1 + [zk_credential_key_secret]*H2 +
251/// [rotation_id]*H3`, discarding the public offset. Used by tests that only need the commitment.
252#[cfg(test)]
253fn compute_cm(
254    aci_scalar: Scalar,
255    zk_credential_key_secret: Scalar,
256    rotation_id: u64,
257) -> RistrettoPoint {
258    avatar_commitment(aci_scalar, zk_credential_key_secret, rotation_id).0
259}
260
261// ---------------------------------------------------------------------------
262// Request context (client-side state, not sent over the wire)
263// ---------------------------------------------------------------------------
264
265#[derive(Clone, Serialize, Deserialize, PartialDefault)]
266pub struct AvatarUploadCredentialRequestContext {
267    reserved: ReservedByte,
268    blinded_cm: zkcredential::issuance::blind::BlindedPoint,
269    key_pair: zkcredential::issuance::blind::BlindingKeyPair,
270    cm_well_formedness_proof: CmWellFormednessProof,
271    cm: RistrettoPoint,
272}
273
274impl AvatarUploadCredentialRequestContext {
275    /// Constructs a new request context.
276    ///
277    /// `zk_credential_key_pair` is the account's long-term Ristretto ZK credential key pair.
278    ///
279    /// `rotation_id` is the server-chosen avatar slot rotation ID. The client already holds it (the
280    /// server returns it when the client sets its ZK credential key), so the client folds it into
281    /// the full commitment `Cm = [aci]*H1 + [zk_credential_key_secret]*H2 + [rotation_id]*H3` here.
282    /// The server later verifies the well-formedness proof against its own `rotation_id`, which
283    /// forces the client to have used the server's value.
284    pub fn new(
285        aci: libsignal_core::Aci,
286        zk_credential_key_pair: &ZkCredentialKeyPair,
287        rotation_id: u64,
288        randomness: RandomnessBytes,
289    ) -> Self {
290        let zk_credential_key_secret = zk_credential_key_pair.secret();
291        let zk_credential_key_pub = zk_credential_key_pair.public_key().point();
292
293        let mut sho = poksho::ShoHmacSha256::new(b"20260329_Signal_AvatarUploadCredentialRequest");
294        sho.absorb_and_ratchet(&randomness);
295
296        let aci_scalar = aci_to_scalar(aci);
297        let (cm, public_offset) =
298            avatar_commitment(aci_scalar, zk_credential_key_secret, rotation_id);
299        let cm_point = CommitmentPoint(cm);
300
301        let key_pair = zkcredential::issuance::blind::BlindingKeyPair::generate(&mut sho);
302        let blinded_cm_with_nonce = key_pair.blind(&cm_point, &mut sho);
303
304        // Extract the blinding nonce for the Cm well-formedness proof.
305        let blinding_nonce = blinded_cm_with_nonce.r.0;
306        // Strip the blinding_nonce by making a BlindedPoint<WithoutNonce>
307        let blinded_cm: zkcredential::issuance::blind::BlindedPoint = blinded_cm_with_nonce.into();
308
309        // The proof's adjusted point is the blinded D2 with the public terms removed. Both terms
310        // are already in `public_offset`, so this is a bare point subtraction (no scalar muls).
311        let D2_adj = blinded_cm.D2 - public_offset;
312
313        let proof_randomness: [u8; RANDOMNESS_LEN] = sho.squeeze_and_ratchet_as_array();
314
315        let cm_well_formedness_proof = CmWellFormednessProof::prove(
316            blinding_nonce,
317            zk_credential_key_secret,
318            &blinded_cm,
319            D2_adj,
320            key_pair.public_key(),
321            zk_credential_key_pub,
322            proof_randomness,
323        );
324
325        Self {
326            reserved: Default::default(),
327            blinded_cm,
328            key_pair,
329            cm_well_formedness_proof,
330            cm,
331        }
332    }
333
334    pub fn get_request(&self) -> AvatarUploadCredentialRequest {
335        AvatarUploadCredentialRequest {
336            reserved: Default::default(),
337            blinded_cm: self.blinded_cm,
338            public_key: *self.key_pair.public_key(),
339            cm_well_formedness_proof: self.cm_well_formedness_proof.clone(),
340        }
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Request (sent to the issuing server)
346// ---------------------------------------------------------------------------
347
348#[derive(Clone, Serialize, Deserialize, PartialDefault)]
349pub struct AvatarUploadCredentialRequest {
350    reserved: ReservedByte,
351    blinded_cm: zkcredential::issuance::blind::BlindedPoint,
352    public_key: zkcredential::issuance::blind::BlindingPublicKey,
353    cm_well_formedness_proof: CmWellFormednessProof,
354}
355
356impl AvatarUploadCredentialRequest {
357    /// Server-side: verify the Cm well-formedness proof and issue a blinded credential.
358    ///
359    /// The server must authenticate the client to obtain `aci`, and must supply
360    /// `zk_credential_key_pub` from its record for that account. The Cm well-formedness proof
361    /// binds the blinded commitment to this `zk_credential_key_pub`, so passing the wrong
362    /// value will fail proof verification.
363    ///
364    /// `rotation_id` is a server-chosen value that the client must already have folded into the
365    /// commitment `Cm = [aci]*H1 + [zk_credential_key_secret]*H2 + [rotation_id]*H3`. The server
366    /// supplies its own `rotation_id` here; the well-formedness proof is verified against it, so a
367    /// client that committed to a different value will fail issuance. The server never learns Cm
368    /// (it stays blinded) yet still controls the rotation ID.
369    ///
370    /// **Client-enforced invariant**: The client must enforce that the server
371    /// only changes `rotation_id` when the client's ZK credential key is
372    /// rotated. Otherwise a malicious server can fingerprint a client across
373    /// credential issuances by varying `rotation_id` while the client's ACI and
374    /// ZK credential key are stable: the server can recompute
375    /// `[delta_rotation_id]*H3` for any candidate (aci, zk_credential_key_pub) pair and check
376    /// whether the observed Cm-delta matches (of course it would have to test
377    /// all pairs because it wouldn't know which ones had the same (aci,zk_credential_key_pub),
378    /// but finding a match would still be meaningful). With this invariant,
379    /// observing two distinct rotation IDs for the same account proves the ZK
380    /// credential key has rotated, which severs the linkability of pre- and
381    /// post-rotation avatar slots.
382    pub fn issue(
383        &self,
384        aci: libsignal_core::Aci,
385        zk_credential_key_pub: &ZkCredentialPublicKey,
386        rotation_id: u64,
387        redemption_time: Timestamp,
388        params: &GenericServerSecretParams,
389        randomness: RandomnessBytes,
390    ) -> Result<AvatarUploadCredentialResponse, ZkGroupVerificationFailure> {
391        if !redemption_time.is_day_aligned() {
392            return Err(ZkGroupVerificationFailure);
393        }
394
395        // Verify the Cm well-formedness proof against the server-supplied zk_credential_key_pub and
396        // the server's own rotation_id. The verifier strips `[aci]*H1 + [rotation_id]*H3` from the
397        // blinded point, so a mismatched rotation_id fails here.
398        let aci_scalar = aci_to_scalar(aci);
399        self.cm_well_formedness_proof.verify(
400            &self.blinded_cm,
401            &self.public_key,
402            aci_scalar,
403            rotation_id,
404            zk_credential_key_pub.point(),
405        )?;
406
407        // Issue the blind credential over (timestamp, Cm). The blinded point already commits to the
408        // full Cm (including [rotation_id]*H3), so no server-side adjustment is needed.
409        let blinded_credential =
410            zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
411                .add_public_attribute(&redemption_time)
412                .add_blinded_revealed_attribute(&self.blinded_cm)
413                .issue(&params.credential_key, &self.public_key, randomness);
414
415        Ok(AvatarUploadCredentialResponse {
416            reserved: Default::default(),
417            redemption_time,
418            blinded_credential,
419        })
420    }
421}
422
423// ---------------------------------------------------------------------------
424// Response (sent from issuing server to client)
425// ---------------------------------------------------------------------------
426
427#[derive(Clone, Serialize, Deserialize, PartialDefault)]
428pub struct AvatarUploadCredentialResponse {
429    reserved: ReservedByte,
430    redemption_time: Timestamp,
431    blinded_credential: zkcredential::issuance::blind::BlindedIssuanceProof,
432}
433
434// ---------------------------------------------------------------------------
435// Receive (client-side: verify and unblind)
436// ---------------------------------------------------------------------------
437
438impl AvatarUploadCredentialRequestContext {
439    /// Verifies the issuing server's response and produces a usable [`AvatarUploadCredential`].
440    ///
441    /// The server chose the `redemption_time` and embedded it in `response`. The client doesn't
442    /// need to predict it, it only needs to confirm that the credential is usable *now*, since the
443    /// verifying server applies the same window (see [`AvatarUploadCredentialPresentation::verify`]).
444    /// `current_time` is the client's view of wall-clock time; the redemption time must be day-aligned
445    /// and fall inside the redemption window relative to it.
446    pub fn receive(
447        self,
448        response: AvatarUploadCredentialResponse,
449        params: &GenericServerPublicParams,
450        current_time: Timestamp,
451    ) -> Result<AvatarUploadCredential, ZkGroupVerificationFailure> {
452        if !response.redemption_time.is_day_aligned() {
453            return Err(ZkGroupVerificationFailure);
454        }
455        check_avatar_upload_credential_redemption_time(response.redemption_time, current_time)?;
456
457        // The blinded point already commits to the full Cm (the client folded in [rotation_id]*H3
458        // at request time), so we verify the issuance directly against it — no adjustment needed.
459        let credential = zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
460            .add_public_attribute(&response.redemption_time)
461            .add_blinded_revealed_attribute(&self.blinded_cm)
462            .verify(
463                &params.credential_key,
464                &self.key_pair,
465                response.blinded_credential,
466            )
467            .map_err(|_| ZkGroupVerificationFailure)?;
468
469        Ok(AvatarUploadCredential {
470            reserved: Default::default(),
471            redemption_time: response.redemption_time,
472            credential,
473            cm: CommitmentPoint(self.cm),
474        })
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Credential (client-side state after unblinding)
480// ---------------------------------------------------------------------------
481
482#[derive(Clone, Serialize, Deserialize, PartialDefault)]
483pub struct AvatarUploadCredential {
484    reserved: ReservedByte,
485    redemption_time: Timestamp,
486    credential: zkcredential::credentials::Credential,
487    cm: CommitmentPoint,
488}
489
490impl AvatarUploadCredential {
491    pub fn present(
492        &self,
493        server_params: &GenericServerPublicParams,
494        randomness: RandomnessBytes,
495    ) -> AvatarUploadCredentialPresentation {
496        AvatarUploadCredentialPresentation {
497            version: Default::default(),
498            redemption_time: self.redemption_time,
499            cm: self.cm,
500            proof: zkcredential::presentation::PresentationProofBuilder::new(CREDENTIAL_LABEL)
501                .add_revealed_attribute(&self.cm)
502                .present(&server_params.credential_key, &self.credential, randomness),
503        }
504    }
505
506    /// The Pedersen commitment `cm`, used as a stable unlinkable identifier.
507    pub fn cm(&self) -> CommitmentPoint {
508        self.cm
509    }
510
511    /// The compressed-Ristretto encoding of Pedersen commitment `cm`, suitable for bridge consumers.
512    pub fn cm_bytes(&self) -> [u8; 32] {
513        self.cm.0.compress().to_bytes()
514    }
515
516    /// The redemption time the issuing server chose for this credential.
517    pub fn redemption_time(&self) -> Timestamp {
518        self.redemption_time
519    }
520}
521
522// ---------------------------------------------------------------------------
523// Presentation (sent to verifying server)
524// ---------------------------------------------------------------------------
525
526#[derive(Clone, Serialize, Deserialize, PartialDefault)]
527pub struct AvatarUploadCredentialPresentation {
528    version: ReservedByte,
529    redemption_time: Timestamp,
530    cm: CommitmentPoint,
531    proof: zkcredential::presentation::PresentationProof,
532}
533
534impl AvatarUploadCredentialPresentation {
535    pub fn verify(
536        &self,
537        current_time: Timestamp,
538        server_params: &GenericServerSecretParams,
539    ) -> Result<(), ZkGroupVerificationFailure> {
540        // Check timestamp window: [-1 day, +2 days]
541        check_avatar_upload_credential_redemption_time(self.redemption_time, current_time)?;
542
543        zkcredential::presentation::PresentationProofVerifier::new(CREDENTIAL_LABEL)
544            .add_public_attribute(&self.redemption_time)
545            .add_revealed_attribute(&self.cm)
546            .verify(&server_params.credential_key, &self.proof)
547            .map_err(|_| ZkGroupVerificationFailure)
548    }
549
550    /// The Pedersen commitment `cm`, used as a stable unlinkable identifier.
551    pub fn cm(&self) -> CommitmentPoint {
552        self.cm
553    }
554
555    /// The compressed-Ristretto encoding of Pedersen commitment `cm`, suitable for bridge consumers.
556    pub fn cm_bytes(&self) -> [u8; 32] {
557        self.cm.0.compress().to_bytes()
558    }
559
560    pub fn redemption_time(&self) -> Timestamp {
561        self.redemption_time
562    }
563}
564
565// ===========================================================================
566// Tests
567// ===========================================================================
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use crate::{SECONDS_PER_DAY, Timestamp};
573
574    const DAY_ALIGNED_TIMESTAMP: Timestamp = Timestamp::from_epoch_seconds(1681344000); // 2023-04-13 00:00:00 UTC
575    const ACI: uuid::Uuid = uuid::uuid!("c0fc16e4-bae5-4343-9f0d-e7ecf4251343");
576    const ROTATION_ID: u64 = 1;
577    const SERVER_SECRET_RAND: RandomnessBytes = [0xA0; RANDOMNESS_LEN];
578    const REQUEST_RAND: RandomnessBytes = [0xA1; RANDOMNESS_LEN];
579    const ISSUE_RAND: RandomnessBytes = [0xA2; RANDOMNESS_LEN];
580    const PRESENT_RAND: RandomnessBytes = [0xA3; RANDOMNESS_LEN];
581    const ZK_CRED_KEY_RAND: RandomnessBytes = [0x42; RANDOMNESS_LEN];
582    const WRONG_ZK_CRED_KEY_RAND: RandomnessBytes = [0x99; RANDOMNESS_LEN];
583
584    fn zk_credential_key_pair() -> ZkCredentialKeyPair {
585        ZkCredentialKeyPair::generate(ZK_CRED_KEY_RAND)
586    }
587
588    fn zk_credential_key_pub() -> ZkCredentialPublicKey {
589        zk_credential_key_pair().public_key()
590    }
591
592    fn zk_credential_key_secret() -> Scalar {
593        zk_credential_key_pair().secret()
594    }
595
596    fn server_secret_params() -> GenericServerSecretParams {
597        GenericServerSecretParams::generate(SERVER_SECRET_RAND)
598    }
599
600    fn generate_credential(redemption_time: Timestamp) -> AvatarUploadCredential {
601        generate_credential_with_rotation_id(redemption_time, ROTATION_ID)
602    }
603
604    fn generate_credential_with_rotation_id(
605        redemption_time: Timestamp,
606        rotation_id: u64,
607    ) -> AvatarUploadCredential {
608        let aci = libsignal_core::Aci::from(ACI);
609        let request_context = AvatarUploadCredentialRequestContext::new(
610            aci,
611            &zk_credential_key_pair(),
612            rotation_id,
613            REQUEST_RAND,
614        );
615        let request = request_context.get_request();
616
617        let response = request
618            .issue(
619                aci,
620                &zk_credential_key_pub(),
621                rotation_id,
622                redemption_time,
623                &server_secret_params(),
624                ISSUE_RAND,
625            )
626            .expect("issuance should succeed");
627
628        let server_public_params = server_secret_params().get_public_params();
629        request_context
630            .receive(response, &server_public_params, redemption_time)
631            .expect("credential should be valid")
632    }
633
634    /// Builds an in-flight request/response pair without consuming the request context, so tests
635    /// can exercise `receive` with adversarial `current_time` values.
636    fn issue_for_receive_test(
637        redemption_time: Timestamp,
638    ) -> (
639        AvatarUploadCredentialRequestContext,
640        AvatarUploadCredentialResponse,
641    ) {
642        let aci = libsignal_core::Aci::from(ACI);
643        let request_context = AvatarUploadCredentialRequestContext::new(
644            aci,
645            &zk_credential_key_pair(),
646            ROTATION_ID,
647            REQUEST_RAND,
648        );
649        let response = request_context
650            .get_request()
651            .issue(
652                aci,
653                &zk_credential_key_pub(),
654                ROTATION_ID,
655                redemption_time,
656                &server_secret_params(),
657                ISSUE_RAND,
658            )
659            .expect("issuance should succeed");
660        (request_context, response)
661    }
662
663    #[test]
664    fn test_happy_path() {
665        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
666        let presentation =
667            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
668
669        presentation
670            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
671            .expect("presentation should be valid");
672    }
673
674    #[test]
675    fn test_context_serialization_does_not_include_raw_zk_credential_key_secret() {
676        let aci = libsignal_core::Aci::from(ACI);
677        let request_context = AvatarUploadCredentialRequestContext::new(
678            aci,
679            &zk_credential_key_pair(),
680            ROTATION_ID,
681            REQUEST_RAND,
682        );
683        let serialized = crate::serialize(&request_context);
684
685        let secret_bytes = zk_credential_key_secret().to_bytes();
686        assert!(
687            !serialized
688                .windows(secret_bytes.len())
689                .any(|window| window == secret_bytes),
690            "request context serialization should not contain the raw ZK credential key secret"
691        );
692    }
693
694    #[test]
695    fn test_server_verify_expiration() {
696        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
697        let presentation =
698            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
699
700        presentation
701            .verify(
702                DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY + 1),
703                &server_secret_params(),
704            )
705            .expect_err("credential should not be valid 24h before redemption time");
706
707        presentation
708            .verify(
709                DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY + 1),
710                &server_secret_params(),
711            )
712            .expect_err("credential should not be valid after expiration (2 days later)");
713    }
714
715    #[test]
716    fn test_server_verify_wrong_cm() {
717        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
718        let valid_presentation =
719            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
720
721        // Tamper with Cm
722        let wrong_cm = Sho::new(b"wrong", b"cm").get_point();
723        let invalid_presentation = AvatarUploadCredentialPresentation {
724            cm: CommitmentPoint(wrong_cm),
725            ..valid_presentation
726        };
727        invalid_presentation
728            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
729            .expect_err("credential should not be valid with altered Cm");
730    }
731
732    #[test]
733    fn test_server_verify_wrong_redemption_time() {
734        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
735        let valid_presentation =
736            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
737
738        let invalid_presentation = AvatarUploadCredentialPresentation {
739            redemption_time: DAY_ALIGNED_TIMESTAMP.add_seconds(1),
740            ..valid_presentation
741        };
742        invalid_presentation
743            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
744            .expect_err("credential should not be valid with altered redemption_time");
745    }
746
747    #[test]
748    fn test_issuance_wrong_aci() {
749        // Client requests for one ACI, server checks against a different one.
750        let client_aci = libsignal_core::Aci::from(ACI);
751        let wrong_aci =
752            libsignal_core::Aci::from(uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
753
754        let request_context = AvatarUploadCredentialRequestContext::new(
755            client_aci,
756            &zk_credential_key_pair(),
757            ROTATION_ID,
758            REQUEST_RAND,
759        );
760        let request = request_context.get_request();
761
762        assert!(
763            request
764                .issue(
765                    wrong_aci,
766                    &zk_credential_key_pub(),
767                    ROTATION_ID,
768                    DAY_ALIGNED_TIMESTAMP,
769                    &server_secret_params(),
770                    ISSUE_RAND,
771                )
772                .is_err(),
773            "issuance should fail with wrong ACI"
774        );
775    }
776
777    #[test]
778    fn test_issuance_wrong_zk_credential_key_pub() {
779        let aci = libsignal_core::Aci::from(ACI);
780        let request_context = AvatarUploadCredentialRequestContext::new(
781            aci,
782            &zk_credential_key_pair(),
783            ROTATION_ID,
784            REQUEST_RAND,
785        );
786        let request = request_context.get_request();
787
788        // Server has a different ZK credential key on file
789        let wrong_zk_credential_key_pub =
790            ZkCredentialKeyPair::generate(WRONG_ZK_CRED_KEY_RAND).public_key();
791
792        assert!(
793            request
794                .issue(
795                    aci,
796                    &wrong_zk_credential_key_pub,
797                    ROTATION_ID,
798                    DAY_ALIGNED_TIMESTAMP,
799                    &server_secret_params(),
800                    ISSUE_RAND,
801                )
802                .is_err(),
803            "issuance should fail with wrong ZK credential key"
804        );
805    }
806
807    #[test]
808    fn test_client_accepts_credential_inside_window() {
809        // The client's `current_time` can drift within the redemption window [-1d, +2d] and
810        // `receive` must still accept the credential.
811        let server_public_params = server_secret_params().get_public_params();
812        let current_times = [
813            DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY),
814            DAY_ALIGNED_TIMESTAMP,
815            DAY_ALIGNED_TIMESTAMP.add_seconds(SECONDS_PER_DAY),
816            DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY),
817        ];
818        for current_time in current_times {
819            let (ctx, response) = issue_for_receive_test(DAY_ALIGNED_TIMESTAMP);
820            ctx.receive(response, &server_public_params, current_time)
821                .expect("receive should succeed inside the redemption window");
822        }
823    }
824
825    #[test]
826    fn test_client_rejects_credential_outside_window() {
827        // Outside the [-1d, +2d] window the client must refuse to accept the credential, even if
828        // everything else is in order.
829        let server_public_params = server_secret_params().get_public_params();
830
831        // current_time more than 1 day before redemption_time => not yet usable.
832        let (ctx, response) = issue_for_receive_test(DAY_ALIGNED_TIMESTAMP);
833        assert!(
834            ctx.receive(
835                response,
836                &server_public_params,
837                DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY + 1),
838            )
839            .is_err(),
840            "client should reject a credential not yet inside the redemption window"
841        );
842
843        // current_time more than 2 days after redemption_time => already expired.
844        let (ctx, response) = issue_for_receive_test(DAY_ALIGNED_TIMESTAMP);
845        assert!(
846            ctx.receive(
847                response,
848                &server_public_params,
849                DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY + 1),
850            )
851            .is_err(),
852            "client should reject an already-expired credential"
853        );
854    }
855
856    #[test]
857    fn test_client_rejects_non_day_aligned_redemption_time() {
858        // The server-side `issue` API won't issue a non-day-aligned credential, so construct a
859        // response directly with a proof that is otherwise valid for the non-day-aligned time.
860        // This makes the test specifically cover the receive-side day-alignment check.
861        let aci = libsignal_core::Aci::from(ACI);
862        let request_context = AvatarUploadCredentialRequestContext::new(
863            aci,
864            &zk_credential_key_pair(),
865            ROTATION_ID,
866            REQUEST_RAND,
867        );
868
869        let request = request_context.get_request();
870        let server_params = server_secret_params();
871        let malicious = AvatarUploadCredentialResponse {
872            reserved: Default::default(),
873            redemption_time: DAY_ALIGNED_TIMESTAMP.add_seconds(3600),
874            blinded_credential: zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
875                .add_public_attribute(&DAY_ALIGNED_TIMESTAMP.add_seconds(3600))
876                .add_blinded_revealed_attribute(&request.blinded_cm)
877                .issue(
878                    &server_params.credential_key,
879                    &request.public_key,
880                    ISSUE_RAND,
881                ),
882        };
883        assert!(
884            request_context
885                .receive(
886                    malicious,
887                    &server_secret_params().get_public_params(),
888                    DAY_ALIGNED_TIMESTAMP,
889                )
890                .is_err(),
891            "client should reject a non-day-aligned redemption_time"
892        );
893    }
894
895    #[test]
896    fn test_credential_exposes_redemption_time() {
897        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
898        assert_eq!(credential.redemption_time(), DAY_ALIGNED_TIMESTAMP);
899    }
900
901    #[test]
902    fn test_server_enforces_timestamp_granularity() {
903        let aci = libsignal_core::Aci::from(ACI);
904        let not_day_aligned = DAY_ALIGNED_TIMESTAMP.add_seconds(3600);
905
906        let request_context = AvatarUploadCredentialRequestContext::new(
907            aci,
908            &zk_credential_key_pair(),
909            ROTATION_ID,
910            REQUEST_RAND,
911        );
912        let request = request_context.get_request();
913
914        assert!(
915            request
916                .issue(
917                    aci,
918                    &zk_credential_key_pub(),
919                    ROTATION_ID,
920                    not_day_aligned,
921                    &server_secret_params(),
922                    ISSUE_RAND,
923                )
924                .is_err(),
925            "issuance should fail when timestamp is not on a day boundary"
926        );
927        // The client enforces it too, but this is not tested because the server
928        // won't issue a non-aligned credential.
929    }
930
931    #[test]
932    fn test_cm_deterministic() {
933        // Same (aci, zk_credential_key_secret, rotation_id) should produce the same Cm.
934        let aci = libsignal_core::Aci::from(ACI);
935        let aci_scalar = aci_to_scalar(aci);
936        let cm1 = compute_cm(aci_scalar, zk_credential_key_secret(), ROTATION_ID);
937        let cm2 = compute_cm(aci_scalar, zk_credential_key_secret(), ROTATION_ID);
938        assert_eq!(cm1, cm2);
939    }
940
941    #[test]
942    fn test_cm_differs_for_different_aci() {
943        let aci1 = libsignal_core::Aci::from(ACI);
944        let aci2 = libsignal_core::Aci::from(uuid::uuid!("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
945        let cm1 = compute_cm(aci_to_scalar(aci1), zk_credential_key_secret(), ROTATION_ID);
946        let cm2 = compute_cm(aci_to_scalar(aci2), zk_credential_key_secret(), ROTATION_ID);
947        assert_ne!(cm1, cm2);
948    }
949
950    #[test]
951    fn test_cm_differs_for_different_zk_credential_key() {
952        let aci = libsignal_core::Aci::from(ACI);
953        let aci_scalar = aci_to_scalar(aci);
954        let zkck1 = Scalar::from_bytes_mod_order([0x42; 32]);
955        let zkck2 = Scalar::from_bytes_mod_order([0x43; 32]);
956        let cm1 = compute_cm(aci_scalar, zkck1, ROTATION_ID);
957        let cm2 = compute_cm(aci_scalar, zkck2, ROTATION_ID);
958        assert_ne!(cm1, cm2);
959    }
960
961    #[test]
962    fn test_cm_differs_for_different_rotation_id() {
963        let aci = libsignal_core::Aci::from(ACI);
964        let aci_scalar = aci_to_scalar(aci);
965        let cm1 = compute_cm(aci_scalar, zk_credential_key_secret(), 1);
966        let cm2 = compute_cm(aci_scalar, zk_credential_key_secret(), 2);
967        assert_ne!(cm1, cm2);
968    }
969
970    #[test]
971    fn test_issuance_wrong_rotation_id() {
972        // Client commits to one rotation_id; server issues against a different one. The
973        // well-formedness proof must fail, because the verifier strips the server's rotation_id and
974        // is left with a residual [delta]*H3.
975        let aci = libsignal_core::Aci::from(ACI);
976        let request_context = AvatarUploadCredentialRequestContext::new(
977            aci,
978            &zk_credential_key_pair(),
979            1,
980            REQUEST_RAND,
981        );
982        let request = request_context.get_request();
983
984        assert!(
985            request
986                .issue(
987                    aci,
988                    &zk_credential_key_pub(),
989                    2,
990                    DAY_ALIGNED_TIMESTAMP,
991                    &server_secret_params(),
992                    ISSUE_RAND,
993                )
994                .is_err(),
995            "issuance should fail when the server's rotation_id differs from the client's"
996        );
997    }
998
999    #[test]
1000    fn test_different_rotation_id_produces_different_presentation_cm() {
1001        let cred_v1 = generate_credential_with_rotation_id(DAY_ALIGNED_TIMESTAMP, 1);
1002        let cred_v2 = generate_credential_with_rotation_id(DAY_ALIGNED_TIMESTAMP, 2);
1003        assert_ne!(cred_v1.cm(), cred_v2.cm());
1004
1005        // Both should present and verify successfully.
1006        let pres_v1 = cred_v1.present(&server_secret_params().get_public_params(), PRESENT_RAND);
1007        let pres_v2 = cred_v2.present(
1008            &server_secret_params().get_public_params(),
1009            [0xA4; RANDOMNESS_LEN],
1010        );
1011        pres_v1
1012            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
1013            .expect("v1 presentation should verify");
1014        pres_v2
1015            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
1016            .expect("v2 presentation should verify");
1017    }
1018}