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, SECONDS_PER_DAY};
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(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(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(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(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(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        let acceptable_start_time = self
288            .redemption_time
289            .checked_sub_seconds(SECONDS_PER_DAY)
290            .ok_or(ZkGroupVerificationFailure)?;
291        let acceptable_end_time = self
292            .redemption_time
293            .checked_add_seconds(2 * SECONDS_PER_DAY)
294            .ok_or(ZkGroupVerificationFailure)?;
295
296        if !(acceptable_start_time..=acceptable_end_time).contains(&current_time) {
297            return Err(ZkGroupVerificationFailure);
298        }
299
300        zkcredential::presentation::PresentationProofVerifier::new(CREDENTIAL_LABEL)
301            .add_public_attribute(&self.redemption_time)
302            .add_public_attribute(&u64::from(self.backup_level))
303            .add_public_attribute(&u64::from(self.credential_type))
304            .add_revealed_attribute(&BackupIdPoint::new(&self.backup_id))
305            .verify(&server_params.credential_key, &self.proof)
306            .map_err(|_| ZkGroupVerificationFailure)
307    }
308
309    pub fn backup_level(&self) -> BackupLevel {
310        self.backup_level
311    }
312
313    pub fn credential_type(&self) -> BackupCredentialType {
314        self.credential_type
315    }
316
317    pub fn backup_id(&self) -> libsignal_account_keys::BackupId {
318        self.backup_id
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use assert_matches::assert_matches;
325
326    use super::*;
327    use crate::{common, RandomnessBytes, Timestamp, RANDOMNESS_LEN, SECONDS_PER_DAY};
328
329    const DAY_ALIGNED_TIMESTAMP: Timestamp = Timestamp::from_epoch_seconds(1681344000); // 2023-04-13 00:00:00 UTC
330    const KEY: libsignal_account_keys::BackupKey = libsignal_account_keys::BackupKey([0x42u8; 32]);
331    const ACI: uuid::Uuid = uuid::uuid!("c0fc16e4-bae5-4343-9f0d-e7ecf4251343");
332    const SERVER_SECRET_RAND: RandomnessBytes = [0xA0; RANDOMNESS_LEN];
333    const ISSUE_RAND: RandomnessBytes = [0xA1; RANDOMNESS_LEN];
334    const PRESENT_RAND: RandomnessBytes = [0xA2; RANDOMNESS_LEN];
335
336    fn server_secret_params() -> GenericServerSecretParams {
337        GenericServerSecretParams::generate(SERVER_SECRET_RAND)
338    }
339
340    fn generate_credential(redemption_time: Timestamp) -> BackupAuthCredential {
341        // client generated materials; issuance request
342        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
343        let request = request_context.get_request();
344
345        // server generated materials; issuance request -> issuance response
346        let blinded_credential = request.issue(
347            redemption_time,
348            BackupLevel::Free,
349            BackupCredentialType::Messages,
350            &server_secret_params(),
351            ISSUE_RAND,
352        );
353
354        // client generated materials; issuance response -> redemption request
355        let server_public_params = server_secret_params().get_public_params();
356        request_context
357            .receive(blinded_credential, &server_public_params, redemption_time)
358            .expect("credential should be valid")
359    }
360
361    #[test]
362    fn test_server_verify_expiration() {
363        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
364        let presentation =
365            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
366
367        presentation
368            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
369            .expect("presentation should be valid");
370
371        presentation
372            .verify(
373                DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY + 1),
374                &server_secret_params(),
375            )
376            .expect_err("credential should not be valid 24h before redemption time");
377        presentation
378            .verify(
379                DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY + 1),
380                &server_secret_params(),
381            )
382            .expect_err("credential should not be valid after expiration (2 days later)");
383    }
384
385    #[test]
386    fn test_server_verify_wrong_backup_id() {
387        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
388        let valid_presentation =
389            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
390        let invalid_presentation = BackupAuthCredentialPresentation {
391            backup_id: libsignal_account_keys::BackupId(*b"a fake backup-id"),
392            ..valid_presentation
393        };
394        invalid_presentation
395            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
396            .expect_err("credential should not be valid with different backup-id");
397    }
398
399    #[test]
400    fn test_server_verify_wrong_redemption() {
401        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
402        let valid_presentation =
403            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
404        let invalid_presentation = BackupAuthCredentialPresentation {
405            redemption_time: DAY_ALIGNED_TIMESTAMP.add_seconds(1),
406            ..valid_presentation
407        };
408        invalid_presentation
409            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
410            .expect_err("credential should not be valid with altered redemption_time");
411    }
412
413    #[test]
414    fn test_server_verify_wrong_receipt_level() {
415        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
416        let valid_presentation =
417            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
418        let invalid_presentation = BackupAuthCredentialPresentation {
419            // Credential was for BackupLevel::Messages
420            backup_level: BackupLevel::Paid,
421            ..valid_presentation
422        };
423        invalid_presentation
424            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
425            .expect_err("credential should not be valid with wrong receipt");
426    }
427
428    #[test]
429    fn test_client_enforces_timestamp() {
430        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP;
431
432        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
433        let request = request_context.get_request();
434        let blinded_credential = request.issue(
435            redemption_time,
436            BackupLevel::Free,
437            BackupCredentialType::Messages,
438            &server_secret_params(),
439            ISSUE_RAND,
440        );
441        assert!(
442            request_context
443                .receive(
444                    blinded_credential,
445                    &server_secret_params().get_public_params(),
446                    redemption_time.add_seconds(SECONDS_PER_DAY),
447                )
448                .is_err(),
449            "client should require that timestamp matches its expectation"
450        );
451    }
452
453    #[test]
454    fn test_client_enforces_timestamp_granularity() {
455        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP.add_seconds(60 * 60); // not on a day boundary!
456
457        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
458        let request = request_context.get_request();
459        let blinded_credential = request.issue(
460            redemption_time,
461            BackupLevel::Free,
462            BackupCredentialType::Messages,
463            &server_secret_params(),
464            ISSUE_RAND,
465        );
466        assert!(
467            request_context
468                .receive(
469                    blinded_credential,
470                    &server_secret_params().get_public_params(),
471                    redemption_time,
472                )
473                .is_err(),
474            "client should require that timestamp is on a day boundary"
475        );
476    }
477
478    #[test]
479    fn test_backup_level_serialization() {
480        let free_bytes = common::serialization::serialize(&BackupLevel::Free);
481        let paid_bytes = common::serialization::serialize(&BackupLevel::Paid);
482        assert_eq!(free_bytes.len(), 8);
483        assert_eq!(paid_bytes.len(), 8);
484
485        let free_num: u64 = common::serialization::deserialize(&free_bytes).expect("valid u64");
486        let paid_num: u64 = common::serialization::deserialize(&paid_bytes).expect("valid u64");
487        assert_eq!(free_num, 200);
488        assert_eq!(paid_num, 201);
489
490        let free: BackupLevel =
491            common::serialization::deserialize(&free_bytes).expect("valid level");
492        let paid: BackupLevel =
493            common::serialization::deserialize(&paid_bytes).expect("valid level");
494        assert_eq!(free, BackupLevel::Free);
495        assert_eq!(paid, BackupLevel::Paid);
496    }
497
498    #[test]
499    fn test_backup_level_validation() {
500        // Check that the u64 level isn't just truncated to u8.
501        assert_matches!(
502            BackupLevel::try_from(0x100000000000u64 + u64::from(BackupLevel::Free)),
503            Err(_)
504        );
505    }
506}