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