1#![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
46struct 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#[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
84const CREDENTIAL_LABEL: &[u8] = b"20260329_Signal_AvatarUploadCredential";
89const CM_WELL_FORMEDNESS_PROOF_LABEL: &[u8] =
90 b"20260329_Signal_AvatarUploadCredential_CmWellFormednessProof";
91
92#[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 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 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 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 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 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
199fn 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
215fn 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(¤t_time) {
244 return Err(ZkGroupVerificationFailure);
245 }
246
247 Ok(())
248}
249
250#[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#[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 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 let blinding_nonce = blinded_cm_with_nonce.r.0;
306 let blinded_cm: zkcredential::issuance::blind::BlindedPoint = blinded_cm_with_nonce.into();
308
309 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#[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 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 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 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(¶ms.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#[derive(Clone, Serialize, Deserialize, PartialDefault)]
428pub struct AvatarUploadCredentialResponse {
429 reserved: ReservedByte,
430 redemption_time: Timestamp,
431 blinded_credential: zkcredential::issuance::blind::BlindedIssuanceProof,
432}
433
434impl AvatarUploadCredentialRequestContext {
439 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 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 ¶ms.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#[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 pub fn cm(&self) -> CommitmentPoint {
508 self.cm
509 }
510
511 pub fn cm_bytes(&self) -> [u8; 32] {
513 self.cm.0.compress().to_bytes()
514 }
515
516 pub fn redemption_time(&self) -> Timestamp {
518 self.redemption_time
519 }
520}
521
522#[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_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 pub fn cm(&self) -> CommitmentPoint {
552 self.cm
553 }
554
555 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#[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); 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 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 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 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 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 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 let server_public_params = server_secret_params().get_public_params();
830
831 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 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 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 }
930
931 #[test]
932 fn test_cm_deterministic() {
933 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 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 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}