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