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