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(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            log::warn!(
205                "redemption_time mismatch: {} != {}",
206                response.redemption_time.epoch_seconds(),
207                expected_redemption_time.epoch_seconds()
208            );
209            return Err(ZkGroupVerificationFailure);
210        }
211
212        if !response.redemption_time.is_day_aligned() {
213            log::warn!(
214                "redemption_time is not day-aligned: {}",
215                response.redemption_time.epoch_seconds()
216            );
217            return Err(ZkGroupVerificationFailure);
218        }
219
220        let credential = zkcredential::issuance::IssuanceProofBuilder::new(CREDENTIAL_LABEL)
221            .add_public_attribute(&response.redemption_time)
222            .add_public_attribute(&u64::from(response.backup_level))
223            .add_public_attribute(&u64::from(response.credential_type))
224            .add_blinded_revealed_attribute(&self.blinded_backup_id)
225            .verify(
226                &params.credential_key,
227                &self.key_pair,
228                response.blinded_credential,
229            )
230            .map_err(|_| ZkGroupVerificationFailure)?;
231
232        Ok(BackupAuthCredential {
233            reserved: Default::default(),
234            redemption_time: response.redemption_time,
235            backup_level: response.backup_level,
236            credential_type: response.credential_type,
237            credential,
238            backup_id: self.backup_id,
239        })
240    }
241}
242
243#[derive(Serialize, Deserialize, PartialDefault)]
244pub struct BackupAuthCredential {
245    reserved: ReservedByte,
246    redemption_time: Timestamp,
247    backup_level: BackupLevel,
248    credential_type: BackupCredentialType,
249    credential: zkcredential::credentials::Credential,
250    backup_id: libsignal_account_keys::BackupId,
251}
252
253impl BackupAuthCredential {
254    pub fn present(
255        &self,
256        server_params: &GenericServerPublicParams,
257        randomness: RandomnessBytes,
258    ) -> BackupAuthCredentialPresentation {
259        BackupAuthCredentialPresentation {
260            version: Default::default(),
261            redemption_time: self.redemption_time,
262            backup_level: self.backup_level,
263            credential_type: self.credential_type,
264            backup_id: self.backup_id,
265            proof: zkcredential::presentation::PresentationProofBuilder::new(CREDENTIAL_LABEL)
266                .add_revealed_attribute(&BackupIdPoint::new(&self.backup_id))
267                .present(&server_params.credential_key, &self.credential, randomness),
268        }
269    }
270
271    pub fn backup_id(&self) -> libsignal_account_keys::BackupId {
272        self.backup_id
273    }
274
275    pub fn backup_level(&self) -> BackupLevel {
276        self.backup_level
277    }
278
279    pub fn credential_type(&self) -> BackupCredentialType {
280        self.credential_type
281    }
282}
283
284#[derive(Serialize, Deserialize, PartialDefault)]
285pub struct BackupAuthCredentialPresentation {
286    version: ReservedByte,
287    backup_level: BackupLevel,
288    credential_type: BackupCredentialType,
289    redemption_time: Timestamp,
290    proof: zkcredential::presentation::PresentationProof,
291    backup_id: libsignal_account_keys::BackupId,
292}
293
294impl BackupAuthCredentialPresentation {
295    pub fn verify(
296        &self,
297        current_time: Timestamp,
298        server_params: &GenericServerSecretParams,
299    ) -> Result<(), ZkGroupVerificationFailure> {
300        crate::ServerSecretParams::check_auth_credential_redemption_time(
301            self.redemption_time,
302            current_time,
303        )?;
304
305        zkcredential::presentation::PresentationProofVerifier::new(CREDENTIAL_LABEL)
306            .add_public_attribute(&self.redemption_time)
307            .add_public_attribute(&u64::from(self.backup_level))
308            .add_public_attribute(&u64::from(self.credential_type))
309            .add_revealed_attribute(&BackupIdPoint::new(&self.backup_id))
310            .verify(&server_params.credential_key, &self.proof)
311            .map_err(|_| ZkGroupVerificationFailure)
312    }
313
314    pub fn backup_level(&self) -> BackupLevel {
315        self.backup_level
316    }
317
318    pub fn credential_type(&self) -> BackupCredentialType {
319        self.credential_type
320    }
321
322    pub fn backup_id(&self) -> libsignal_account_keys::BackupId {
323        self.backup_id
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use assert_matches::assert_matches;
330
331    use super::*;
332    use crate::{RANDOMNESS_LEN, RandomnessBytes, SECONDS_PER_DAY, Timestamp, common};
333
334    const DAY_ALIGNED_TIMESTAMP: Timestamp = Timestamp::from_epoch_seconds(1681344000); // 2023-04-13 00:00:00 UTC
335    const KEY: libsignal_account_keys::BackupKey = libsignal_account_keys::BackupKey([0x42u8; 32]);
336    const ACI: uuid::Uuid = uuid::uuid!("c0fc16e4-bae5-4343-9f0d-e7ecf4251343");
337    const SERVER_SECRET_RAND: RandomnessBytes = [0xA0; RANDOMNESS_LEN];
338    const ISSUE_RAND: RandomnessBytes = [0xA1; RANDOMNESS_LEN];
339    const PRESENT_RAND: RandomnessBytes = [0xA2; RANDOMNESS_LEN];
340
341    fn server_secret_params() -> GenericServerSecretParams {
342        GenericServerSecretParams::generate(SERVER_SECRET_RAND)
343    }
344
345    fn generate_credential(redemption_time: Timestamp) -> BackupAuthCredential {
346        // client generated materials; issuance request
347        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
348        let request = request_context.get_request();
349
350        // server generated materials; issuance request -> issuance response
351        let blinded_credential = request.issue(
352            redemption_time,
353            BackupLevel::Free,
354            BackupCredentialType::Messages,
355            &server_secret_params(),
356            ISSUE_RAND,
357        );
358
359        // client generated materials; issuance response -> redemption request
360        let server_public_params = server_secret_params().get_public_params();
361        request_context
362            .receive(blinded_credential, &server_public_params, redemption_time)
363            .expect("credential should be valid")
364    }
365
366    #[test]
367    fn test_server_verify_expiration() {
368        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
369        let presentation =
370            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
371
372        presentation
373            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
374            .expect("presentation should be valid");
375
376        presentation
377            .verify(
378                DAY_ALIGNED_TIMESTAMP.sub_seconds(SECONDS_PER_DAY + 1),
379                &server_secret_params(),
380            )
381            .expect_err("credential should not be valid 24h before redemption time");
382        presentation
383            .verify(
384                DAY_ALIGNED_TIMESTAMP.add_seconds(2 * SECONDS_PER_DAY + 1),
385                &server_secret_params(),
386            )
387            .expect_err("credential should not be valid after expiration (2 days later)");
388    }
389
390    #[test]
391    fn test_server_verify_wrong_backup_id() {
392        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
393        let valid_presentation =
394            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
395        let invalid_presentation = BackupAuthCredentialPresentation {
396            backup_id: libsignal_account_keys::BackupId(*b"a fake backup-id"),
397            ..valid_presentation
398        };
399        invalid_presentation
400            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
401            .expect_err("credential should not be valid with different backup-id");
402    }
403
404    #[test]
405    fn test_server_verify_wrong_redemption() {
406        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
407        let valid_presentation =
408            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
409        let invalid_presentation = BackupAuthCredentialPresentation {
410            redemption_time: DAY_ALIGNED_TIMESTAMP.add_seconds(1),
411            ..valid_presentation
412        };
413        invalid_presentation
414            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
415            .expect_err("credential should not be valid with altered redemption_time");
416    }
417
418    #[test]
419    fn test_server_verify_wrong_receipt_level() {
420        let credential = generate_credential(DAY_ALIGNED_TIMESTAMP);
421        let valid_presentation =
422            credential.present(&server_secret_params().get_public_params(), PRESENT_RAND);
423        let invalid_presentation = BackupAuthCredentialPresentation {
424            // Credential was for BackupLevel::Messages
425            backup_level: BackupLevel::Paid,
426            ..valid_presentation
427        };
428        invalid_presentation
429            .verify(DAY_ALIGNED_TIMESTAMP, &server_secret_params())
430            .expect_err("credential should not be valid with wrong receipt");
431    }
432
433    #[test]
434    fn test_client_enforces_timestamp() {
435        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP;
436
437        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
438        let request = request_context.get_request();
439        let blinded_credential = request.issue(
440            redemption_time,
441            BackupLevel::Free,
442            BackupCredentialType::Messages,
443            &server_secret_params(),
444            ISSUE_RAND,
445        );
446        assert!(
447            request_context
448                .receive(
449                    blinded_credential,
450                    &server_secret_params().get_public_params(),
451                    redemption_time.add_seconds(SECONDS_PER_DAY),
452                )
453                .is_err(),
454            "client should require that timestamp matches its expectation"
455        );
456    }
457
458    #[test]
459    fn test_client_enforces_timestamp_granularity() {
460        let redemption_time: Timestamp = DAY_ALIGNED_TIMESTAMP.add_seconds(60 * 60); // not on a day boundary!
461
462        let request_context = BackupAuthCredentialRequestContext::new(&KEY, ACI.into());
463        let request = request_context.get_request();
464        let blinded_credential = request.issue(
465            redemption_time,
466            BackupLevel::Free,
467            BackupCredentialType::Messages,
468            &server_secret_params(),
469            ISSUE_RAND,
470        );
471        assert!(
472            request_context
473                .receive(
474                    blinded_credential,
475                    &server_secret_params().get_public_params(),
476                    redemption_time,
477                )
478                .is_err(),
479            "client should require that timestamp is on a day boundary"
480        );
481    }
482
483    #[test]
484    fn test_backup_level_serialization() {
485        let free_bytes = common::serialization::serialize(&BackupLevel::Free);
486        let paid_bytes = common::serialization::serialize(&BackupLevel::Paid);
487        assert_eq!(free_bytes.len(), 8);
488        assert_eq!(paid_bytes.len(), 8);
489
490        let free_num: u64 = common::serialization::deserialize(&free_bytes).expect("valid u64");
491        let paid_num: u64 = common::serialization::deserialize(&paid_bytes).expect("valid u64");
492        assert_eq!(free_num, 200);
493        assert_eq!(paid_num, 201);
494
495        let free: BackupLevel =
496            common::serialization::deserialize(&free_bytes).expect("valid level");
497        let paid: BackupLevel =
498            common::serialization::deserialize(&paid_bytes).expect("valid level");
499        assert_eq!(free, BackupLevel::Free);
500        assert_eq!(paid, BackupLevel::Paid);
501    }
502
503    #[test]
504    fn test_backup_level_validation() {
505        // Check that the u64 level isn't just truncated to u8.
506        assert_matches!(
507            BackupLevel::try_from(0x100000000000u64 + u64::from(BackupLevel::Free)),
508            Err(_)
509        );
510    }
511}