zkgroup/api/backups/
auth_credential.rs

1//
2// Copyright 2023 Signal Messenger, LLC.
3// SPDX-License-Identifier: AGPL-3.0-only
4//
5
6//! Provides BackupAuthCredential and related types.
7//!
8//! BackupAuthCredential is a MAC over:
9//! - a backup-id (a 16-byte value deterministically derived from the client's account entropy pool, blinded at issuance, revealed for verification)
10//! - a timestamp, truncated to day granularity (chosen by the chat server at issuance, passed publicly to the verifying server)
11//! - a receipt level (chosen by the chat server at issuance, passed publicly to the verifying server)
12//!
13//! The BackupAuthCredentialPresentation includes the public backup-id in the clear for verification
14//!
15//! The BackupAuthCredential has the additional constraint that it should be deterministically reproducible. Rather than a randomly
16//! seeded blinding key pair, the key pair is derived from, you guessed it, the client's AEP.
17
18use curve25519_dalek_signal::ristretto::RistrettoPoint;
19use partial_default::PartialDefault;
20use poksho::ShoApi;
21use serde::{Deserialize, Serialize};
22
23use crate::common::serialization::ReservedByte;
24use crate::common::sho::Sho;
25use crate::common::simple_types::*;
26use crate::generic_server_params::{GenericServerPublicParams, GenericServerSecretParams};
27use crate::{ZkGroupDeserializationFailure, ZkGroupVerificationFailure};
28
29#[derive(Serialize, Deserialize, Clone, Copy)]
30struct BackupIdPoint(RistrettoPoint);
31
32impl BackupIdPoint {
33    fn new(backup_id: &libsignal_account_keys::BackupId) -> Self {
34        Self(Sho::new(b"20231003_Signal_BackupId", &backup_id.0).get_point())
35    }
36}
37
38impl zkcredential::attributes::RevealedAttribute for BackupIdPoint {
39    fn as_point(&self) -> RistrettoPoint {
40        self.0
41    }
42}
43
44const CREDENTIAL_LABEL: &[u8] = b"20231003_Signal_BackupAuthCredential";
45
46// We make sure we serialize BackupLevel and BackupType with plenty of room to expand to other u64
47// values later. But since they fit in a byte today, we stick to just a u8 in the in-memory and
48// bridge representation.
49
50#[derive(
51    Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialDefault, Debug, derive_more::TryFrom,
52)]
53#[serde(into = "u64", try_from = "u64")]
54#[repr(u8)]
55#[try_from(repr)]
56pub enum BackupLevel {
57    #[partial_default]
58    Free = 200,
59    Paid = 201,
60}
61
62impl From<BackupLevel> for u64 {
63    fn from(backup_level: BackupLevel) -> Self {
64        backup_level as u64
65    }
66}
67
68impl TryFrom<u64> for BackupLevel {
69    // Unfortunately u8::try_from and TryFromPrimitive have different Error types.
70    // But we shouldn't be passing invalid BackupLevels anyway.
71    type Error = ZkGroupDeserializationFailure;
72    fn try_from(value: u64) -> Result<Self, Self::Error> {
73        u8::try_from(value)
74            .ok()
75            .and_then(|v| BackupLevel::try_from(v).ok())
76            .ok_or(ZkGroupDeserializationFailure::new::<Self>())
77    }
78}
79
80#[derive(
81    Copy, Clone, Serialize, Deserialize, PartialEq, Eq, PartialDefault, Debug, derive_more::TryFrom,
82)]
83#[serde(into = "u64", try_from = "u64")]
84#[repr(u8)]
85#[try_from(repr)]
86pub enum BackupCredentialType {
87    #[partial_default]
88    Messages = 1,
89    Media = 2,
90}
91
92impl From<BackupCredentialType> for u64 {
93    fn from(credential_type: BackupCredentialType) -> Self {
94        credential_type as u64
95    }
96}
97
98impl TryFrom<u64> for BackupCredentialType {
99    // Unfortunately u8::try_from and TryFromPrimitive have different Error types.
100    // But we shouldn't be passing invalid BackupTypes anyway.
101    type Error = ZkGroupDeserializationFailure;
102    fn try_from(value: u64) -> Result<Self, Self::Error> {
103        u8::try_from(value)
104            .ok()
105            .and_then(|v| BackupCredentialType::try_from(v).ok())
106            .ok_or(ZkGroupDeserializationFailure::new::<Self>())
107    }
108}
109
110#[derive(Clone, Serialize, Deserialize, PartialDefault)]
111pub struct BackupAuthCredentialRequestContext {
112    reserved: ReservedByte,
113    blinded_backup_id: zkcredential::issuance::blind::BlindedPoint,
114    backup_id: libsignal_account_keys::BackupId,
115    key_pair: zkcredential::issuance::blind::BlindingKeyPair,
116}
117
118impl BackupAuthCredentialRequestContext {
119    pub fn new<const VERSION: u8>(
120        backup_key: &libsignal_account_keys::BackupKey<VERSION>,
121        aci: libsignal_core::Aci,
122    ) -> Self {
123        // derive the backup-id (blinded in the issuance request, revealed at verification)
124        let backup_id = backup_key.derive_backup_id(&aci);
125
126        let mut sho = poksho::ShoHmacSha256::new(b"20231003_Signal_BackupAuthCredentialRequest");
127        sho.absorb_and_ratchet(uuid::Uuid::from(aci).as_bytes());
128        sho.absorb_and_ratchet(&backup_key.0);
129
130        let key_pair = zkcredential::issuance::blind::BlindingKeyPair::generate(&mut sho);
131
132        let blinded_backup_id = key_pair
133            .blind(&BackupIdPoint::new(&backup_id), &mut sho)
134            .into();
135
136        Self {
137            reserved: Default::default(),
138            blinded_backup_id,
139            backup_id,
140            key_pair,
141        }
142    }
143
144    pub fn get_request(&self) -> BackupAuthCredentialRequest {
145        BackupAuthCredentialRequest {
146            reserved: Default::default(),
147            blinded_backup_id: self.blinded_backup_id,
148            public_key: *self.key_pair.public_key(),
149        }
150    }
151}
152
153#[derive(Clone, Serialize, Deserialize, PartialDefault)]
154pub struct BackupAuthCredentialRequest {
155    reserved: ReservedByte,
156    blinded_backup_id: zkcredential::issuance::blind::BlindedPoint,
157    public_key: zkcredential::issuance::blind::BlindingPublicKey,
158}
159
160impl BackupAuthCredentialRequest {
161    pub fn issue(
162        &self,
163        redemption_time: Timestamp,
164        backup_level: BackupLevel,
165        credential_type: BackupCredentialType,
166        params: &GenericServerSecretParams,
167        randomness: RandomnessBytes,
168    ) -> BackupAuthCredentialResponse {
169        BackupAuthCredentialResponse {
170            reserved: Default::default(),
171            redemption_time,
172            backup_level,
173            credential_type,
174            blinded_credential: zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
175                .add_public_attribute(&redemption_time)
176                .add_public_attribute(&u64::from(backup_level))
177                .add_public_attribute(&u64::from(credential_type))
178                .add_blinded_revealed_attribute(&self.blinded_backup_id)
179                .issue(&params.credential_key, &self.public_key, randomness),
180        }
181    }
182}
183
184#[derive(Clone, Serialize, Deserialize, PartialDefault)]
185pub struct BackupAuthCredentialResponse {
186    reserved: ReservedByte,
187    // In theory, we don't need to store this (AuthCredentialResponse doesn't),
188    // because the redemption time is also passed *outside* the response by chat-server.
189    // But that would change the format.
190    redemption_time: Timestamp,
191    backup_level: BackupLevel,
192    credential_type: BackupCredentialType,
193    blinded_credential: zkcredential::issuance::blind::BlindedIssuanceProof,
194}
195
196impl BackupAuthCredentialRequestContext {
197    pub fn receive(
198        self,
199        response: BackupAuthCredentialResponse,
200        params: &GenericServerPublicParams,
201        expected_redemption_time: Timestamp,
202    ) -> Result<BackupAuthCredential, ZkGroupVerificationFailure> {
203        if response.redemption_time != expected_redemption_time
204            || !response.redemption_time.is_day_aligned()
205        {
206            return Err(ZkGroupVerificationFailure);
207        }
208
209        Ok(BackupAuthCredential {
210            reserved: Default::default(),
211            redemption_time: response.redemption_time,
212            backup_level: response.backup_level,
213            credential_type: response.credential_type,
214            credential: zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
215                .add_public_attribute(&response.redemption_time)
216                .add_public_attribute(&u64::from(response.backup_level))
217                .add_public_attribute(&u64::from(response.credential_type))
218                .add_blinded_revealed_attribute(&self.blinded_backup_id)
219                .verify(
220                    &params.credential_key,
221                    &self.key_pair,
222                    response.blinded_credential,
223                )
224                .map_err(|_| ZkGroupVerificationFailure)?,
225            backup_id: self.backup_id,
226        })
227    }
228}
229
230#[derive(Clone, Serialize, Deserialize, PartialDefault)]
231pub struct BackupAuthCredential {
232    reserved: ReservedByte,
233    redemption_time: Timestamp,
234    backup_level: BackupLevel,
235    credential_type: BackupCredentialType,
236    credential: zkcredential::credentials::Credential,
237    backup_id: libsignal_account_keys::BackupId,
238}
239
240impl BackupAuthCredential {
241    pub fn present(
242        &self,
243        server_params: &GenericServerPublicParams,
244        randomness: RandomnessBytes,
245    ) -> BackupAuthCredentialPresentation {
246        BackupAuthCredentialPresentation {
247            version: Default::default(),
248            redemption_time: self.redemption_time,
249            backup_level: self.backup_level,
250            credential_type: self.credential_type,
251            backup_id: self.backup_id,
252            proof: zkcredential::presentation::PresentationProofBuilder::new(CREDENTIAL_LABEL)
253                .add_revealed_attribute(&BackupIdPoint::new(&self.backup_id))
254                .present(&server_params.credential_key, &self.credential, randomness),
255        }
256    }
257
258    pub fn backup_id(&self) -> libsignal_account_keys::BackupId {
259        self.backup_id
260    }
261
262    pub fn backup_level(&self) -> BackupLevel {
263        self.backup_level
264    }
265
266    pub fn credential_type(&self) -> BackupCredentialType {
267        self.credential_type
268    }
269}
270
271#[derive(Clone, Serialize, Deserialize, PartialDefault)]
272pub struct BackupAuthCredentialPresentation {
273    version: ReservedByte,
274    backup_level: BackupLevel,
275    credential_type: BackupCredentialType,
276    redemption_time: Timestamp,
277    proof: zkcredential::presentation::PresentationProof,
278    backup_id: libsignal_account_keys::BackupId,
279}
280
281impl BackupAuthCredentialPresentation {
282    pub fn verify(
283        &self,
284        current_time: Timestamp,
285        server_params: &GenericServerSecretParams,
286    ) -> Result<(), ZkGroupVerificationFailure> {
287        crate::ServerSecretParams::check_auth_credential_redemption_time(
288            self.redemption_time,
289            current_time,
290        )?;
291
292        zkcredential::presentation::PresentationProofVerifier::new(CREDENTIAL_LABEL)
293            .add_public_attribute(&self.redemption_time)
294            .add_public_attribute(&u64::from(self.backup_level))
295            .add_public_attribute(&u64::from(self.credential_type))
296            .add_revealed_attribute(&BackupIdPoint::new(&self.backup_id))
297            .verify(&server_params.credential_key, &self.proof)
298            .map_err(|_| ZkGroupVerificationFailure)
299    }
300
301    pub fn backup_level(&self) -> BackupLevel {
302        self.backup_level
303    }
304
305    pub fn credential_type(&self) -> BackupCredentialType {
306        self.credential_type
307    }
308
309    pub fn backup_id(&self) -> libsignal_account_keys::BackupId {
310        self.backup_id
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use assert_matches::assert_matches;
317
318    use super::*;
319    use crate::{RANDOMNESS_LEN, RandomnessBytes, SECONDS_PER_DAY, Timestamp, common};
320
321    const DAY_ALIGNED_TIMESTAMP: Timestamp = Timestamp::from_epoch_seconds(1681344000); // 2023-04-13 00:00:00 UTC
322    const KEY: libsignal_account_keys::BackupKey = libsignal_account_keys::BackupKey([0x42u8; 32]);
323    const ACI: uuid::Uuid = uuid::uuid!("c0fc16e4-bae5-4343-9f0d-e7ecf4251343");
324    const SERVER_SECRET_RAND: RandomnessBytes = [0xA0; RANDOMNESS_LEN];
325    const ISSUE_RAND: RandomnessBytes = [0xA1; RANDOMNESS_LEN];
326    const PRESENT_RAND: RandomnessBytes = [0xA2; RANDOMNESS_LEN];
327
328    fn server_secret_params() -> GenericServerSecretParams {
329        GenericServerSecretParams::generate(SERVER_SECRET_RAND)
330    }
331
332    fn generate_credential(redemption_time: Timestamp) -> BackupAuthCredential {
333        // client generated materials; issuance request
334        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
335        let request = request_context.get_request();
336
337        // server generated materials; issuance request -> issuance response
338        let blinded_credential = request.issue(
339            redemption_time,
340            BackupLevel::Free,
341            BackupCredentialType::Messages,
342            &server_secret_params(),
343            ISSUE_RAND,
344        );
345
346        // client generated materials; issuance response -> redemption request
347        let server_public_params = server_secret_params().get_public_params();
348        request_context
349            .receive(blinded_credential, &server_public_params, redemption_time)
350            .expect("credential should be valid")
351    }
352
353    #[test]
354    fn test_server_verify_expiration() {
355        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
356        let presentation =
357            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
358
359        presentation
360            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
361            .expect("presentation should be valid");
362
363        presentation
364            .verify(
365                DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY + 1),
366                &server_secret_params(),
367            )
368            .expect_err("credential should not be valid 24h before redemption time");
369        presentation
370            .verify(
371                DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY + 1),
372                &server_secret_params(),
373            )
374            .expect_err("credential should not be valid after expiration (2 days later)");
375    }
376
377    #[test]
378    fn test_server_verify_wrong_backup_id() {
379        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
380        let valid_presentation =
381            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
382        let invalid_presentation = BackupAuthCredentialPresentation {
383            backup_id: libsignal_account_keys::BackupId(*b"a fake backup-id"),
384            ..valid_presentation
385        };
386        invalid_presentation
387            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
388            .expect_err("credential should not be valid with different backup-id");
389    }
390
391    #[test]
392    fn test_server_verify_wrong_redemption() {
393        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
394        let valid_presentation =
395            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
396        let invalid_presentation = BackupAuthCredentialPresentation {
397            redemption_time: DAY_ALIGNED_TIMESTAMP.add_seconds(1),
398            ..valid_presentation
399        };
400        invalid_presentation
401            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
402            .expect_err("credential should not be valid with altered redemption_time");
403    }
404
405    #[test]
406    fn test_server_verify_wrong_receipt_level() {
407        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
408        let valid_presentation =
409            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
410        let invalid_presentation = BackupAuthCredentialPresentation {
411            // Credential was for BackupLevel::Messages
412            backup_level: BackupLevel::Paid,
413            ..valid_presentation
414        };
415        invalid_presentation
416            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
417            .expect_err("credential should not be valid with wrong receipt");
418    }
419
420    #[test]
421    fn test_client_enforces_timestamp() {
422        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP;
423
424        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
425        let request = request_context.get_request();
426        let blinded_credential = request.issue(
427            redemption_time,
428            BackupLevel::Free,
429            BackupCredentialType::Messages,
430            &server_secret_params(),
431            ISSUE_RAND,
432        );
433        assert!(
434            request_context
435                .receive(
436                    blinded_credential,
437                    &server_secret_params().get_public_params(),
438                    redemption_time.add_seconds(SECONDS_PER_DAY),
439                )
440                .is_err(),
441            "client should require that timestamp matches its expectation"
442        );
443    }
444
445    #[test]
446    fn test_client_enforces_timestamp_granularity() {
447        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP.add_seconds(60 * 60); // not on a day boundary!
448
449        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
450        let request = request_context.get_request();
451        let blinded_credential = request.issue(
452            redemption_time,
453            BackupLevel::Free,
454            BackupCredentialType::Messages,
455            &server_secret_params(),
456            ISSUE_RAND,
457        );
458        assert!(
459            request_context
460                .receive(
461                    blinded_credential,
462                    &server_secret_params().get_public_params(),
463                    redemption_time,
464                )
465                .is_err(),
466            "client should require that timestamp is on a day boundary"
467        );
468    }
469
470    #[test]
471    fn test_backup_level_serialization() {
472        let free_bytes = common::serialization::serialize(&BackupLevel::Free);
473        let paid_bytes = common::serialization::serialize(&BackupLevel::Paid);
474        assert_eq!(free_bytes.len(), 8);
475        assert_eq!(paid_bytes.len(), 8);
476
477        let free_num: u64 = common::serialization::deserialize(&free_bytes).expect("valid u64");
478        let paid_num: u64 = common::serialization::deserialize(&paid_bytes).expect("valid u64");
479        assert_eq!(free_num, 200);
480        assert_eq!(paid_num, 201);
481
482        let free: BackupLevel =
483            common::serialization::deserialize(&free_bytes).expect("valid level");
484        let paid: BackupLevel =
485            common::serialization::deserialize(&paid_bytes).expect("valid level");
486        assert_eq!(free, BackupLevel::Free);
487        assert_eq!(paid, BackupLevel::Paid);
488    }
489
490    #[test]
491    fn test_backup_level_validation() {
492        // Check that the u64 level isn't just truncated to u8.
493        assert_matches!(
494            BackupLevel::try_from(0x100000000000u64 + u64::from(BackupLevel::Free)),
495            Err(_)
496        );
497    }
498}