Skip to main content

proton_sdk/
session.rs

1//! Authenticated API session.
2//!
3//! Mirrors `Proton.Sdk.ProtonApiSession`. [`ProtonApiSession::resume`] rebuilds
4//! a session from previously obtained tokens — the path used by all read
5//! workflows. [`ProtonApiSession::begin`] (SRP password login) is intentionally
6//! deferred, matching the official SDK's stance that login flows live outside
7//! the core Drive scope.
8
9use base64::Engine;
10use base64::engine::general_purpose::STANDARD as BASE64;
11use serde::{Deserialize, Serialize};
12
13use crate::config::ProtonClientConfiguration;
14use crate::crypto;
15use crate::error::{ProtonError, Result};
16use crate::http::{self, ApiHttpClient, Tokens};
17use crate::ids::{SessionId, UserId};
18
19/// Whether the account uses a single password or a separate data password.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum PasswordMode {
22    Single,
23    Dual,
24}
25
26impl PasswordMode {
27    /// Map the wire value (`1` = single, `2` = dual); anything else defaults to dual.
28    fn from_wire(value: i32) -> Self {
29        match value {
30            1 => PasswordMode::Single,
31            _ => PasswordMode::Dual,
32        }
33    }
34}
35
36/// Parameters required to resume a session from persisted credentials.
37#[derive(Debug, Clone)]
38pub struct ResumeParameters {
39    pub session_id: SessionId,
40    pub username: String,
41    pub user_id: UserId,
42    pub access_token: String,
43    pub refresh_token: String,
44    pub scopes: Vec<String>,
45    pub is_waiting_for_second_factor_code: bool,
46    pub password_mode: PasswordMode,
47}
48
49/// An authenticated Proton API session.
50#[derive(Clone)]
51pub struct ProtonApiSession {
52    http: ApiHttpClient,
53    session_id: SessionId,
54    username: String,
55    user_id: UserId,
56    scopes: Vec<String>,
57    password_mode: PasswordMode,
58    is_waiting_for_second_factor: bool,
59}
60
61impl ProtonApiSession {
62    /// Resume a session from previously obtained tokens.
63    pub fn resume(config: ProtonClientConfiguration, params: ResumeParameters) -> Result<Self> {
64        let tokens = Tokens {
65            access_token: params.access_token,
66            refresh_token: params.refresh_token,
67        };
68        let http = ApiHttpClient::new(config, params.session_id.clone(), tokens)?;
69
70        Ok(Self {
71            http,
72            session_id: params.session_id,
73            username: params.username,
74            user_id: params.user_id,
75            scopes: params.scopes,
76            password_mode: params.password_mode,
77            is_waiting_for_second_factor: params.is_waiting_for_second_factor_code,
78        })
79    }
80
81    /// SRP password login.
82    ///
83    /// Mirrors C# `ProtonApiSession.BeginAsync`: initiate an SRP session
84    /// (`auth/v4/info`), run the client handshake, then authenticate
85    /// (`auth/v4`). The returned session is ready for read/write workflows once
86    /// the data password is applied (see CLAUDE.md key chain); if the account
87    /// requires a second factor, [`is_waiting_for_second_factor`] is set and the
88    /// caller must complete 2FA before authorized scopes are granted.
89    ///
90    /// Both API calls are unauthenticated (no session id / bearer), matching the
91    /// reference SDK.
92    pub async fn begin(
93        config: ProtonClientConfiguration,
94        username: &str,
95        password: &[u8],
96    ) -> Result<Self> {
97        let init: SessionInitiationResponse = http::post_unauthenticated(
98            &config,
99            "auth/v4/info",
100            &SessionInitiationRequest { username },
101        )
102        .await?;
103
104        let salt = BASE64
105            .decode(init.salt.trim())
106            .map_err(|e| ProtonError::invalid_operation(format!("decode SRP salt: {e}")))?;
107        let server_ephemeral = BASE64
108            .decode(init.server_ephemeral.trim())
109            .map_err(|e| ProtonError::invalid_operation(format!("decode server ephemeral: {e}")))?;
110
111        let proofs = crypto::generate_proofs(
112            init.version,
113            password,
114            &salt,
115            &init.modulus,
116            &server_ephemeral,
117            crypto::DEFAULT_BIT_LENGTH,
118        )?;
119
120        let auth: AuthenticationResponse = http::post_unauthenticated(
121            &config,
122            "auth/v4",
123            &AuthenticationRequest {
124                username,
125                client_ephemeral: BASE64.encode(&proofs.client_ephemeral),
126                client_proof: BASE64.encode(&proofs.client_proof),
127                srp_session: init.srp_session,
128            },
129        )
130        .await?;
131
132        // Reject a forged server: the server must prove it holds the verifier.
133        let server_proof = BASE64
134            .decode(auth.server_proof.trim())
135            .map_err(|e| ProtonError::invalid_operation(format!("decode server proof: {e}")))?;
136        if server_proof != proofs.expected_server_proof {
137            return Err(ProtonError::invalid_operation(
138                "SRP server proof mismatch — server failed authentication",
139            ));
140        }
141
142        let tokens = Tokens {
143            access_token: auth.access_token,
144            refresh_token: auth.refresh_token,
145        };
146        let http = ApiHttpClient::new(config, auth.session_id.clone(), tokens)?;
147
148        Ok(Self {
149            http,
150            session_id: auth.session_id,
151            username: username.to_owned(),
152            user_id: auth.user_id,
153            scopes: auth.scopes,
154            password_mode: PasswordMode::from_wire(auth.password_mode),
155            is_waiting_for_second_factor: auth
156                .second_factor
157                .map(|f| f.is_enabled())
158                .unwrap_or(false),
159        })
160    }
161
162    /// Submit a second-factor code (`POST auth/v4/2fa`).
163    ///
164    /// Mirrors C# `ProtonApiSession.ApplySecondFactorCodeAsync`: on success the
165    /// server returns the now-elevated scopes; clear the waiting flag and adopt
166    /// them. Must be called on a session whose [`is_waiting_for_second_factor`]
167    /// is set (typically right after [`begin`], before the session is shared
168    /// with a Drive client).
169    pub async fn apply_second_factor_code(&mut self, code: &str) -> Result<()> {
170        let response: ScopesResponse = self
171            .http
172            .post("auth/v4/2fa", &SecondFactorValidationRequest { code })
173            .await?;
174        self.is_waiting_for_second_factor = false;
175        self.scopes = response.scopes;
176        Ok(())
177    }
178
179    /// Refresh the session's authorized scopes (`GET auth/v4/scopes`).
180    ///
181    /// Mirrors C# `ProtonApiSession.RefreshScopesAsync`.
182    pub async fn refresh_scopes(&mut self) -> Result<()> {
183        let response: ScopesResponse = self.http.get("auth/v4/scopes").await?;
184        self.scopes = response.scopes;
185        Ok(())
186    }
187
188    /// End the session server-side (`DELETE auth/v4`).
189    pub async fn end(&self) -> Result<()> {
190        let _: crate::api::ApiResponse = self.http.delete("auth/v4").await?;
191        Ok(())
192    }
193
194    pub fn http(&self) -> &ApiHttpClient {
195        &self.http
196    }
197
198    pub fn session_id(&self) -> &SessionId {
199        &self.session_id
200    }
201
202    pub fn user_id(&self) -> &UserId {
203        &self.user_id
204    }
205
206    pub fn username(&self) -> &str {
207        &self.username
208    }
209
210    pub fn scopes(&self) -> &[String] {
211        &self.scopes
212    }
213
214    pub fn password_mode(&self) -> PasswordMode {
215        self.password_mode
216    }
217
218    /// Whether the account still needs a second-factor code before its scopes
219    /// are fully authorized.
220    pub fn is_waiting_for_second_factor(&self) -> bool {
221        self.is_waiting_for_second_factor
222    }
223
224    /// Snapshot current tokens for persistence (they may have rotated on refresh).
225    pub async fn current_tokens(&self) -> Tokens {
226        self.http.current_tokens().await
227    }
228}
229
230#[derive(Serialize)]
231struct SessionInitiationRequest<'a> {
232    #[serde(rename = "Username")]
233    username: &'a str,
234}
235
236#[derive(Deserialize)]
237struct SessionInitiationResponse {
238    #[serde(rename = "Version")]
239    version: i32,
240    /// Cleartext-signed modulus (verified before use).
241    #[serde(rename = "Modulus")]
242    modulus: String,
243    /// Base64 server ephemeral `B`.
244    #[serde(rename = "ServerEphemeral")]
245    server_ephemeral: String,
246    /// Base64 login salt.
247    #[serde(rename = "Salt")]
248    salt: String,
249    #[serde(rename = "SRPSession")]
250    srp_session: String,
251}
252
253#[derive(Serialize)]
254struct AuthenticationRequest<'a> {
255    #[serde(rename = "Username")]
256    username: &'a str,
257    /// Base64 client ephemeral `A`.
258    #[serde(rename = "ClientEphemeral")]
259    client_ephemeral: String,
260    /// Base64 client proof `M1`.
261    #[serde(rename = "ClientProof")]
262    client_proof: String,
263    #[serde(rename = "SRPSession")]
264    srp_session: String,
265}
266
267#[derive(Deserialize)]
268struct AuthenticationResponse {
269    #[serde(rename = "UID")]
270    session_id: SessionId,
271    #[serde(rename = "UserID")]
272    user_id: UserId,
273    /// Base64 server proof `M2`.
274    #[serde(rename = "ServerProof")]
275    server_proof: String,
276    #[serde(rename = "AccessToken")]
277    access_token: String,
278    #[serde(rename = "RefreshToken")]
279    refresh_token: String,
280    #[serde(rename = "Scopes", default)]
281    scopes: Vec<String>,
282    #[serde(rename = "PasswordMode")]
283    password_mode: i32,
284    #[serde(rename = "2FA")]
285    second_factor: Option<SecondFactorInfo>,
286}
287
288#[derive(Serialize)]
289struct SecondFactorValidationRequest<'a> {
290    #[serde(rename = "TwoFactorCode")]
291    code: &'a str,
292}
293
294#[derive(Deserialize)]
295struct ScopesResponse {
296    #[serde(rename = "Scopes", default)]
297    scopes: Vec<String>,
298}
299
300#[derive(Deserialize)]
301struct SecondFactorInfo {
302    #[serde(rename = "Enabled", default)]
303    enabled: i32,
304}
305
306impl SecondFactorInfo {
307    fn is_enabled(&self) -> bool {
308        self.enabled != 0
309    }
310}