1#![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
53struct 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#[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
96const CREDENTIAL_LABEL: &[u8] = b"20260329_Signal_AvatarUploadCredential";
101const CM_WELL_FORMEDNESS_PROOF_LABEL: &[u8] =
102 b"20260329_Signal_AvatarUploadCredential_CmWellFormednessProof";
103
104#[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 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 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 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 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 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
219fn 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
235fn 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 let public_offset = RistrettoPoint::vartime_multiscalar_mul(
246 [aci_scalar, Scalar::from(rotation_id)],
247 [params.H1, params.H2],
248 );
249 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(¤t_time) {
266 return Err(ZkGroupVerificationFailure);
267 }
268
269 Ok(())
270}
271
272#[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#[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 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 let blinding_nonce = blinded_cm_with_nonce.r.0;
323 let blinded_cm: zkcredential::issuance::blind::BlindedPoint = blinded_cm_with_nonce.into();
325
326 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#[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 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 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 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(¶ms.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#[derive(Clone, Serialize, Deserialize, PartialDefault)]
446pub struct AvatarUploadCredentialResponse {
447 reserved: ReservedByte,
448 redemption_time: Timestamp,
449 blinded_credential: zkcredential::issuance::blind::BlindedIssuanceProof,
450}
451
452impl AvatarUploadCredentialRequestContext {
457 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 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 ¶ms.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#[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 pub fn cm(&self) -> CommitmentPoint {
526 self.cm
527 }
528
529 pub fn cm_bytes(&self) -> [u8; 32] {
531 self.cm.0.compress().to_bytes()
532 }
533
534 pub fn redemption_time(&self) -> Timestamp {
536 self.redemption_time
537 }
538}
539
540#[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_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 pub fn cm(&self) -> CommitmentPoint {
570 self.cm
571 }
572
573 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#[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); 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 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 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 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 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 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 let server_public_params = server_secret_params().get_public_params();
850
851 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 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 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 }
950
951 #[test]
952 fn test_commitment_generators_are_pairwise_independent() {
953 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 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 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 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}