//! Login, session persistence, and Drive-client construction. //! //! A successful login is persisted to the OS keyring as a single JSON blob //! holding the resumable session tokens plus the mailbox password (needed to //! rebuild the key chain on resume). The daemon resumes from this blob with no //! interactive step; the refresh token auto-renews via the HTTP client's 401 //! path, so no fresh 2FA is required until the refresh token itself expires. use keyring::Entry; use proton_drive_rs::{KeySalt, ProtonDriveClient}; use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use serde::{Deserialize, Serialize}; use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT}; use crate::error::{Error, Result}; /// Fixed keyring account name for the single stored session blob. const KEYRING_USER: &str = "session"; /// Everything needed to resume a session unattended, persisted to the keyring. #[derive(Serialize, Deserialize, Clone)] pub struct StoredSession { pub session_id: String, pub username: String, pub user_id: String, pub access_token: String, pub refresh_token: String, pub scopes: Vec, /// `1` = single password, `2` = dual (Proton wire value). pub password_mode: u8, /// Mailbox (data) password — required by `ProtonDriveClient::new` to derive /// the key chain. Lives only in the OS keyring, never on disk in cleartext. pub mailbox_password: String, /// The account's key salts, captured at login. /// /// `core/v4/keys/salts` requires the `locked` scope, which only the access /// token minted by a password login carries: once that token has been /// rotated through `auth/v4/refresh` (as it is on every daemon start after /// the first), the endpoint answers 403 and the key chain can no longer be /// unlocked. Salts only change when the password changes, so store them /// once and seed the Drive client with them on resume. /// /// Empty for blobs written before this field existed; [`resume_client`] /// then falls back to fetching (and, if the token is still scoped for it, /// backfilling) them. #[serde(default)] pub key_salts: Vec, } impl StoredSession { fn to_params(&self) -> ResumeParameters { ResumeParameters { session_id: self.session_id.clone().into(), username: self.username.clone(), user_id: self.user_id.clone().into(), access_token: self.access_token.clone(), refresh_token: self.refresh_token.clone(), scopes: self.scopes.clone(), is_waiting_for_second_factor_code: false, password_mode: match self.password_mode { 1 => PasswordMode::Single, _ => PasswordMode::Dual, }, } } } fn client_config() -> ProtonClientConfiguration { let (app_version, user_agent) = match AppDirs::new() { Ok(dirs) => { let config = dirs.load_config(); (config.app_version, config.user_agent) } Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()), }; ProtonClientConfiguration::new(app_version).with_user_agent(user_agent) } fn keyring_entry() -> Result { Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?) } /// Run an interactive SRP + (optional) 2FA login and persist the session. /// /// `get_totp` is only invoked when the account requires a second factor, so /// callers can defer prompting until it is actually needed. It may be called /// more than once — [`login_interactive`] re-runs the whole login after human /// verification, and a 2FA account needs a fresh code on that second attempt. /// /// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end /// that can present the challenge should use [`login_interactive`] instead. pub async fn login( username: &str, password: &str, get_totp: impl Fn() -> Result, ) -> Result<()> { login_verified(username, password, None, get_totp).await } /// [`login`], able to answer a human-verification gate rather than fail on it. /// /// `get_hv` is invoked only when the API actually gates the login, mirroring how /// `get_totp` is only invoked for accounts with a second factor: neither costs a /// prompt the user did not need. It receives the challenge and returns the token /// the user earned by solving it. /// /// The retry lives here rather than in the front-ends because the recovery is /// not a UI concern: the gated attempt burned its SRP handshake, so the login /// has to start over with the credential attached, and every front-end would /// otherwise have to know that. Gating the *retry* is not handled — a second /// challenge in a row means the token was rejected, and looping on it would trap /// the user in a CAPTCHA that never clears. pub async fn login_interactive( username: &str, password: &str, get_totp: impl Fn() -> Result, get_hv: impl FnOnce(HumanVerification) -> Result, ) -> Result<()> { match login_verified(username, password, None, &get_totp).await { Err(Error::HumanVerificationRequired(hv)) => { let credential = get_hv(*hv)?; login_verified(username, password, Some(&credential), &get_totp).await } other => other, } } /// [`login`], replaying a human-verification token the user has already earned. /// /// A gated login fails with [`Error::HumanVerificationRequired`] carrying the /// challenge. The front-end presents it, collects the solved token, and calls /// this. The login starts over from the SRP handshake rather than resuming: the /// gated attempt never got a session, and its `srp_session` is spent. pub async fn login_verified( username: &str, password: &str, verification: Option<&HumanVerificationCredential>, get_totp: impl Fn() -> Result, ) -> Result<()> { let mut session = ProtonApiSession::begin_verified( client_config(), username, password.as_bytes(), verification, ) .await .map_err(classify_verification_gate)?; if session.is_waiting_for_second_factor() { let code = get_totp()?; session.apply_second_factor_code(code.trim()).await?; } // Grab the key salts while this access token still has the `locked` scope: // after its first refresh it never will again, and without them no later // resume can unlock the key chain. See `StoredSession::key_salts`. let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec()); let key_salts = client.account().key_salts().await?; register_refresh_handler(&session, password.to_owned(), key_salts.clone()); save(&session, password, key_salts).await?; Ok(()) } /// Lift a human-verification gate out of the generic API error into the typed /// variant a front-end can act on. /// /// Only a gate that names a solvable method is converted. A `9001` with no /// `Details`, or one offering only `email`/`sms`, stays a plain API error: /// promoting it would send the UI to open a verification page it cannot /// complete, which reads to the user as a hang rather than a refusal. fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error { let proton_sdk::error::ProtonError::Api(api) = &e else { return e.into(); }; match api.human_verification() { Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)), _ => e.into(), } } /// Persist the session's current tokens, mailbox password and key salts. pub async fn save( session: &ProtonApiSession, mailbox_password: &str, key_salts: Vec, ) -> Result<()> { let tokens = session.current_tokens().await; let stored = StoredSession { session_id: session.session_id().as_str().to_owned(), username: session.username().to_owned(), user_id: session.user_id().as_str().to_owned(), access_token: tokens.access_token, refresh_token: tokens.refresh_token, scopes: session.scopes().to_vec(), password_mode: match session.password_mode() { PasswordMode::Single => 1, PasswordMode::Dual => 2, }, mailbox_password: mailbox_password.to_owned(), key_salts, }; let json = serde_json::to_string(&stored)?; keyring_entry()?.set_password(&json)?; Ok(()) } /// Load the persisted session blob, or `Error::NotLoggedIn` if absent. pub fn load() -> Result { let entry = keyring_entry()?; match entry.get_password() { Ok(json) => Ok(serde_json::from_str(&json)?), Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn), Err(e) => Err(e.into()), } } /// Forget the persisted session (best-effort; absent entry is not an error). pub fn logout() -> Result<()> { match keyring_entry()?.delete_credential() { Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), } } /// Resume a persisted session and build an authenticated Drive client. /// /// Returns `Error::NotLoggedIn` when no session has been saved. The caller is /// responsible for persisting rotated tokens over the session's lifetime via /// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not /// written back leaves the keyring holding a stale (now-invalid) refresh token, /// and the next resume fails with `InvalidRefreshToken`. pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; let session = ProtonApiSession::resume(client_config(), stored.to_params())?; let password = stored.mailbox_password.clone(); // Blobs written before key salts were persisted have none: fetch them once // more and write them back, which only works while the stored access token // still carries the `locked` scope. If it doesn't, the account must be // logged into again — no refresh can restore that scope. let key_salts = if stored.key_salts.is_empty() { register_refresh_handler(&session, password.clone(), Vec::new()); let probe = ProtonDriveClient::new(&session, password.clone().into_bytes()); let salts = probe.account().key_salts().await.map_err(|e| match &e { proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => { Error::ReloginRequired } _ => e.into(), })?; save(&session, &password, salts.clone()).await?; salts } else { stored.key_salts }; register_refresh_handler(&session, password.clone(), key_salts.clone()); let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts); Ok((client, session)) } /// Write the session's current tokens back to the keyring. /// /// The session does not carry the mailbox password (it is only needed to rebuild /// the key chain on resume), so it is re-read from the existing stored blob and /// preserved. Call whenever the session may have rotated its tokens through the /// 401-refresh path so a later [`resume_client`] presents a live refresh token. pub async fn persist(session: &ProtonApiSession) -> Result<()> { let stored = load()?; save(session, &stored.mailbox_password, stored.key_salts).await } fn register_refresh_handler( session: &ProtonApiSession, mailbox_password: String, key_salts: Vec, ) { let session_id = session.session_id().as_str().to_owned(); let username = session.username().to_owned(); let user_id = session.user_id().as_str().to_owned(); let scopes = session.scopes().to_vec(); let password_mode = match session.password_mode() { PasswordMode::Single => 1, PasswordMode::Dual => 2, }; session.http().set_on_tokens_refreshed(move |tokens| { let stored = StoredSession { session_id: session_id.clone(), username: username.clone(), user_id: user_id.clone(), access_token: tokens.access_token, refresh_token: tokens.refresh_token, scopes: scopes.clone(), password_mode, mailbox_password: mailbox_password.clone(), key_salts: key_salts.clone(), }; if let Ok(json) = serde_json::to_string(&stored) && let Ok(entry) = keyring_entry() { if let Err(e) = entry.set_password(&json) { tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring"); } else { tracing::info!("successfully auto-persisted refreshed tokens in keyring"); } } }); }